From 1586b4bd1ac17fd313aaeae96d0b690f946a48bc Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 13 Aug 2026 12:38:08 -0400 Subject: [PATCH] dwa --- .../core/nms/v26_2_R1/CustomBiomeSource.java | 12 ++-- .../nms/v26_2_R1/ImportedFeatureStage.java | 12 +++- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 22 ++++-- ...ustomBiomeSourceStructureContractTest.java | 5 +- .../src/main/java/art/arcane/iris/Iris.java | 15 ++-- .../core/PendingWorldReplacementManager.java | 16 ++++- .../iris/core/commands/CommandPack.java | 4 ++ .../iris/core/commands/CommandStudio.java | 8 +-- .../iris/nativegen/NativeBuildFutures.java | 31 ++++++++ .../NativeStructureTerrainIntegrator.java | 71 +++++++++++++------ .../nativegen/NativeStructureVolumeIndex.java | 13 +++- .../arcane/iris/modded/ModdedPlatform.java | 15 +++- .../iris/modded/ModdedWorldManager.java | 12 ++++ .../arcane/iris/modded/api/IrisModdedAPI.java | 18 +++-- .../command/ModdedCommandSuggestions.java | 12 ++++ .../modded/command/ModdedCommandTree.java | 7 +- .../modded/command/ModdedObjectCommands.java | 24 +++++-- .../service/ModdedChunkUpdateService.java | 19 +++-- .../iris/core/gui/NoiseExplorerGUI.java | 4 +- .../arcane/iris/core/gui/PregeneratorJob.java | 11 ++- .../art/arcane/iris/core/gui/VisionGUI.java | 16 ++--- .../lifecycle/BukkitWorldConfiguration.java | 7 ++ .../core/lifecycle/DirectoryDurability.java | 42 +++++++++++ .../core/lifecycle/WorldLifecycleSupport.java | 19 ++++- .../lifecycle/WorldReplacementBootstrap.java | 36 ++++++++-- .../lifecycle/WorldReplacementFilesystem.java | 52 +++++++------- .../lifecycle/WorldReplacementJournal.java | 58 +++++++++------ .../BukkitCommandMessagesExtended.java | 10 +++ .../art/arcane/iris/core/service/TreeSVC.java | 19 +++-- .../core/splash/IrisSplashPackScanner.java | 8 ++- .../arcane/iris/core/tools/IrisConverter.java | 3 + .../arcane/iris/core/tools/IrisToolbelt.java | 12 +++- .../iris/core/tools/WorldMaintenance.java | 38 +++++----- .../arcane/iris/engine/EngineHotloader.java | 3 +- .../iris/engine/EngineRuntimeBuilder.java | 10 ++- .../engine/data/chunk/LinkedTerrainChunk.java | 6 +- .../framework/NativeStructureVolume.java | 39 +++++++++- .../iris/engine/object/IrisObjectIO.java | 12 +++- .../engine/platform/BukkitChunkGenerator.java | 5 +- .../iris/util/common/math/VectorMath.java | 15 ---- .../util/common/parallel/BurstExecutor.java | 5 ++ .../iris/util/common/parallel/MultiBurst.java | 2 +- .../hunk/view/ChunkDataHunkHolder.java | 6 ++ core/src/main/resources/languages/de_DE.json | 2 + core/src/main/resources/languages/es_ES.json | 2 + core/src/main/resources/languages/fi_FI.json | 2 + core/src/main/resources/languages/fr_FR.json | 2 + core/src/main/resources/languages/he_IL.json | 2 + core/src/main/resources/languages/it_IT.json | 2 + core/src/main/resources/languages/ja-JP.json | 2 + core/src/main/resources/languages/ko_KR.json | 2 + core/src/main/resources/languages/lt_LT.json | 2 + core/src/main/resources/languages/nl_NL.json | 2 + core/src/main/resources/languages/pl_PL.json | 2 + core/src/main/resources/languages/pt_PT.json | 2 + core/src/main/resources/languages/ru_RU.json | 2 + core/src/main/resources/languages/tr_TR.json | 2 + core/src/main/resources/languages/vi_VI.json | 2 + core/src/main/resources/languages/zh_CN.json | 2 + core/src/main/resources/languages/zh_TW.json | 2 + .../WorldLifecycleUnloadAsyncTest.java | 37 ++++++++++ .../WorldReplacementBootstrapTest.java | 42 ++++++++--- .../WorldReplacementFilesystemTest.java | 17 +++++ .../art/arcane/iris/spi/IrisLoggingTest.java | 23 +++++- .../art/arcane/iris/probe/StubTileData.java | 1 + .../arcane/iris/probe/StubTileDataTest.java | 18 +++++ .../java/art/arcane/iris/spi/IrisLogging.java | 5 ++ .../art/arcane/iris/spi/IrisPlatform.java | 13 ++++ 68 files changed, 733 insertions(+), 211 deletions(-) create mode 100644 adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeBuildFutures.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index c863df632..630910985 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -57,6 +57,7 @@ public class CustomBiomeSource extends BiomeSource { private volatile KMap> customBiomes; private volatile Map> vanillaSpawnBiomes; private volatile IrisDimension cacheDimension; + private volatile int cacheRuntimeId; public CustomBiomeSource(long seed, Engine engine, World world) { this.engine = engine; @@ -67,6 +68,7 @@ public class CustomBiomeSource extends BiomeSource { this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome); this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine); this.cacheDimension = engine.getDimension(); + this.cacheRuntimeId = engine.getCacheID(); } private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine) { @@ -246,11 +248,11 @@ public class CustomBiomeSource extends BiomeSource { } GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_spawn_biome"); if (lease == null) { - throw new IllegalStateException("Iris spawn biome lookup was rejected during an engine transition"); + return null; } try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { if (!isRuntimeAvailable()) { - throw new IllegalStateException("Iris spawn biome lookup has no active engine runtime"); + return null; } ensureCachesCurrent(); return vanillaSpawnBiomes.get(biome.value()); @@ -434,11 +436,12 @@ public class CustomBiomeSource extends BiomeSource { private void ensureCachesCurrent() { IrisDimension dimension = engine.getDimension(); - if (cacheDimension == dimension) { + int runtimeId = engine.getCacheID(); + if (cacheDimension == dimension && cacheRuntimeId == runtimeId) { return; } synchronized (this) { - if (cacheDimension == dimension) { + if (cacheDimension == dimension && cacheRuntimeId == runtimeId) { return; } KMap> refreshedCustomBiomes = fillCustomBiomes( @@ -451,6 +454,7 @@ public class CustomBiomeSource extends BiomeSource { customBiomes = refreshedCustomBiomes; vanillaSpawnBiomes = refreshedSpawnBiomes; cacheDimension = dimension; + cacheRuntimeId = runtimeId; } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java index 96c94540f..db370e592 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java @@ -57,6 +57,7 @@ final class ImportedFeatureStage { private final Engine engine; private volatile FeatureTable featureTable; private volatile IrisDimension inertDimension; + private volatile int settledRuntimeId; ImportedFeatureStage(Engine engine) { this.engine = engine; @@ -86,18 +87,23 @@ final class ImportedFeatureStage { */ void prepare(WorldGenLevel level) { IrisDimension dimension = engine.getDimension(); - if (settled(dimension)) { + int runtimeId = engine.getCacheID(); + if (settled(dimension, runtimeId)) { return; } synchronized (this) { - if (settled(dimension)) { + if (settled(dimension, runtimeId)) { return; } build(level, dimension); + settledRuntimeId = runtimeId; } } - private boolean settled(IrisDimension dimension) { + private boolean settled(IrisDimension dimension, int runtimeId) { + if (settledRuntimeId != runtimeId) { + return false; + } FeatureTable current = featureTable; if (current != null && current.dimension() == dimension) { return true; diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index a54f88485..9e646e4f9 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -128,6 +128,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private final int runtimeHeight; private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); + private volatile IrisDimension spawnTableDimension; + private volatile int spawnTableRuntimeId; private final ImportedFeatureStage importedFeatures; private final AtomicReference retainedStudioStructureState = new AtomicReference<>(); private volatile ReachableStructureCache reachableStructureCache; @@ -310,18 +312,19 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private Set reachableStructureKeys(ServerLevel level) { IrisDimension dimension = engine.getDimension(); + int runtimeId = engine.getCacheID(); ReachableStructureCache cached = reachableStructureCache; - if (cached != null && cached.dimension() == dimension) { + if (cached != null && cached.dimension() == dimension && cached.runtimeId() == runtimeId) { return cached.keys(); } synchronized (this) { cached = reachableStructureCache; - if (cached != null && cached.dimension() == dimension) { + if (cached != null && cached.dimension() == dimension && cached.runtimeId() == runtimeId) { return cached.keys(); } Set reachable = Set.copyOf( VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource)); - reachableStructureCache = new ReachableStructureCache(dimension, reachable); + reachableStructureCache = new ReachableStructureCache(dimension, runtimeId, reachable); return reachable; } } @@ -738,6 +741,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return explicitSpawns; } + IrisDimension spawnDimension = engine.getDimension(); + int spawnRuntimeId = engine.getCacheID(); + if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) { + synchronized (mergedSpawnTables) { + if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) { + mergedSpawnTables.clear(); + spawnTableDimension = spawnDimension; + spawnTableRuntimeId = spawnRuntimeId; + } + } + } SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype); return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns)); } @@ -1160,7 +1174,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private record SpawnTableKey(Biome biome, MobCategory category) { } - private record ReachableStructureCache(IrisDimension dimension, Set keys) { + private record ReachableStructureCache(IrisDimension dimension, int runtimeId, Set keys) { } private record StructureStepCache(Registry registry, List> structures) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 7f34dd8c6..8c4d17d88 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -26,8 +26,9 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_possible_biomes\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_spawn_biome\")")); - assertTrue(source.contains("Iris spawn biome lookup was rejected during an engine transition")); - assertTrue(source.contains("Iris spawn biome lookup has no active engine runtime")); + assertFalse(source.contains("Iris spawn biome lookup was rejected during an engine transition")); + assertFalse(source.contains("Iris spawn biome lookup has no active engine runtime")); + assertTrue(source.contains("vanillaSpawnBiomes.get(biome.value())")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_biome\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_biomes_within\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 04775fe09..8e2f81126 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -613,18 +613,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { // (listeners, shutdown hook, replacement journals) are the safety-critical ones. // Only services that actually enabled get listeners and a later onDisable. enabledServices.clear(); - IrisService firstFailedService = null; for (IrisService service : orderedServices) { try { service.onEnable(); enabledServices.add(service); } catch (Throwable e) { - if (firstFailedService == null) { - firstFailedService = service; - } + // A service failure is NOT a datapack validation failure: the admission gate + // must never lock every login over a broken cosmetic service. Log loudly, + // continue degraded, and clean up whatever the partial onEnable started + // (a failed service is excluded from the teardown loop). Iris.reportError("Failed to enable " + service.getClass().getSimpleName() + "; continuing with a degraded runtime.", e); - // A failed service is excluded from the teardown loop, so clean up whatever - // its partial onEnable started right here, best-effort. try { service.onDisable(); } catch (Throwable cleanup) { @@ -632,11 +630,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } } - if (firstFailedService != null) { - IrisStartupValidation.markDatapacksInvalid("Iris service " - + firstFailedService.getClass().getSimpleName() - + " failed to enable; world creation and player admission are locked. Check the log above."); - } for (IrisService service : enabledServices) { try { registerListener(service); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index cf81f83f4..62552ef1a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -468,7 +468,21 @@ public final class PendingWorldReplacementManager implements Listener { } private List loadTransactions() throws IOException { - return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath()); + Path levelRoot = IrisWorldStorage.levelRoot().toPath(); + List transactions = WorldReplacementJournal.load(dataDirectory(), levelRoot); + List applicable = new ArrayList<>(transactions.size()); + for (Transaction transaction : transactions) { + // A journal staged against another level root is not this server's transaction; + // skip it (the bootstrap already told the operator how to resolve it). + if (WorldReplacementJournal.appliesTo(transaction, levelRoot)) { + applicable.add(transaction); + } else { + Iris.warn("Ignoring pending world replacement " + transaction.id() + " for " + + transaction.worldKey() + "; it was staged against level root " + + transaction.levelRoot() + "."); + } + } + return applicable; } private void writeTransaction(Transaction transaction) throws IOException { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java index 1f778fb55..8f8c32b00 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java @@ -263,6 +263,10 @@ public class CommandPack implements DirectorExecutor { for (File packDirectory : packDirectories) { PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); if (result == null) { + // The boot cache only loads when it covers every pack, so a partial write is + // pointless - but say so instead of silently skipping the persist forever. + Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() + + "\" has no registered result. Run /iris pack validate all to repopulate it."); return; } results.add(result); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 8096c17d0..56ccdc9e0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -329,8 +329,7 @@ public class CommandStudio implements DirectorExecutor { return; } if (radius <= 0 || radius > 2048) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); - sender().sendMessage("Radius must be between 1 and 2048 chunks."); + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE)); return; } var sender = sender(); @@ -393,7 +392,8 @@ public class CommandStudio implements DirectorExecutor { data.forEach((k, v) -> sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_MESSAGE, MessageArgument.untrusted("k", k), MessageArgument.untrusted("value", loader.load(k).getRarity()), MessageArgument.untrusted("value2", Form.f((double) v.get() / totalTasks * 100, 2))))); } catch (Throwable e) { Iris.reportError(e); - sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); + sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REGIONS_SCAN_FAILED, + MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); } finally { if (c != -1) { J.car(c); @@ -712,7 +712,7 @@ public class CommandStudio implements DirectorExecutor { } } - @Director(aliases = "find-objects", description = "Capture an IGenData chunk report for nearby chunks", descriptionKey = "iris.director.commandstudio.director.capture_igendata_chunk_report_nearby_chunks") + @Director(aliases = "find-objects", description = "Capture an IGenData chunk report for nearby chunks", descriptionKey = "iris.director.commandstudio.director.capture_igendata_chunk_report_nearby_chunks", origin = DirectorOrigin.PLAYER) public void objects() { if (!IrisToolbelt.isIrisWorld(player().getWorld())) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_BE_IRIS_WORLD)); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeBuildFutures.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeBuildFutures.java new file mode 100644 index 000000000..d4782f6f1 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeBuildFutures.java @@ -0,0 +1,31 @@ +package art.arcane.iris.nativegen; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Shared waiter for the racy build caches in this package: the loser of a putIfAbsent race + * must surface the same exception the builder threw, not a CompletionException wrapper. + */ +final class NativeBuildFutures { + private NativeBuildFutures() { + } + + static T awaitBuild(CompletableFuture future, String what) { + try { + return future.join(); + } catch (CompletionException error) { + Throwable cause = error.getCause(); + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + if (cause instanceof Error fatal) { + throw fatal; + } + if (cause == null) { + throw error; + } + throw new IllegalStateException(what + " failed", cause); + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java index 25fee3811..47eeaddb3 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java @@ -39,11 +39,13 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp import java.util.ArrayList; import java.util.BitSet; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -73,8 +75,11 @@ public final class NativeStructureTerrainIntegrator { Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY, Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE, Blocks.RED_SANDSTONE, Blocks.SCULK); - private static final Map CARVE_FOOTPRINTS = - new LinkedHashMap<>(16, 0.75F, true); + // Weakly keyed on the StructureStart so retired starts (world unload, generation done) + // are collectable instead of pinned forever by a process-wide static; values hold only + // primitives, so there is no value-to-key cycle defeating the weak keys. + private static final Map> CARVE_FOOTPRINTS = + new WeakHashMap<>(16); private static final ConcurrentHashMap> CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>(); private static int cachedCarveCells; @@ -531,6 +536,15 @@ public final class NativeStructureTerrainIntegrator { if (active != null) { return awaitCarveFootprint(active); } + // Close the check-then-claim window: a racer whose cache check missed before the + // previous builder cached can claim the build slot after that builder retired it, + // and would rebuild a second instance without this re-check. + StructureCarvingFootprint published = cachedCarveFootprint(key); + if (published != null) { + build.complete(published); + CARVE_FOOTPRINT_BUILDS.remove(key, build); + return published; + } try { StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns( sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS); @@ -551,53 +565,64 @@ public final class NativeStructureTerrainIntegrator { static int cachedCarveFootprintCells() { synchronized (CARVE_FOOTPRINTS) { + cachedCarveCells = liveCarveCells(); return cachedCarveCells; } } + private static int liveCarveCells() { + int total = 0; + for (Map perStart : CARVE_FOOTPRINTS.values()) { + for (CachedCarveFootprint cached : perStart.values()) { + total += cached.cells(); + } + } + return total; + } + static int maximumCachedCarveFootprintCells() { return MAX_CACHED_CARVE_CELLS; } private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) { synchronized (CARVE_FOOTPRINTS) { - CachedCarveFootprint cached = CARVE_FOOTPRINTS.get(key); + Map perStart = CARVE_FOOTPRINTS.get(key.start()); + CachedCarveFootprint cached = perStart == null ? null : perStart.get(key.padding()); return cached == null ? null : cached.footprint(); } } private static StructureCarvingFootprint awaitCarveFootprint( CompletableFuture future) { - try { - return future.join(); - } catch (CompletionException error) { - Throwable cause = error.getCause(); - if (cause instanceof RuntimeException runtime) { - throw runtime; - } - if (cause instanceof Error fatal) { - throw fatal; - } - throw new IllegalStateException("Native structure carve footprint build failed", cause); - } + return NativeBuildFutures.awaitBuild(future, "Native structure carve footprint build"); } private static void cacheCarveFootprint(CarveFootprintKey key, StructureCarvingFootprint footprint) { int cells = Math.multiplyExact(footprint.width(), footprint.depth()); synchronized (CARVE_FOOTPRINTS) { - CachedCarveFootprint previous = CARVE_FOOTPRINTS.remove(key); + // Weak keys expunge silently as the GC drops retired starts; re-derive the live + // total so the cell budget tracks reality instead of drifting. + cachedCarveCells = liveCarveCells(); + Map perStart = + CARVE_FOOTPRINTS.computeIfAbsent(key.start(), ignored -> new LinkedHashMap<>(4)); + CachedCarveFootprint previous = perStart.remove(key.padding()); if (previous != null) { cachedCarveCells -= previous.cells(); } - while (!CARVE_FOOTPRINTS.isEmpty() - && cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS) { - Map.Entry eldest = - CARVE_FOOTPRINTS.entrySet().iterator().next(); - cachedCarveCells -= eldest.getValue().cells(); - CARVE_FOOTPRINTS.remove(eldest.getKey()); + Iterator>> eviction = + CARVE_FOOTPRINTS.entrySet().iterator(); + while (cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS && eviction.hasNext()) { + Map.Entry> victim = eviction.next(); + if (victim.getKey() == key.start()) { + continue; + } + for (CachedCarveFootprint evicted : victim.getValue().values()) { + cachedCarveCells -= evicted.cells(); + } + eviction.remove(); } - CARVE_FOOTPRINTS.put(key, new CachedCarveFootprint(footprint, cells)); + perStart.put(key.padding(), new CachedCarveFootprint(footprint, cells)); cachedCarveCells += cells; } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 7d39615ce..6c80b2ce6 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -329,7 +329,18 @@ public final class NativeStructureVolumeIndex { CompletableFuture> future = new CompletableFuture<>(); CompletableFuture> existing = builds.putIfAbsent(key, future); if (existing != null) { - return existing.join(); + return NativeBuildFutures.awaitBuild(existing, "Native structure volume build"); + } + + // Close the check-then-claim window: a racer whose cache check missed before the + // previous builder cached could claim the build slot after it retired and rebuild. + synchronized (cache) { + KList published = cache.get(key); + if (published != null) { + future.complete(published); + builds.remove(key, future); + return published; + } } try { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index df5b1d0ff..fe2c9420b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -236,6 +236,19 @@ public final class ModdedPlatform implements IrisPlatform { */ @Override public void reportError(Throwable error) { + reportThrottled("Iris reported error", error); + } + + /** + * Contextual reports run the same throttle and emit exactly one trace through the modded + * log; the SPI default's raw stderr copy would bypass the per-signature suppression. + */ + @Override + public void reportError(String context, Throwable error) { + reportThrottled(context == null || context.isBlank() ? "Iris reported error" : context, error); + } + + private void reportThrottled(String message, Throwable error) { if (error == null) { return; } @@ -247,7 +260,7 @@ public final class ModdedPlatform implements IrisPlatform { sink.accept(error); return; } - ModdedIrisLog.error("Iris reported error", error); + ModdedIrisLog.error(message, error); Consumer capture = CAPTURE_SINK; if (capture != null) { try { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 26a165498..36cad303c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -800,6 +800,18 @@ public final class ModdedWorldManager implements EngineWorldManager { } if (!mantleWarmupExecutorStopped) { try { + // Drain, don't interrupt: an interrupt inside a FileChannel plate read closes + // the channel and the read-failure fallback installs an empty plate that a + // later flush persists over real data. Queued tasks no-op on the closed flag, + // so the await only covers a single in-flight load; escalate on timeout only. + mantleWarmupExecutor.shutdown(); + if (!mantleWarmupExecutor.awaitTermination(5L, java.util.concurrent.TimeUnit.SECONDS)) { + IrisLogging.warn("Iris mantle warm-up did not stop before world manager close; forcing interrupt"); + mantleWarmupExecutor.shutdownNow(); + } + mantleWarmupExecutorStopped = true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); mantleWarmupExecutor.shutdownNow(); mantleWarmupExecutorStopped = true; } catch (Throwable e) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java index 340cd5cfb..43b4dcf47 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/IrisModdedAPI.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded.api; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.command.ModdedPregenJob; import net.minecraft.server.level.ServerLevel; @@ -130,14 +131,17 @@ public final class IrisModdedAPI { * never create or load one - or nothing of {@code type} is stored there. A {@code y} outside the engine's * height range reads as null rather than throwing. * - * @throws IllegalStateException if the engine's mantle has already been closed + * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown. */ public static T getMantleData(ServerLevel level, int x, int y, int z, Class type) { Engine engine = getEngine(level); if (engine == null) { return null; } - return engine.getMantle().getMantle().get(x, y - engine.getMinHeight(), z, type); + // Lease-gated so the engine shutdown drain covers this public accessor; a mantle + // mid-close refuses the lease instead of racing the region flush. + return EngineLifecycleTasks.call(engine, "modded_api_mantle_get", + () -> engine.getMantle().getMantle().get(x, y - engine.getMinHeight(), z, type), null); } /** @@ -151,28 +155,30 @@ public final class IrisModdedAPI { * Values written under a custom type are discarded when Iris trims a mantle region unless the type is * declared with {@link #retainMantleDataForSlice(Class)}. * - * @throws IllegalStateException if the engine's mantle has already been closed + * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown. */ public static void setMantleData(ServerLevel level, int x, int y, int z, T data) { Engine engine = getEngine(level); if (engine == null || data == null) { return; } - engine.getMantle().getMantle().set(x, y - engine.getMinHeight(), z, data); + EngineLifecycleTasks.run(engine, "modded_api_mantle_set", + () -> engine.getMantle().getMantle().set(x, y - engine.getMinHeight(), z, data)); } /** * Removes any mantle value of {@code type} at world coordinates. A non-Iris level or an out-of-range * {@code y} is a silent no-op. Like a write, this creates the mantle region if it is absent. * - * @throws IllegalStateException if the engine's mantle has already been closed + * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown. */ public static void deleteMantleData(ServerLevel level, int x, int y, int z, Class type) { Engine engine = getEngine(level); if (engine == null) { return; } - engine.getMantle().getMantle().remove(x, y - engine.getMinHeight(), z, type); + EngineLifecycleTasks.run(engine, "modded_api_mantle_delete", + () -> engine.getMantle().getMantle().remove(x, y - engine.getMinHeight(), z, type)); } /** diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index 1f593aa89..ed5200a3d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -69,6 +69,9 @@ final class ModdedCommandSuggestions { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final int TAB_FAILURE_KEYS_MAX = 256; private static final Set REPORTED_TAB_FAILURES = ConcurrentHashMap.newKeySet(); + private static final long PACK_NAME_CACHE_TTL_MS = 3_000L; + private static volatile Set cachedPackNames; + private static volatile long cachedPackNamesAt; private ModdedCommandSuggestions() { } @@ -228,6 +231,13 @@ final class ModdedCommandSuggestions { private static CompletableFuture suggestPackNames(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); + // Suggestion packets arrive per keystroke; a short-lived snapshot keeps the directory + // walk off the hot path without ever serving stale names for more than a few seconds. + long now = System.currentTimeMillis(); + Set cached = cachedPackNames; + if (cached != null && now - cachedPackNamesAt < PACK_NAME_CACHE_TTL_MS) { + return SharedSuggestionProvider.suggest(cached, builder); + } Set names = new TreeSet<>(); names.add("overworld"); try { @@ -249,6 +259,8 @@ final class ModdedCommandSuggestions { } catch (Throwable e) { warnTabFailure("pack names", context.getSource(), e); } + cachedPackNames = names; + cachedPackNamesAt = now; return SharedSuggestionProvider.suggest(names, builder); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index bbf5a2a91..3ffaa5fca 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -70,9 +70,10 @@ final class ModdedCommandTree { .then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES) .executes((CommandContext context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension"))))); - // ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS; the /iris what overlays are read-only, - // so the root builder relaxes the whole subtree here instead of forking that file. - root.then(ModdedWhatCommands.tree().requires(READ_ONLY)); + // ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS and keeps that gate: + // "what markers" drives lease-gated 9x9 mantle scans, and the Bukkit twin puts the + // whole tree behind an op-default permission. requires() would overwrite, not AND. + root.then(ModdedWhatCommands.tree()); root.then(teleportTree("teleport")); root.then(teleportTree("tp")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java index e58ed694a..0e2607b62 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectCommands.java @@ -285,7 +285,20 @@ public final class ModdedObjectCommands { return 0; } File file = new File(engine.getData().getDataFolder(), "objects" + File.separator + name.replace('/', File.separatorChar) + ".iob"); - if (file.exists() && !overwrite) { + // Atomic path claim ON the server thread: the async write below turned a plain + // exists() check into a TOCTOU where two rapid saves interleaved into one file. + File parent = file.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + boolean claimed; + try { + claimed = file.createNewFile(); + } catch (IOException e) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); + return 0; + } + if (!claimed && !overwrite) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FILE_ALREADY_EXISTS_USE_IRIS_OBJECT_SAVE_OVERWRITE, MessageArgument.untrusted("name", name))); return 0; } @@ -295,15 +308,16 @@ public final class ModdedObjectCommands { // async-safe), but the disk write of a local, unshared object is not tick work. IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved); MinecraftServer server = source.getServer(); + boolean finalClaimed = claimed; J.a(() -> { - File parent = file.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } try { object.write(file); } catch (IOException e) { LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e); + if (finalClaimed) { + // Never leave a 0-byte claim file permanently blocking non-overwrite saves. + file.delete(); + } server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))))); return; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index cd5867744..6e19788cf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -85,13 +85,16 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { ExecutorService active = warmupExecutor; warmupExecutor = null; if (active != null) { - // Drop queued warm-ups (pure prefetch) and AWAIT: the very next shutdown stage - // closes every Mantle, and an in-flight mantle.getChunk would fault a plate back - // in after the close-time flush. - active.shutdownNow(); + // NEVER interrupt first: an interrupt inside a FileChannel plate read closes the + // channel (ClosedByInterruptException) and the read-failure fallback installs an + // EMPTY plate that the very next shutdown stage flushes over real data. Queued + // warm-ups self-cancel (warmupExecutor is already null), so shutdown() + await + // only waits on the single in-flight load; escalate only on timeout. + active.shutdown(); try { if (!active.awaitTermination(5L, TimeUnit.SECONDS)) { - IrisLogging.warn("Iris mantle warm-up did not stop before engine close"); + IrisLogging.warn("Iris mantle warm-up did not stop before engine close; forcing interrupt"); + active.shutdownNow(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -279,6 +282,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } try { active.execute(() -> { + // Self-cancel on shutdown: warmupExecutor is nulled first in onDisable, so + // queued prefetches no-op instantly and only an in-flight load is awaited. + if (warmupExecutor == null || mantle.isClosed()) { + warmupQueue.remove(key); + return; + } try { mantle.getChunk(chunkX, chunkZ); } catch (Throwable e) { diff --git a/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java index 6ec3a1a74..082ff59f5 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java @@ -492,7 +492,9 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener { } long sleepMs = Math.max(1, 16 - (long) p.getMilliseconds()); - EventQueue.invokeLater(() -> { + // Pace on a worker, not the EDT: a sleep queued on the event thread blocks painting + // and input for the whole frame budget. repaint() marshals itself back. + J.a(() -> { J.sleep(sleepMs); repaint(); }); diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index a7c402cbd..880e295b0 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -124,7 +124,16 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { service.shutdown(); throw new IllegalStateException("An Iris pregeneration job is already running; stop it first."); } - worker.start(); + try { + worker.start(); + } catch (Throwable startFailure) { + // Un-publish: a worker that never started can never run onClose(), so nothing + // else would ever clear this instance via the normal path. + instance.compareAndSet(this, null); + monitor.close(); + service.shutdown(); + throw startFailure; + } } private void computeBounds() { diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 2900b731c..895604f5f 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -528,14 +528,16 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener }); return; } + double frameMs = 0; try { - paintBody(gx); + frameMs = paintBody(gx); } catch (Throwable e) { IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); } finally { // The repaint loop is entirely self-driven from here; it must survive any render - // exception or the window freezes on a stale frame permanently. - long sleepMs = eco ? 32 : 16; + // exception or the window freezes on a stale frame permanently. Frame-time + // compensated so a slow frame does not stack a full sleep on top of itself. + long sleepMs = Math.max(1, (eco ? 32 : 16) - (long) frameMs); J.a(() -> { J.sleep(sleepMs); repaint(); @@ -543,7 +545,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener } } - private void paintBody(Graphics gx) { + private double paintBody(Graphics gx) { velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36; oxp = lerp(oxp, ox, 0.36); @@ -630,11 +632,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener handleFollow(); renderOverlays(g, p.getMilliseconds()); - - if (!isVisible() || !getParent().isVisible()) { - return; - } - + return p.getMilliseconds(); } private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index 421a84b43..920512422 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -255,6 +255,13 @@ public final class BukkitWorldConfiguration { } Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING); } + // bukkit.yml is the commit record the bootstrap reconciles against; its rename + // needs the same directory-durability barrier the journal and world moves get. + if (requireAtomicReplacement) { + DirectoryDurability.forceDirectoryRequired(parent); + } else { + DirectoryDurability.forceDirectoryAfterCommit(parent, "A bukkit.yml world configuration change"); + } } finally { Files.deleteIfExists(staged); } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java b/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java new file mode 100644 index 000000000..8d645c115 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/DirectoryDurability.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisLogging; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +/** + * Directory-level durability barrier shared by every atomic publication in the + * world-replacement protocol (journal writes, world-directory moves, bukkit.yml + * saves). A rename is only durable once its parent directory has been fsynced; + * skipped on Windows, where directory handles cannot be forced. + */ +final class DirectoryDurability { + private DirectoryDurability() { + } + + static void forceDirectoryRequired(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } catch (UnsupportedOperationException failure) { + throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); + } + } + + static void forceDirectoryAfterCommit(Path directory, String context) { + try { + forceDirectoryRequired(directory); + } catch (IOException failure) { + IrisLogging.reportError( + context + " completed, but its parent directory could not be durability-synced.", + failure + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java index 4975af635..33bc592ac 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java @@ -15,6 +15,7 @@ import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.world.WorldUnloadEvent; import org.bukkit.generator.ChunkGenerator; import java.io.File; @@ -479,6 +480,10 @@ final class WorldLifecycleSupport { } } + if (!announceManualWorldUnload(world)) { + return CompletableFuture.completedFuture(false); + } + if (save) { world.save(); } @@ -496,6 +501,12 @@ final class WorldLifecycleSupport { } } + static boolean announceManualWorldUnload(World world) { + WorldUnloadEvent unloadEvent = new WorldUnloadEvent(world); + Bukkit.getPluginManager().callEvent(unloadEvent); + return !unloadEvent.isCancelled(); + } + private static CompletableFuture unloadWorldViaAsyncApi(CapabilitySnapshot capabilities, World world, boolean save) { if (capabilities.unloadWorldAsyncMethod() == null || capabilities.bukkitServer() == null) { return null; @@ -597,9 +608,11 @@ final class WorldLifecycleSupport { Field worldsField = CapabilityResolution.resolveField(bukkitServer.getClass(), "worlds"); Object rawWorlds = worldsField.get(bukkitServer); if (rawWorlds instanceof Map map) { - map.remove(WorldIdentity.key(world)); - map.remove(WorldIdentity.serialize(world)); - map.remove(world.getName()); + boolean removed = map.values().removeIf(candidate -> candidate == world); + if (!removed) { + throw new IllegalStateException( + "CraftServer world registry did not contain \"" + world.getName() + "\"."); + } } } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java index 9d2d44690..c15dd9417 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java @@ -38,7 +38,20 @@ public final class WorldReplacementBootstrap { int published = 0; int rolledBack = 0; int retained = 0; + int skipped = 0; for (Transaction transaction : transactions) { + // A journal recorded against another level root or logical world name is not this + // boot's transaction; skip it with a pointer instead of aborting the whole startup. + if (!WorldReplacementJournal.appliesTo(transaction, requiredLevelRoot)) { + skipped++; + requiredFeedback.accept("Skipping pending world replacement " + transaction.id() + + " for " + transaction.worldKey() + ": it was staged against level root " + + transaction.levelRoot() + " but this server now uses " + requiredLevelRoot + + ". Remove " + requiredDataDirectory.resolve(WorldReplacementJournal.DIRECTORY_NAME) + .resolve(transaction.id() + ".properties") + + " or restore the previous level-name to resolve it."); + continue; + } ReconcileAction action = reconcileTransaction( requiredDataDirectory, requiredLevelRoot, @@ -52,7 +65,7 @@ public final class WorldReplacementBootstrap { case RETAINED -> retained++; } } - return new ReconcileResult(transactions.size(), published, rolledBack, retained); + return new ReconcileResult(transactions.size(), published, rolledBack, retained, skipped); } public static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) { @@ -124,11 +137,20 @@ public final class WorldReplacementBootstrap { } throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); } - WorldReplacementFilesystem.publish( - paths, - active.originalTargetPresent(), - active.packFingerprint() - ); + try { + WorldReplacementFilesystem.publish( + paths, + active.originalTargetPresent(), + active.packFingerprint() + ); + } catch (IOException publishFailure) { + throw new IOException("Pending replacement for " + active.worldKey() + + " cannot be published: " + publishFailure.getMessage() + + " To recover, restore the original generator entry for \"" + active.worldName() + + "\" in bukkit.yml (the next boot rolls the replacement back), or delete the journal at " + + dataDirectory.resolve(WorldReplacementJournal.DIRECTORY_NAME).resolve(active.id() + ".properties") + + ".", publishFailure); + } active = active.withPhase(Phase.PUBLISHED); WorldReplacementJournal.write(dataDirectory, active); feedback.accept("Published Iris world replacement for " + active.worldKey() @@ -227,7 +249,7 @@ public final class WorldReplacementBootstrap { return new IOException("Pending replacement for " + transaction.worldKey() + " is blocked: " + detail); } - public record ReconcileResult(int transactions, int published, int rolledBack, int retained) { + public record ReconcileResult(int transactions, int published, int rolledBack, int retained, int skipped) { } private enum ReconcileAction { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index 7a2734fb9..09b8bee80 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -2,9 +2,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ExactWorldSlotPathPolicy; import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; -import art.arcane.iris.spi.IrisLogging; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -13,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; @@ -61,6 +60,26 @@ public final class WorldReplacementFilesystem { if (state.stagePresent() || state.backupPresent()) { throw new IOException("A replacement artifact already exists for this transaction."); } + requireMigratedRetainedWorld(requiredPaths.target()); + } + + public static void requireMigratedRetainedWorld(Path retainedWorld) throws IOException { + try { + requireDirectory(retainedWorld, "retained world"); + requireDirectory(retainedWorld.resolve("data"), "retained world data"); + requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data"); + requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data"); + for (Path relative : PAPER_WORLD_METADATA) { + BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative)); + if (!sourceAttributes.isRegularFile()) { + throw new IOException("Retained Paper world metadata is not a regular file: " + relative); + } + } + } catch (NoSuchFileException e) { + throw new IOException("World slot " + retainedWorld.getFileName() + + " is missing Paper world metadata (" + e.getFile() + + "); load the world once on this server before replacing it.", e); + } } public static void publish( @@ -328,17 +347,8 @@ public final class WorldReplacementFilesystem { } private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException { - requireDirectory(retainedWorld, "retained world"); + requireMigratedRetainedWorld(retainedWorld); requireDirectory(replacementWorld, "replacement world"); - requireDirectory(retainedWorld.resolve("data"), "retained world data"); - requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data"); - requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data"); - for (Path relative : PAPER_WORLD_METADATA) { - BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative)); - if (!sourceAttributes.isRegularFile()) { - throw new IOException("Retained Paper world metadata is not a regular file: " + relative); - } - } ensureDirectory(replacementWorld.resolve("data"), "replacement world data"); ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data"); ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data"); @@ -420,25 +430,11 @@ public final class WorldReplacementFilesystem { } private static void forceDirectoryRequired(Path directory) throws IOException { - if (File.separatorChar == '\\') { - return; - } - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); - } catch (UnsupportedOperationException failure) { - throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); - } + DirectoryDurability.forceDirectoryRequired(directory); } private static void forceDirectoryAfterCommit(Path directory) { - try { - forceDirectoryRequired(directory); - } catch (IOException failure) { - IrisLogging.reportError( - "A world-replacement move completed, but its parent directory could not be durability-synced.", - failure - ); - } + DirectoryDurability.forceDirectoryAfterCommit(directory, "A world-replacement move"); } public record ReplacementPaths(Path target, Path stage, Path backup) { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java index edb5fe8d7..af94b2d17 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java @@ -3,10 +3,8 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ExactWorldSlotPathPolicy; import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; -import art.arcane.iris.spi.IrisLogging; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -46,19 +44,50 @@ public final class WorldReplacementJournal { if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Replacement journal entry is unsafe: " + file); } - transactions.add(read(file, currentLevelRoot)); + try { + transactions.add(read(file)); + } catch (IOException failure) { + throw new IOException("Invalid replacement journal " + file + ": " + failure.getMessage(), failure); + } } } transactions.sort(Comparator.comparing(transaction -> transaction.id().toString())); + // Only transactions that target the current level root compete for a slot; a stale + // journal recorded against another level root must not collide with a live one. Set worldKeys = new HashSet<>(); for (Transaction transaction : transactions) { - if (!worldKeys.add(transaction.worldKey())) { + if (appliesTo(transaction, currentLevelRoot) && !worldKeys.add(transaction.worldKey())) { throw new IOException("Multiple replacement journals target " + transaction.worldKey() + "."); } } return List.copyOf(transactions); } + /** + * Whether this journal entry targets the current level root and logical world name. A + * mismatch means the operator changed level-name or world container after the replacement + * was staged; such entries are skipped at bootstrap instead of aborting the whole boot. + */ + public static boolean appliesTo(Transaction transaction, Path currentLevelRoot) { + Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction"); + ExactWorldSlotPathPolicy.Target target; + try { + target = ExactWorldSlotPathPolicy.resolve(currentLevelRoot, requiredTransaction.worldKey()); + } catch (RuntimeException failure) { + return false; + } + if (!target.levelRoot().equals(requiredTransaction.levelRoot())) { + return false; + } + String expectedWorldName; + try { + expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey()); + } catch (IllegalArgumentException failure) { + return false; + } + return expectedWorldName.equals(requiredTransaction.worldName()); + } + public static void write(Path dataDirectory, Transaction transaction) throws IOException { Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction"); Path directory = Objects.requireNonNull(directory(dataDirectory, true)); @@ -145,7 +174,7 @@ public final class WorldReplacementJournal { throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey); } - private static Transaction read(Path file, Path currentLevelRoot) throws IOException { + private static Transaction read(Path file) throws IOException { Properties properties = new Properties(); try (InputStream input = Files.newInputStream(file)) { properties.load(input); @@ -196,7 +225,6 @@ public final class WorldReplacementJournal { originalTargetPresent, phase ); - resolveTarget(transaction, currentLevelRoot); return transaction; } @@ -342,25 +370,11 @@ public final class WorldReplacementJournal { } private static void forceDirectoryRequired(Path directory) throws IOException { - if (File.separatorChar == '\\') { - return; - } - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); - } catch (UnsupportedOperationException failure) { - throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); - } + DirectoryDurability.forceDirectoryRequired(directory); } private static void forceDirectoryAfterCommit(Path directory) { - try { - forceDirectoryRequired(directory); - } catch (IOException failure) { - IrisLogging.reportError( - "A world-replacement journal change completed, but its parent directory could not be durability-synced.", - failure - ); - } + DirectoryDurability.forceDirectoryAfterCommit(directory, "A world-replacement journal change"); } public record Transaction( diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java index 98b890f02..fb1089c6d 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java @@ -507,6 +507,14 @@ public final class BukkitCommandMessagesExtended { "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks", C.RED + "Pregen radius must be greater than zero blocks." ); + public static final TextKey COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE = TextKey.of( + "iris.bukkit.commandstudio.regions_radius_out_of_range", + C.RED + "Radius must be between 1 and 2048 chunks." + ); + public static final TextKey COMMAND_STUDIO_REGIONS_SCAN_FAILED = TextKey.of( + "iris.bukkit.commandstudio.regions_scan_failed", + C.RED + "Region scan failed: {value}" + ); public static final TextKey COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER = TextKey.of( "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server", C.RED + "Strict serial pregeneration requires Paper or a Paper-compatible server." @@ -965,6 +973,8 @@ public final class BukkitCommandMessagesExtended { COMMAND_OBJECT_NO_AREA_SELECTED_4, COMMAND_OBJECT_AUTO_SELECT_COMPLETE_2, COMMAND_PREGEN_PREGEN_RADIUS_MUST_BE_GREATER_THAN_ZERO_BLOCKS, + COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE, + COMMAND_STUDIO_REGIONS_SCAN_FAILED, COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER, COMMAND_PREGEN_ENGINE_ACCESS_THIS_WORLD_IS_NULL, COMMAND_PREGEN_PLEASE_MAKE_SURE_WORLD_IS_LOADED_ENGINE_IS_INITIALIZED_GENERATE, diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index c4095c2dc..cefa5cc3e 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisBiome; @@ -270,13 +271,17 @@ public class TreeSVC implements IrisService { IrisServices.get(ExternalDataSVC.class).processUpdate(engine, block, data.getCustom()); } else block.setBlockData(d, false); int mantleY = block.getY() - event.getWorld().getMinHeight(); - engine.getMantle().getMantle().set(block.getX(), mantleY, block.getZ(), treeMarker); - engine.getMantle().getMantle().set( - block.getX(), - mantleY, - block.getZ(), - TreeBlockMaterial.of(block.getBlockData().getAsString()) - ); + // Lease-gated: the grow task can land after the engine started closing, and + // an unleased mantle write would race the region flush. + EngineLifecycleTasks.run(engine, "tree_grow_mantle", () -> { + engine.getMantle().getMantle().set(block.getX(), mantleY, block.getZ(), treeMarker); + engine.getMantle().getMantle().set( + block.getX(), + mantleY, + block.getZ(), + TreeBlockMaterial.of(block.getBlockData().getAsString()) + ); + }); } } }; diff --git a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java index 02ba1a079..68d442f73 100644 --- a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java +++ b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java @@ -1,6 +1,7 @@ package art.arcane.iris.core.splash; import art.arcane.iris.core.pack.PackDirectoryResolver; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; @@ -51,12 +52,13 @@ public final class IrisSplashPackScanner { try (FileReader reader = new FileReader(dimensionFile)) { JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); - if (!json.has("version")) { + JsonElement version = json.get("version"); + if (version == null || !version.isJsonPrimitive()) { return null; } - return new SplashPackMetadata(dimName, json.get("version").getAsString()); - } catch (IOException | JsonParseException | IllegalStateException error) { + return new SplashPackMetadata(dimName, version.getAsString()); + } catch (IOException | JsonParseException | IllegalStateException | UnsupportedOperationException error) { report(reporter, "Failed to read splash metadata for dimension pack \"" + dimName + "\".", error); return null; } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java b/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java index 87b174079..9fc86f846 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisConverter.java @@ -198,6 +198,9 @@ public class IrisConverter { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_CONVERTER_SOME_SCHEMATICS_FAILED_CONVERT_CHECK_CONSOLE_DETAILS)); } }); + // Single-use pool: retire the worker once the queued conversion finishes instead of + // leaking a non-daemon thread per invocation. + executorService.shutdown(); } private static int resolveVersion(CompoundTag compound) throws Exception { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index 31be88ab1..f59d7bdac 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -39,6 +39,7 @@ import art.arcane.iris.core.pregenerator.methods.HybridPregenMethod; import art.arcane.iris.core.service.GlobalCacheSVC; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.platform.BukkitChunkGenerator; @@ -643,7 +644,10 @@ public class IrisToolbelt { if (e == null) { return null; } - return e.getEngine().getMantle().getMantle().get(x, y - world.getMinHeight(), z, of); + // Lease-gated so the engine shutdown drain covers these public accessors; a mantle + // mid-close refuses the lease instead of racing the region flush. + return EngineLifecycleTasks.call(e.getEngine(), "api_mantle_get", + () -> e.getEngine().getMantle().getMantle().get(x, y - world.getMinHeight(), z, of), null); } public static void deleteMantleData(World world, int x, int y, int z, Class of) { @@ -651,7 +655,8 @@ public class IrisToolbelt { if (e == null) { return; } - e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of); + EngineLifecycleTasks.run(e.getEngine(), "api_mantle_delete", + () -> e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of)); } public static void setMantleData(World world, int x, int y, int z, T data) { @@ -659,7 +664,8 @@ public class IrisToolbelt { if (e == null || data == null) { return; } - e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data); + EngineLifecycleTasks.run(e.getEngine(), "api_mantle_set", + () -> e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data)); } public static boolean removeWorld(World world) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java b/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java index 0c12908bb..1397be1d8 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java +++ b/core/src/main/java/art/arcane/iris/core/tools/WorldMaintenance.java @@ -24,9 +24,9 @@ public final class WorldMaintenance { return; } - int depth = worldMaintenanceDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); + int depth = incrementDepth(worldMaintenanceDepth, worldName); if (bypassMantleStages) { - worldMaintenanceMantleBypassDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); + incrementDepth(worldMaintenanceMantleBypassDepth, worldName); } if (IrisSettings.get().getGeneral().isDebug()) { IrisLogging.info("World maintenance enter: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); @@ -44,29 +44,17 @@ public final class WorldMaintenance { return; } - AtomicInteger depthCounter = worldMaintenanceDepth.get(worldName); - if (depthCounter == null) { + if (!worldMaintenanceDepth.containsKey(worldName)) { return; } - int depth = depthCounter.decrementAndGet(); - if (depth <= 0) { - worldMaintenanceDepth.remove(worldName, depthCounter); - depth = 0; - } + int depth = decrementDepth(worldMaintenanceDepth, worldName); // Only a bypass-begin's paired end releases bypass credit: a plain end overlapping a // bypassing operation stole its credit and re-enabled mantle stages under it. int bypassDepth = 0; if (bypassMantleStages) { - AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName); - if (bypassCounter != null) { - bypassDepth = bypassCounter.decrementAndGet(); - if (bypassDepth <= 0) { - worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter); - bypassDepth = 0; - } - } + bypassDepth = decrementDepth(worldMaintenanceMantleBypassDepth, worldName); } if (IrisSettings.get().getGeneral().isDebug()) { @@ -76,6 +64,22 @@ public final class WorldMaintenance { } } + // Depth mutations run inside compute() so an identity-based remove can never clear a + // registration a concurrent begin just re-incremented. + private static int incrementDepth(Map depths, String worldName) { + return depths.compute(worldName, (key, current) -> { + AtomicInteger counter = current == null ? new AtomicInteger() : current; + counter.incrementAndGet(); + return counter; + }).get(); + } + + private static int decrementDepth(Map depths, String worldName) { + AtomicInteger remaining = depths.computeIfPresent( + worldName, (key, current) -> current.decrementAndGet() <= 0 ? null : current); + return remaining == null ? 0 : Math.max(0, remaining.get()); + } + public static boolean isWorldMaintenanceActive(String worldName) { if (worldName == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 92f51330a..7357f1c69 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -34,7 +34,6 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.protocol.IrisMessage; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.localization.MessageArgument; -import art.arcane.volmlib.util.math.RNG; import static art.arcane.iris.engine.EngineShutdownSequence.runCleanup; @@ -64,7 +63,7 @@ final class EngineHotloader { IrisComplex nextComplex = null; try { engine.sealForTransition("complex hotload", false); - RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), previous.target()); + RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), previous.target()); engine.runtimeAssembly.set(assembly); EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index c5d55501a..eef84ec95 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -40,10 +40,10 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.math.M; -import art.arcane.volmlib.util.math.RNG; import java.io.File; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; import static art.arcane.iris.engine.EngineShutdownSequence.propagate; @@ -65,7 +65,7 @@ final class EngineRuntimeBuilder { } EngineRuntime buildRuntime(EngineTarget runtimeTarget) { - RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), runtimeTarget); + RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), runtimeTarget); engine.runtimeAssembly.set(assembly); try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { IrisLogging.debug("Setup Engine " + assembly.cacheId); @@ -279,8 +279,14 @@ final class EngineRuntimeBuilder { } static final class RuntimeAssembly { + private static final AtomicInteger RUNTIME_IDS = new AtomicInteger(); + final int cacheId; final EngineTarget target; + + static int nextRuntimeId() { + return RUNTIME_IDS.incrementAndGet(); + } IrisComplex complex; UpperDimensionContext upperContext; EngineEffects effects; diff --git a/core/src/main/java/art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java b/core/src/main/java/art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java index 603c89152..50ee56d32 100644 --- a/core/src/main/java/art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java +++ b/core/src/main/java/art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java @@ -97,7 +97,11 @@ public class LinkedTerrainChunk implements TerrainChunk { @Override public synchronized void setRegion(int xMin, int yMin, int zMin, int xMax, int yMax, int zMax, PlatformBlockState state) { - rawChunkData.setRegion(xMin, yMin, zMin, xMax, yMax, zMax, (BlockData) state.nativeHandle()); + BlockData blockData = (BlockData) state.nativeHandle(); + if (blockData instanceof IrisCustomData data) { + blockData = data.getBase(); + } + rawChunkData.setRegion(xMin, yMin, zMin, xMax, yMax, zMax, blockData); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java index 01a92f4ab..a277630ef 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureVolume.java @@ -33,7 +33,44 @@ public record NativeStructureVolume( int maxY, int maxZ ) { - public static final KList NONE = new KList<>(); + // Shared empty sentinel handed to every engine and memoized by the volume caches; a silent + // add()/clear() here would corrupt them all globally, so mutation fails loudly instead. + public static final KList NONE = new KList<>() { + @Override + public boolean add(NativeStructureVolume volume) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public void add(int index, NativeStructureVolume volume) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public boolean addAll(java.util.Collection volumes) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public boolean addAll(int index, java.util.Collection volumes) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public void clear() { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public NativeStructureVolume remove(int index) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + + @Override + public boolean remove(Object volume) { + throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable"); + } + }; public static NativeStructureVolume of(String structure, int aX, int aY, int aZ, int bX, int bY, int bZ) { return new NativeStructureVolume( diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index 8e3f18229..87482d6cc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -231,6 +231,10 @@ public final class IrisObjectIO { static void write(IrisObject self, OutputStream o) throws IOException { validateWritable(self); + writeValidated(self, o); + } + + private static void writeValidated(IrisObject self, OutputStream o) throws IOException { DataOutputStream dos = new DataOutputStream(o); dos.writeInt(self.w); dos.writeInt(self.h); @@ -270,6 +274,10 @@ public final class IrisObjectIO { static void write(IrisObject self, OutputStream o, VolmitSender sender) throws IOException { validateWritable(self); + writeValidated(self, o, sender); + } + + private static void writeValidated(IrisObject self, OutputStream o, VolmitSender sender) throws IOException { AtomicReference ref = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); new Job() { @@ -365,7 +373,7 @@ public final class IrisObjectIO { // object must leave the existing .iob untouched. validateWritable(self); try (FileOutputStream out = new FileOutputStream(file)) { - write(self, out); + writeValidated(self, out); } } @@ -376,7 +384,7 @@ public final class IrisObjectIO { validateWritable(self); try (FileOutputStream out = new FileOutputStream(file)) { - write(self, out, sender); + writeValidated(self, out, sender); } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index be6a48bcd..292d94dae 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -482,9 +482,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (throwable == null) { future.complete(null); } else { + // The close body already ran and tore the engine down; unlike the pre-dispatch + // failure above, resetting the gate here would advertise a healthy generator + // over a CLOSED or FAILED engine. Stay latched and surface the failure. future.completeExceptionally(throwable); - closeFuture.compareAndSet(future, null); - closing = false; } }); return future; diff --git a/core/src/main/java/art/arcane/iris/util/common/math/VectorMath.java b/core/src/main/java/art/arcane/iris/util/common/math/VectorMath.java index ca4e81e5e..df7425321 100644 --- a/core/src/main/java/art/arcane/iris/util/common/math/VectorMath.java +++ b/core/src/main/java/art/arcane/iris/util/common/math/VectorMath.java @@ -75,19 +75,4 @@ public class VectorMath extends art.arcane.volmlib.util.math.VectorMath { return v; } - public static Vector getAxis(Direction current, Direction to) { - if (current.equals(Direction.U) || current.equals(Direction.D)) { - if (to.equals(Direction.U) || to.equals(Direction.D)) { - return new Vector(1, 0, 0); - } else { - if (current.equals(Direction.N) || current.equals(Direction.S)) { - return Direction.E.toVector(); - } else { - return Direction.S.toVector(); - } - } - } - - return new Vector(0, 1, 0); - } } diff --git a/core/src/main/java/art/arcane/iris/util/common/parallel/BurstExecutor.java b/core/src/main/java/art/arcane/iris/util/common/parallel/BurstExecutor.java index 8f89858a6..534cbc169 100644 --- a/core/src/main/java/art/arcane/iris/util/common/parallel/BurstExecutor.java +++ b/core/src/main/java/art/arcane/iris/util/common/parallel/BurstExecutor.java @@ -3,9 +3,14 @@ package art.arcane.iris.util.common.parallel; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.parallel.BurstExecutorSupport; import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; public class BurstExecutor extends BurstExecutorSupport { public BurstExecutor(ExecutorService executor, int burstSizeEstimate) { super(executor, burstSizeEstimate, IrisLogging::reportError); } + + public BurstExecutor(Supplier executorSource, int burstSizeEstimate) { + super(executorSource, burstSizeEstimate, IrisLogging::reportError); + } } diff --git a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java index 396ffda53..221cec69d 100644 --- a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java +++ b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java @@ -72,7 +72,7 @@ public class MultiBurst extends MultiBurstSupport { @Override public BurstExecutor burst(int estimate) { - return new BurstExecutor(service(), estimate); + return new BurstExecutor(this::service, estimate); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java index f9e1f7e56..c2f4fb608 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java @@ -21,6 +21,7 @@ package art.arcane.iris.util.project.hunk.view; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.project.hunk.storage.AtomicHunk; import org.bukkit.block.data.BlockData; import org.bukkit.generator.ChunkGenerator.ChunkData; @@ -88,6 +89,11 @@ public class ChunkDataHunkHolder extends AtomicHunk { for (int y = 0; y < height; y++) { PlatformBlockState state = super.getRaw(x, y, z); BlockData block = state == null ? null : (BlockData) state.nativeHandle(); + // Custom wrappers are not real Bukkit data; write the vanilla base like the + // NMS fast path (NMSBinding.applyChunkDataBlocks) does. + if (block instanceof IrisCustomData custom) { + block = custom.getBase(); + } if (block == null) { flushRun(x, z, runStart, y, activeBlock); activeBlock = null; diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 10edd0041..9944a234a 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Kein Bereich ausgewählt.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatische Auswahl abgeschlossen!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cDer Vorgenerierungsradius muss größer als null Blöcke sein.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cDer Radius muss zwischen 1 und 2048 Chunks liegen.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cRegion-Scan fehlgeschlagen: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cDie strikt serielle Vorgenerierung erfordert Paper oder einen Paper-kompatiblen Server.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDer Engine-Zugriff für diese Welt ist null!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cStelle sicher, dass die Welt geladen und die Engine initialisiert ist. Generiere zum Beispiel einen neuen Chunk.", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index a8a757b08..3fcfde20c 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "No hay ningún área seleccionada.", "iris.bukkit.commandobject.auto_select_complete_2": "§a¡Selección automática completada!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cEl radio de pregen debe ser mayor que cero bloques.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cEl radio debe estar entre 1 y 2048 chunks.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cError al escanear regiones: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregeneración estrictamente secuencial requiere Paper o un servidor compatible con Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c¡El acceso al motor de este mundo es nulo!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAsegúrate de que el mundo esté cargado y el motor inicializado. Por ejemplo, genera un chunk nuevo.", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index 594186221..9c909b6fd 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Aluetta ei ole valittu.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomaattivalinta valmis!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen-säteen on oltava suurempi kuin nolla lohkoa.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cSäteen on oltava 1 ja 2048 chunkin välillä.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cAlueskannaus epäonnistui: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiukasti sarjallinen esigenerointi vaatii Paperin tai Paper-yhteensopivan palvelimen.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cTämän maailman moottorit eivät toimi!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVarmista, että maailma on ladattu. & Moottori on alustettu. Luo esimerkiksi uusi pala.", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 85a258e48..067a28189 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Aucune zone sélectionnée.", "iris.bukkit.commandobject.auto_select_complete_2": "§aSélection automatique terminée !", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cLe rayon de pregen doit être supérieur à zéro bloc.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cLe rayon doit être compris entre 1 et 2048 chunks.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cÉchec de l'analyse des régions : {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa prégénération strictement séquentielle nécessite Paper ou un serveur compatible avec Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accès au moteur de ce monde est nul !", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVérifiez que le monde est chargé et que le moteur est initialisé. Générez par exemple un nouveau chunk.", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 6396b749d..725164b94 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "שום אזור לא נבחר.", "iris.bukkit.commandobject.auto_select_complete_2": "§aבחירה אוטומטית הושלם!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cרדיוס Pregen חייב להיות גדול יותר מאשר אפס בלוקים.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cהרדיוס חייב להיות בין 1 ל-2048 צ'אנקים.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cסריקת האזורים נכשלה: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cקדם-יצירה טורית קפדנית דורשת Paper או שרת תואם Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cהגישה של המנוע לעולם הזה היא אפס!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cודא שהעולם טעון & המנוע הוא ראשוני. ליצור נתח חדש, למשל.", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 35cbaecf7..9514b98d0 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Nessuna area selezionata.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-selezione completa!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cIl raggio di pregenerazione deve essere maggiore di zero blocchi.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cIl raggio deve essere compreso tra 1 e 2048 chunk.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cScansione delle regioni non riuscita: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregenerazione strettamente seriale richiede Paper o un server compatibile con Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accesso all'engine per questo mondo è nullo!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAssicurati che il mondo sia caricato e che l'engine sia inizializzato. Ad esempio, genera un nuovo chunk.", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index db4fbd530..1e1dc0fd9 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "範囲が選択されていません。", "iris.bukkit.commandobject.auto_select_complete_2": "§a自動選択完了!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§c事前生成の半径は 0 ブロックより大きくなければなりません。", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半径は1から2048チャンクの範囲で指定してください。", + "iris.bukkit.commandstudio.regions_scan_failed": "§cリージョンスキャンに失敗しました: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c厳密な直列事前生成には Paper または Paper 互換サーバーが必要です。", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cこのワールドのエンジンにアクセスできません!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cワールドが読み込まれ、エンジンが初期化されていることを確認してください。たとえば、新しいチャンクを生成してください。", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 11d01782e..ba2fff99b 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "선택 없음.", "iris.bukkit.commandobject.auto_select_complete_2": "§a자동 선택 완료!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen 반경은 0 개 이상의 블록이 있어야합니다.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§c반경은 1~2048 청크 사이여야 합니다.", + "iris.bukkit.commandstudio.regions_scan_failed": "§c지역 스캔에 실패했습니다: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c엄격한 순차 사전 생성에는 Paper 또는 Paper 호환 서버가 필요합니다.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c이 세상의 엔진 접근은 null입니다!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c세상이 로드되었는지 확인하십시오. & 엔진은 초기화됩니다. 새로운 청크 생성, 예를 들어.", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 54578b851..033c12043 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Nepasirinkta sritis.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-pasirinkite baigtas!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen spindulys turi būti didesnis nei nulis blokų.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cSpindulys turi būti nuo 1 iki 2048 gabalų.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cRegionų nuskaitymas nepavyko: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cGriežtai nuosekliam išankstiniam generavimui reikia Paper arba su Paper suderinamo serverio.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cVariklio pasiekiamumas šiam pasauliui nulinis!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cĮsitikinkite, kad pasaulis įkeltas & variklis įjungiamas. Pavyzdžiui, generuoti naują gabalą.", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 87d3e5fcf..fe52509dd 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Geen gebied geselecteerd.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatisch selecteren voltooid!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius moet groter zijn dan nul blokken.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cDe radius moet tussen 1 en 2048 chunks liggen.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cRegioscan mislukt: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cStrikt seriële pregeneratie vereist Paper of een Paper-compatibele server.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDe toegang tot de motor voor deze wereld is nul!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cZorg ervoor dat de wereld geladen is & de motor is geïnitialiseerd. Genereer bijvoorbeeld een nieuwe chunk.", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index e4303dfaa..7ca25b56c 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Nie wybrano obszaru.", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto- select zakończone!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPromień pregen musi być większy niż zero bloków.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cPromień musi mieścić się w zakresie od 1 do 2048 chunków.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cSkanowanie regionów nie powiodło się: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cŚciśle szeregowa pregeneracja wymaga Paper lub serwera zgodnego z Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cSilnik dostępu dla tego świata jest zerowy!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cUpewnij się, że świat jest załadowany. & silnik jest inicjalizowany. Na przykład wygenerować nowy kawałek.", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 5974114a8..e527a1540 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Nenhuma área selecionada.", "iris.bukkit.commandobject.auto_select_complete_2": "§aSelecção automática completa!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cO raio de Pregen deve ser superior a zero blocos.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cO raio deve estar entre 1 e 2048 chunks.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cFalha ao analisar regiões: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cA pré-geração estritamente sequencial requer Paper ou um servidor compatível com Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cO acesso do motor para este mundo é nulo!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cPor favor, certifique-se que o mundo está carregado & O motor está inicializado. Gerar um novo chunk, por exemplo.", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index f4016ec9e..69a993f3d 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Никакой области не выбрано.", "iris.bukkit.commandobject.auto_select_complete_2": "§aАвтовыбор завершен!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cРадиус прегена должен быть больше нуля блоков.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cРадиус должен быть от 1 до 2048 чанков.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cНе удалось просканировать регионы: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cСтрого последовательная прегенерация требует Paper или Paper-совместимый сервер.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cДоступ к двигателю для этого мира недействителен!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cУбедитесь, что мир загружен & Двигатель инициализируется. Например, создать новый чанк.", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index f72c7b7d1..bd4501550 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Hiçbir alan seçilmiş değildir.", "iris.bukkit.commandobject.auto_select_complete_2": "§aotomatik seçim tamamen!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius sıfır bloklardan daha büyük olmalıdır.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cYarıçap 1 ile 2048 chunk arasında olmalıdır.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cBölge taraması başarısız oldu: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cKesin sıralı ön oluşturma Paper veya Paper uyumlu bir sunucu gerektirir.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cBu dünya için motor erişimi çıplak!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cLütfen dünyanın yüklendiğinden emin olun & Motor başlangıçlı. Örneğin yeni bir chunk.", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 43cd27f32..2b3cd23d4 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "Chưa chọn diện tích.", "iris.bukkit.commandobject.auto_select_complete_2": "§aTự động chọn hoàn tất!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cBán kính Pregen phải lớn hơn không khối.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§cBán kính phải nằm trong khoảng từ 1 đến 2048 chunk.", + "iris.bukkit.commandstudio.regions_scan_failed": "§cQuét khu vực thất bại: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiền tạo tuần tự nghiêm ngặt yêu cầu Paper hoặc máy chủ tương thích với Paper.", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cĐộng cơ truy cập thế giới này là vô ích!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cHãy chắc chắn rằng thế giới đã lên đạn. & Động cơ đã khởi động. Chẳng hạn như tạo ra một mảng mới.", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 0d08e7a61..22e3d555e 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "未选择区域 。", "iris.bukkit.commandobject.auto_select_complete_2": "§a自动选择完成!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半径必须大于零块.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半径必须在 1 到 2048 区块之间。", + "iris.bukkit.commandstudio.regions_scan_failed": "§c区域扫描失败: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c严格的序列预生成要求 Paper 或一个 Paper- 兼容服务器。", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c这个世界的引擎是无效的!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c请确保世界充满 & 引擎已经初始化。 例如, 生成一个新的块 。", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 3e3bbf346..3084e74f8 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -232,6 +232,8 @@ "iris.bukkit.commandobject.no_area_selected_4": "未選擇區域 。", "iris.bukkit.commandobject.auto_select_complete_2": "§a自動選擇完成!", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半徑必須大於零塊.", + "iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半徑必須介於 1 到 2048 區塊之間。", + "iris.bukkit.commandstudio.regions_scan_failed": "§c區域掃描失敗: {value}", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c嚴格的序列預生成要求 Paper 或一個 Paper- 相容伺服器。", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c這個世界的引擎是無效的!", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c請確保世界充滿 & 引擎已經初始化。 例如, 生成一個新的塊 。", diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java index d2db21662..5783297fe 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java @@ -1,9 +1,13 @@ package art.arcane.iris.core.lifecycle; import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.plugin.PluginManager; import org.junit.Test; +import org.mockito.MockedStatic; import java.lang.reflect.Method; import java.util.concurrent.CompletableFuture; @@ -16,10 +20,43 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class WorldLifecycleUnloadAsyncTest { + @Test + public void manualUnloadFallbackDispatchesWorldUnloadEvent() { + World world = mock(World.class); + PluginManager pluginManager = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertTrue(WorldLifecycleSupport.announceManualWorldUnload(world)); + } + + verify(pluginManager).callEvent(any(WorldUnloadEvent.class)); + } + + @Test + public void manualUnloadFallbackHonorsCancelledWorldUnloadEvent() { + World world = mock(World.class); + PluginManager pluginManager = mock(PluginManager.class); + doAnswer(invocation -> { + WorldUnloadEvent event = invocation.getArgument(0); + event.setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(WorldUnloadEvent.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertFalse(WorldLifecycleSupport.announceManualWorldUnload(world)); + } + } + @Test public void reflectedAsyncUnloadWaitsForTrueCallback() throws Exception { CallbackServer server = new CallbackServer(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 12e69fa9e..0bd6b454b 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -192,22 +192,22 @@ public class WorldReplacementBootstrapTest { } @Test - public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception { + public void skipsChangedLevelRootWithoutTouchingStagedStorage() throws Exception { Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); configureReplacement(transaction); Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world")); - assertThrows( - IOException.class, - () -> WorldReplacementBootstrap.reconcile( - dataDirectory, - otherLevelRoot, - bukkitConfiguration, - ignored -> { - } - ) + WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile( + dataDirectory, + otherLevelRoot, + bukkitConfiguration, + ignored -> { + } ); + assertEquals(1, result.skipped()); + assertEquals(0, result.published()); + assertEquals(0, result.rolledBack()); assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); assertTrue(Files.isDirectory(paths(transaction).stage())); assertFalse(Files.exists(paths(transaction).backup())); @@ -280,6 +280,28 @@ public class WorldReplacementBootstrapTest { } } + @Test + public void skipsJournalStagedAgainstAnotherLevelRootInsteadOfAborting() throws Exception { + Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original"); + Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world")); + + WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile( + dataDirectory, + otherLevelRoot, + bukkitConfiguration, + ignored -> { + } + ); + + assertEquals(1, result.skipped()); + assertEquals(0, result.published()); + assertEquals(0, result.rolledBack()); + assertEquals(0, result.retained()); + assertTrue(Files.exists(dataDirectory + .resolve(WorldReplacementJournal.DIRECTORY_NAME) + .resolve(transaction.id() + ".properties"))); + } + private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception { return WorldReplacementBootstrap.reconcile( dataDirectory, diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index 75e21580d..e6cac9e62 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -108,6 +108,23 @@ public class WorldReplacementFilesystemTest { assertFalse(Files.exists(paths.backup())); } + @Test + public void rejectsUnmigratedRetainedWorldAtAdmission() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-missing-paper-metadata", TRANSACTION_ID); + Files.createDirectories(paths.target()); + Files.writeString(paths.target().resolve("original.txt"), "original"); + + IOException failure = assertThrows( + IOException.class, + () -> WorldReplacementFilesystem.requireExistingTarget(paths) + ); + + assertTrue(failure.getMessage().contains("missing Paper world metadata")); + assertTrue(failure.getMessage().contains("load the world once on this server")); + assertFalse(Files.exists(paths.stage())); + assertFalse(Files.exists(paths.backup())); + } + @Test public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception { WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID); diff --git a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java index 423efea5d..993ced0aa 100644 --- a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java +++ b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java @@ -8,7 +8,9 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.nio.charset.StandardCharsets; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -25,7 +27,7 @@ public class IrisLoggingTest { @Test public void contextualReportPrintsFullStacktraceWithBoundPlatform() { - IrisPlatform platform = mock(IrisPlatform.class); + IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS); IrisPlatforms.bind(platform); IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner")); ByteArrayOutputStream output = new ByteArrayOutputStream(); @@ -38,10 +40,29 @@ public class IrisLoggingTest { } verify(platform).log(LogLevel.ERROR, "Runtime world creation failed."); + verify(platform).reportError("Runtime world creation failed.", failure); verify(platform).reportError(failure); String text = output.toString(StandardCharsets.UTF_8); assertTrue(text.contains("IllegalStateException")); assertTrue(text.contains("IllegalArgumentException")); assertTrue(text.contains("inner")); } + + @Test + public void contextualReportHonorsPlatformOverrideSuppression() { + IrisPlatform platform = mock(IrisPlatform.class); + IrisPlatforms.bind(platform); + IllegalStateException failure = new IllegalStateException("suppressed"); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); + try { + IrisLogging.reportError("Throttled failure.", failure); + } finally { + System.setErr(originalErr); + } + + verify(platform).reportError("Throttled failure.", failure); + assertFalse(output.toString(StandardCharsets.UTF_8).contains("suppressed")); + } } diff --git a/probe/src/main/java/art/arcane/iris/probe/StubTileData.java b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java index 5e92ea58b..120744997 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubTileData.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java @@ -19,6 +19,7 @@ public final class StubTileData extends TileData { private static final Gson GSON = new GsonBuilder() .disableHtmlEscaping() .setStrictness(Strictness.LENIENT) + .setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.LONG_OR_DOUBLE) .create(); private final String blockKey; diff --git a/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java b/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java index 4bc3c1687..0b8465bd2 100644 --- a/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/StubTileDataTest.java @@ -32,6 +32,24 @@ public class StubTileDataTest { assertEquals("minecraft:chests/ancient_city", decoded.getProperties().get("LootTable")); } + @Test + public void modernIntegralPropertiesReencodeByteIdentical() throws Exception { + KMap properties = new KMap<>(); + properties.put("LootTable", "minecraft:chests/ancient_city"); + properties.put("LootTableSeed", -7205759403792793599L); + StubTileData original = StubTileData.fromProperties( + StubPlatform.blockStateForTest("minecraft:chest[facing=north]"), properties); + byte[] encoded = encode(original::toBinary); + + StubTileData decoded; + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + decoded = StubTileData.read(input); + } + + assertArrayEquals(encoded, encode(decoded::toBinary)); + assertEquals(-7205759403792793599L, decoded.getProperties().get("LootTableSeed")); + } + @Test public void legacyLootablePayloadPreservesItsCompleteFrame() throws Exception { byte[] payload = encode(output -> { diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java index 0df764f65..54bab13d3 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java @@ -97,6 +97,11 @@ public final class IrisLogging { inner.printStackTrace(System.err); } + if (IrisPlatforms.isBound()) { + IrisPlatforms.get().reportError(message, cause); + return; + } + reportError(cause); cause.printStackTrace(System.err); } diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java index d729b5d31..ab9592001 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatform.java @@ -195,4 +195,17 @@ public interface IrisPlatform { * Hands a throwable to the host's error reporting. Safe from any thread; must not rethrow. */ void reportError(Throwable error); + + /** + * Contextual variant of {@link #reportError(Throwable)}. The platform owns trace emission so + * each adapter prints exactly one copy and its own suppression (throttles, debug gates) is + * honored; the default preserves the historical behavior of reporting plus an unconditional + * stack trace on stderr. + */ + default void reportError(String context, Throwable error) { + reportError(error); + if (error != null) { + error.printStackTrace(System.err); + } + } }