diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java index c1559165a..8933cdc93 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java @@ -189,7 +189,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { } @Override - public boolean shouldBypassMantleStages(Engine engine) { + public boolean shouldRefuseGeneration(Engine engine) { if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) { return false; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index b6bd3726e..0936c651e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -79,6 +79,8 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.generation.concurrent.MultiBurst; import java.util.ArrayDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedEngineBootstrap { private static final String[] CORE_SELF_TEST_CLASSES = { @@ -87,12 +89,17 @@ public final class ModdedEngineBootstrap { "art.arcane.iris.pack.loading.IrisData" }; private static final Object LOCK = new Object(); + private static final Object STOP_LOCK = new Object(); + private static final AtomicBoolean EXIT_FLUSH_REGISTERED = new AtomicBoolean(); + private static final long EXIT_FLUSH_SERVER_WAIT_SECONDS = 30L; private static final ModdedServiceManager UNBOUND_SERVICE_MANAGER = new ModdedServiceManager(); private static volatile NativeModdedLoader loader; private static volatile BoundRuntime runtime; private static volatile NativeModdedServer currentServer; private static volatile NativeModdedServer spawnCaptureServer; private static volatile boolean initialSpawnWasDefault; + private static volatile boolean stoppedPending; + private static volatile Thread stoppingThread; private ModdedEngineBootstrap() { } @@ -119,10 +126,12 @@ public final class ModdedEngineBootstrap { } public static void start(NativeModdedServer server) { + finishPreviousServer("server start"); + registerExitFlush(); captureInitialSpawn(server); currentServer = server; bind(); - // Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once + // Pair of the stopped() burst-pools stage. Load-bearing on integrated servers: once // closed, MultiBurst falls back to a same-thread executor, so a second world load // without reopen() would silently run every burst inline. MultiBurst.burst.reopen(); @@ -150,6 +159,7 @@ public final class ModdedEngineBootstrap { } public static void serverAboutToStart(NativeModdedServer server) { + finishPreviousServer("server start"); captureInitialSpawn(server); ModdedStartup.prepareForStartup(); } @@ -180,20 +190,16 @@ public final class ModdedEngineBootstrap { try { generator.unbindEngine(world); } catch (Throwable exception) { + // Never propagate into the loader's unload loop: a throw there skips closing every later level. A + // runtime dimension removal retries the eviction itself and fails with the retained mapping. ModdedIrisLog.error("Iris engine unload failed for {}", world.name(), exception); - if (exception instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (exception instanceof Error fatalError) { - throw fatalError; - } - throw new IllegalStateException("Iris engine unload failed for " - + world.name(), exception); } } public static void stop() { NativeModdedServer stoppingServer = currentServer; + stoppingThread = Thread.currentThread(); + stoppedPending = true; Throwable failure = null; failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads); failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer)); @@ -205,6 +211,62 @@ public final class ModdedEngineBootstrap { failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear); failure = runStopStage(failure, "gui host", ModdedGuiHost::clear); failure = runStopStage(failure, "services", () -> services().disableAll()); + reportStopFailure("stopping", failure); + } + + /** + * The loaders still drain queued chunk generation after server stopping and only close levels afterwards, so + * engines, their mantle and generation history, and the generation pools stay open until the server stopped. + * Engines of unloaded levels are already closed by {@link #levelUnloaded}; this closes whatever remains. + * Fabric fires it at the tail of stopServer, so a throw there skips it: the next server start or the JVM + * exit runs it instead. + */ + public static void stopped() { + synchronized (STOP_LOCK) { + stoppedPending = false; + stoppingThread = null; + closeStoppedServer(); + } + } + + private static void finishPreviousServer(String trigger) { + synchronized (STOP_LOCK) { + if (!stoppedPending) { + return; + } + ModdedIrisLog.warn("Iris closing the engines the previous server left open ({})", trigger); + stopped(); + } + } + + private static void registerExitFlush() { + if (EXIT_FLUSH_REGISTERED.compareAndSet(false, true)) { + Runtime.getRuntime().addShutdownHook(new Thread(ModdedEngineBootstrap::flushAtExit, "Iris Exit Flush")); + } + } + + private static void flushAtExit() { + if (!stoppedPending) { + return; + } + Thread serverThread = stoppingThread; + if (serverThread != null && serverThread != Thread.currentThread()) { + try { + serverThread.join(TimeUnit.SECONDS.toMillis(EXIT_FLUSH_SERVER_WAIT_SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + if (serverThread.isAlive()) { + ModdedIrisLog.warn("Iris skipped its exit flush: the server thread is still stopping"); + return; + } + } + finishPreviousServer("JVM exit"); + } + + private static void closeStoppedServer() { + Throwable failure = null; failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown); failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear); failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear); @@ -226,10 +288,14 @@ public final class ModdedEngineBootstrap { spawnCaptureServer = null; initialSpawnWasDefault = false; }); + reportStopFailure("stopped", failure); + } + + private static void reportStopFailure(String phase, Throwable failure) { if (failure != null) { // The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and // can leave the level unsaved. Every stage already logged its own failure. - ModdedIrisLog.error("Iris modded shutdown completed with failures", failure); + ModdedIrisLog.error("Iris modded shutdown ({}) completed with failures", phase, failure); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java index 0763209cc..b4a162b54 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java @@ -25,6 +25,7 @@ import art.arcane.volmlib.nativelib.terrain.feature.NativeFeatureTable; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedImportedFeatures; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy; import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl; @@ -163,6 +164,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe try { control = NativeFeatureGenerationPolicy.control(engine); } catch (RuntimeException error) { + GenerationFailures.rethrowEngineFailure(error); ModdedIrisLog.error("Iris could not read importedFeatures for this dimension; features off: {}", error.toString(), error); markInert(runtimeIdentity, generation); @@ -177,6 +179,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { built = nativeFeatures.buildTable(new ModdedImportedFeaturePolicy(engine), control, generation); } catch (Throwable error) { + GenerationFailures.rethrowEngineFailure(error); ModdedIrisLog.error("Iris importedFeatures is off for {}: feature table construction failed: {}", dimensionKey(engine), error.toString(), error); markInert(runtimeIdentity, generation); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java index 84c8d12b4..ba497052f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java @@ -16,6 +16,7 @@ public final class ModdedLifecycleCallbacks { ModdedEngineBootstrap::start, ModdedEngineBootstrap::serverStarted, ModdedEngineBootstrap::stop, + ModdedEngineBootstrap::stopped, ModdedEngineBootstrap::levelLoaded, ModdedEngineBootstrap::levelUnloaded, ModdedProtocolHandler::onPlayerJoin, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java index 0e41da5f3..85fa84155 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -40,29 +40,31 @@ import java.util.function.BooleanSupplier; * references and is safe on a dedicated server. */ public final class ModdedMixinAudit { + private static final String ENTITY_CONFIG = "volmlib.entity.mixins.json"; + private static final String CLIENT_CONFIG = "volmlib.client.mixins.json"; private static final AtomicBoolean AUDITED = new AtomicBoolean(false); - private static final List EXPECTED = List.of( - new ExpectedMixin("EntityPersistenceMixin", "entity", + static final List EXPECTED = List.of( + new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", false, NativeMixinFlags::entityPersistenceRan), - new ExpectedMixin("LivingEntityLootMixin", "entity", + new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin", NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot", false, NativeMixinFlags::livingEntityLootRan), - new ExpectedMixin("MobAwarenessMixin", "entity", + new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin", NativeMixinTarget.MOB, "iris$tickUnawareMob", false, NativeMixinFlags::mobAwarenessRan), - new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common", + new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin", NativeMixinTarget.STRUCTURE_PALETTE, "iris$installConcurrentBlockCache", false, NativeMixinFlags::structureTemplatePaletteRan), - new ExpectedMixin("NativeWorldOpenFlowsMixin", "client", + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin", NativeMixinTarget.WORLD_OPEN_FLOWS, - "iris$openWorldCheckWorldStemCompatibility", + "volmlib$openWorldCheckWorldStemCompatibility", true, NativeMixinFlags::worldOpenFlowsRan), - new ExpectedMixin("NativeWorldTypeEntryMixin", "client", + new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin", NativeMixinTarget.WORLD_TYPE_ENTRY, - "iris$describePreset", + "volmlib$describePreset", true, NativeMixinFlags::worldTypeEntryRan)); private ModdedMixinAudit() { @@ -90,7 +92,7 @@ public final class ModdedMixinAudit { if (isApplied(expected)) { applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)")); } else { - missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass()); + missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass().className()); } } if (missing.isEmpty()) { @@ -119,7 +121,7 @@ public final class ModdedMixinAudit { } } - private record ExpectedMixin(String mixinName, String config, NativeMixinTarget targetClass, String handlerMethod, - boolean clientOnly, BooleanSupplier ran) { + record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod, + boolean clientOnly, BooleanSupplier ran) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index e49d6e537..6c7919bdf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java @@ -195,7 +195,8 @@ public final class ModdedScheduler implements PlatformScheduler { mainQueue.clear(); delayedQueue.clear(); mainThread = null; - // Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it. + // Runs from ModdedEngineBootstrap.stopped, or its fallback at the next server start or JVM exit when the + // loader skipped it: release the level snapshot with it. ModdedServerLevels.forget(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 30bd305a4..dbc613a2e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -426,9 +426,9 @@ public final class ModdedWorldEngines { Engine engine = entry.getValue(); try { // Latch the generator's unloading flag BEFORE closing (unbindEngine sets it, - // then evicts): chunk-system drain work running after this stage would - // otherwise see a closed engine and silently rebuild a fresh engine + Mantle - // that no teardown stage ever closes, writing plates after the final save. + // then evicts): late chunk work would otherwise see a closed engine and + // silently rebuild a fresh engine + Mantle that no teardown stage ever + // closes, writing plates after the final save. IrisModdedChunkGenerator generator = NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class); if (generator != null) { generator.unbindEngine(level); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 110654bb5..3771034dd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -60,12 +60,16 @@ import java.util.Locale; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import art.arcane.volmlib.util.localization.MessageArgument; public final class IrisModdedCommands { private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; + // The first chunk of a cold dimension also loads the pack and binds the engine; the deadline only bounds a + // destination whose chunk never completes (a refusing generator or a removed dimension). + private static final long TELEPORT_DEADLINE_SECONDS = 120L; private static final Object DOWNLOAD_MONITOR = new Object(); static final SuggestionProvider PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES; @@ -133,7 +137,7 @@ public final class IrisModdedCommands { NativeModdedServer server = source.server(); CompletableFuture teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination( server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, - System.nanoTime() + TimeUnit.SECONDS.toNanos(10L))); + System.nanoTime() + TimeUnit.SECONDS.toNanos(TELEPORT_DEADLINE_SECONDS))); teleport.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success) && failure == null) { return; @@ -142,14 +146,42 @@ public final class IrisModdedCommands { ModdedIrisLog.error("Iris teleport into '{}' failed for {}", dimensionId, player.id(), failure); } - server.execute(() -> fail(source, IrisLanguage.plain( - ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, - MessageArgument.untrusted("dimensionId", dimensionId)))); + server.execute(() -> fail(source, teleportFailure(dimensionId, player.name(), failure, + ModdedDimensionManager.level(server, dimensionId) != null, server.player(player.id()) != null))); }); ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.name()), MessageArgument.untrusted("dimensionId", dimensionId))); return 1; } + static String teleportFailure(String dimensionId, String playerName, Throwable failure, + boolean dimensionLoaded, boolean playerOnline) { + if (failure != null) { + return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON, + MessageArgument.untrusted("dimensionId", dimensionId), + MessageArgument.untrusted("reason", failureReason(failure))); + } + if (!dimensionLoaded) { + return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, + MessageArgument.untrusted("dimensionId", dimensionId)); + } + if (!playerOnline) { + return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE, + MessageArgument.untrusted("dimensionId", dimensionId), + MessageArgument.untrusted("value", playerName)); + } + return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED, + MessageArgument.untrusted("dimensionId", dimensionId)); + } + + private static String failureReason(Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + String message = cause.getMessage(); + return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message; + } + static int evacuate(NativeCommandSource source, NativeWorld target) { NativeModdedServer server = source.server(); NativeWorld level = target != null ? target : source.world(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java index e25c83ce8..f6b6779cb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java @@ -71,6 +71,18 @@ public final class ModdedCommandMessages { "iris.modded.irismoddedcommands.teleport_failed_dimension_is_not_loaded", "Teleport failed: dimension " + "{dimensionId}" + " is not loaded." ); + public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON = TextKey.of( + "iris.modded.irismoddedcommands.teleport_failed_reason", + "Teleport into " + "{dimensionId}" + " failed: " + "{reason}" + ); + public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE = TextKey.of( + "iris.modded.irismoddedcommands.teleport_cancelled_player_offline", + "Teleport into " + "{dimensionId}" + " cancelled: " + "{value}" + " is no longer online." + ); + public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_REFUSED = TextKey.of( + "iris.modded.irismoddedcommands.teleport_refused", + "Teleport into " + "{dimensionId}" + " was refused by the server." + ); public static final TextKey IRIS_MODDED_COMMANDS_TELEPORTING = TextKey.of( "iris.modded.irismoddedcommands.teleporting", "Teleporting " + "{value}" + " to " + "{dimensionId}" + "..." @@ -1266,6 +1278,9 @@ public final class ModdedCommandMessages { IRIS_MODDED_COMMANDS_CONSOLE_MUST_NAME_PLAYER_IRIS_TP_DIMENSION_PLAYER, IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS, IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, + IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON, + IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE, + IRIS_MODDED_COMMANDS_TELEPORT_REFUSED, IRIS_MODDED_COMMANDS_TELEPORTING, IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS_2, IRIS_MODDED_COMMANDS_CANNOT_EVACUATE_PRIMARY_WORLD_THERE_IS_NOWHERE_SEND_PLAYERS, 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 e29deb1c7..5fe28ac2d 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 @@ -30,6 +30,7 @@ import art.arcane.iris.studio.view.PregeneratorJob; import art.arcane.iris.integration.Identifier; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockResolution; @@ -38,6 +39,7 @@ import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -47,6 +49,7 @@ import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterUpdate; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import it.unimi.dsi.fastutil.ints.IntArrayList; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -149,7 +152,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { int centerZ = player.z() >> 4; for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) { for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) { - updateChunk(engine, level, centerX + dx, centerZ + dz); + updatePassChunk(engine, level, centerX + dx, centerZ + dz); } } }); @@ -157,15 +160,29 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { private void updateForcedChunks(Engine engine, NativeWorld level) { new NativeWorldMaintenance(level).forEachForcedChunk(chunkKey -> - updateChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32))); + updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32))); } public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { updateChunk(engine, level, chunkX, chunkZ, false); } - private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { - updateChunk(engine, level, chunkX, chunkZ, true); + /** + * One chunk of a level pass. A failed chunk keeps its flags and retries on a later pass without stopping the + * chunks after it; only a closed engine ends the level's pass. + */ + private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) { + try { + updateChunk(engine, level, chunkX, chunkZ, true); + } catch (Throwable failure) { + if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) { + throw failure; + } + if (IrisLogging.errorOnce("chunk-update:" + level.name() + ":" + chunkX + "," + chunkZ + ":" + failure, + "Iris chunk update failed at " + chunkX + "," + chunkZ + " in " + level.name() + ", retrying each pass: " + failure)) { + IrisLogging.reportError(failure); + } + } } private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) { @@ -195,6 +212,11 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, () -> runCustomPass(engine, level, chunkX, chunkZ, chunk)); chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk)); }); + } catch (SavedBiomeUnavailableException unavailable) { + if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) { + throw unavailable; + } + IrisLogging.debug("Iris chunk update deferred at " + chunkX + "," + chunkZ + ": saved biomes loading"); } finally { chunk.release(); } @@ -315,15 +337,40 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } - chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> { - if (v != null && v.isUpdate()) { - update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk); - } - }); + IntArrayList completed = new IntArrayList(); + try { + chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> { + if (v != null && v.isUpdate()) { + update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk); + completed.add((yf << 8) | ((x & 15) << 4) | (z & 15)); + } + }); + } catch (RuntimeException | Error failure) { + forgetCompletedUpdates(chunk, completed, failure); + throw failure; + } chunk.deleteSlices(MatterUpdate.class); engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds()); } + private static void forgetCompletedUpdates(MantleChunk chunk, IntArrayList completed, Throwable failure) { + try { + for (int index = 0; index < completed.size(); index++) { + int position = completed.getInt(index); + int y = position >>> 8; + Matter section = chunk.get(y >> 4); + if (section != null && section.hasSlice(MatterUpdate.class)) { + section.getSlice(MatterUpdate.class) + .set((position >> 4) & 15, y & 15, position & 15, null); + } + } + } catch (RuntimeException | Error cleanupFailure) { + if (cleanupFailure != failure) { + failure.addSuppressed(cleanupFailure); + } + } + } + private void update(Engine engine, NativeWorld level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk) { if (y < level.minHeight() || y >= level.maxHeight() - 1) { return; @@ -337,7 +384,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } try { ModdedLootApplier.apply(engine, level, pos, state, chunk); + } catch (SavedBiomeUnavailableException unavailable) { + GenerationFailures.rethrowEngineFailure(unavailable); + IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(), + "Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage()); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } else { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java new file mode 100644 index 000000000..72134991d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java @@ -0,0 +1,111 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.modded.command.IrisModdedCommands; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.ChunkGenerator; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedEngineBootstrapShutdownTest { + private IrisSettings previousSettings; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Before + public void openRuntime() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + reopenBurstPools(); + } + + @After + public void restoreRuntime() { + reopenBurstPools(); + IrisModdedCommands.openDownloadAdmission(); + IrisSettings.settings = previousSettings; + } + + @Test + public void serverStoppingKeepsEnginesAndWorkersOpenUntilServerStopped() throws Exception { + NativeWorld level = level(); + Engine engine = mock(Engine.class); + ModdedWorldEngines.installReplacement(level, engine); + Callable worker = Thread::currentThread; + + ModdedEngineBootstrap.stop(); + + verify(engine, never()).close(); + assertTrue(ModdedWorldEngines.activeEngines().contains(engine)); + assertNotSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS)); + + ModdedEngineBootstrap.stopped(); + + verify(engine).close(); + assertFalse(ModdedWorldEngines.activeEngines().contains(engine)); + assertSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS)); + } + + @Test + public void nextServerStartClosesTheEnginesOfAServerThatNeverReachedStopped() { + NativeWorld level = level(); + Engine engine = mock(Engine.class); + ModdedWorldEngines.installReplacement(level, engine); + ModdedEngineBootstrap.stop(); + + try (MockedStatic startup = mockStatic(ModdedStartup.class); + MockedConstruction spawn = mockConstruction(NativeServerSpawn.class)) { + ModdedEngineBootstrap.serverAboutToStart(mock(NativeModdedServer.class)); + } + + verify(engine).close(); + assertFalse(ModdedWorldEngines.activeEngines().contains(engine)); + } + + private static NativeWorld level() { + ServerLevel serverLevel = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(serverLevel.getChunkSource()).thenReturn(chunks); + when(chunks.getGenerator()).thenReturn(mock(ChunkGenerator.class)); + NativeWorld level = mock(NativeWorld.class); + when(level.nativeHandle()).thenReturn(serverLevel); + when(level.name()).thenReturn("example:shutdown"); + return level; + } + + private static void reopenBurstPools() { + MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); + MultiBurst.ioBurst.reopen(); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java new file mode 100644 index 000000000..97ef1c08c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java @@ -0,0 +1,194 @@ +package art.arcane.iris.modded; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Assume; +import org.junit.Test; +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedMixinAuditTest { + private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;"; + private static final Set INJECTOR_ANNOTATIONS = Set.of( + "Lorg/spongepowered/asm/mixin/injection/Inject;", + "Lorg/spongepowered/asm/mixin/injection/Redirect;", + "Lorg/spongepowered/asm/mixin/injection/ModifyArg;", + "Lorg/spongepowered/asm/mixin/injection/ModifyArgs;", + "Lorg/spongepowered/asm/mixin/injection/ModifyVariable;", + "Lorg/spongepowered/asm/mixin/injection/ModifyConstant;"); + private static final Pattern NEOFORGE_MIXIN_CONFIG = Pattern.compile( + "\\[\\[mixins]]\\s*config\\s*=\\s*\"([^\"]+)\""); + // One applied mixin per config proves the config was registered, which is all the audit detects. + private static final Set UNAUDITED = Set.of( + "volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin", + "volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess", + "volmlib.entity.mixins.json/ChunkTerrainReceiptMixin", + "volmlib.entity.mixins.json/LevelChunkTerrainReceiptMixin", + "volmlib.entity.mixins.json/SerializableTerrainReceiptMixin", + "volmlib.fabric.mixins.json/BlockItemMixin", + "volmlib.fabric.mixins.json/BlockMixin", + "volmlib.fabric.mixins.json/PackRepositoryMixin"); + + @Test + public void everyExpectedHandlerIsAnInjectorDeclaredByItsMixin() throws IOException { + for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) { + MixinConfig config = MixinConfig.read(expected.config()); + String section = config.sections().get(expected.mixinName()); + assertNotNull(expected.config() + " does not list " + expected.mixinName(), section); + assertEquals(expected.mixinName() + " client-only flag disagrees with its config section", + "client".equals(section), expected.clientOnly()); + + MixinClass mixin = MixinClass.read(config.packageName() + '.' + expected.mixinName()); + assertTrue(expected.mixinName() + " does not target " + expected.targetClass().className() + + " (targets " + mixin.targets() + ")", + mixin.targets().contains(expected.targetClass().className())); + assertTrue(expected.mixinName() + " declares no injector handler named " + expected.handlerMethod() + + " (injectors " + mixin.injectors() + ")", + mixin.injectors().contains(expected.handlerMethod())); + } + } + + @Test + public void everyRegisteredMixinIsAuditedOrDeliberatelyUnaudited() throws IOException { + Set registered = registeredConfigs(); + Assume.assumeFalse("loader metadata on this classpath declares no mixin configs", registered.isEmpty()); + Set audited = new LinkedHashSet<>(); + for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) { + audited.add(expected.config() + '/' + expected.mixinName()); + } + + Set listed = new TreeSet<>(); + for (String configName : registered) { + for (String mixinName : MixinConfig.read(configName).sections().keySet()) { + listed.add(configName + '/' + mixinName); + } + } + + Set uncovered = new TreeSet<>(listed); + uncovered.removeAll(audited); + uncovered.removeAll(UNAUDITED); + assertEquals("mixins neither audited nor deliberately unaudited", Set.of(), uncovered); + + Set unregisteredAudits = new TreeSet<>(audited); + unregisteredAudits.removeAll(listed); + assertEquals("audited mixins missing from the registered configs", Set.of(), unregisteredAudits); + } + + private static Set registeredConfigs() throws IOException { + Set configs = new LinkedHashSet<>(); + String fabric = readResource("fabric.mod.json"); + if (fabric != null) { + for (JsonElement entry : JsonParser.parseString(fabric).getAsJsonObject().getAsJsonArray("mixins")) { + configs.add(entry.isJsonObject() + ? entry.getAsJsonObject().get("config").getAsString() + : entry.getAsString()); + } + } + String neoforge = readResource("META-INF/neoforge.mods.toml"); + if (neoforge != null) { + Matcher matcher = NEOFORGE_MIXIN_CONFIG.matcher(neoforge); + while (matcher.find()) { + configs.add(matcher.group(1)); + } + } + return configs; + } + + private static String readResource(String name) throws IOException { + try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(name)) { + return input == null ? null : new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private record MixinConfig(String packageName, Map sections) { + static MixinConfig read(String name) throws IOException { + String json = readResource(name); + assertNotNull("Missing mixin config resource " + name, json); + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + Map sections = new LinkedHashMap<>(); + for (String section : new String[]{"mixins", "client", "server"}) { + JsonArray entries = root.getAsJsonArray(section); + if (entries == null) { + continue; + } + for (JsonElement entry : entries) { + sections.put(entry.getAsString(), section); + } + } + return new MixinConfig(root.get("package").getAsString(), sections); + } + } + + private record MixinClass(Set targets, Set injectors) { + static MixinClass read(String className) throws IOException { + String resource = className.replace('.', '/') + ".class"; + byte[] bytes; + try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(resource)) { + assertNotNull("Missing mixin class " + className, input); + bytes = input.readAllBytes(); + } + Set targets = new TreeSet<>(); + Set injectors = new TreeSet<>(); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + if (!MIXIN_ANNOTATION.equals(descriptor)) { + return null; + } + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitArray(String name) { + return new AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visit(String ignored, Object value) { + if (value instanceof Type type) { + targets.add(type.getClassName()); + } else if (value instanceof String target) { + targets.add(target.replace('/', '.')); + } + } + }; + } + }; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, + String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public AnnotationVisitor visitAnnotation(String annotation, boolean visible) { + if (INJECTOR_ANNOTATIONS.contains(annotation)) { + injectors.add(name); + } + return null; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return new MixinClass(targets, injectors); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java new file mode 100644 index 000000000..a6abc2183 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.iris.modded.localization.ModdedCommandMessages; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.localization.MessageArgument; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisModdedCommandsTeleportTest { + private static final String DIMENSION = "irisworldgen:1"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void teleportWaitsForAColdDestinationWithinABoundedDeadline() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeCommandSource source = mock(NativeCommandSource.class); + when(source.server()).thenReturn(server); + NativeWorld level = mock(NativeWorld.class); + when(level.name()).thenReturn(DIMENSION); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + when(player.name()).thenReturn("Tester"); + AtomicReference requested = new AtomicReference<>(); + + long before = System.nanoTime(); + try (MockedStatic generators = mockStatic(NativeWorldGenerators.class); + MockedStatic dimensions = mockStatic(ModdedDimensionManager.class); + MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) { + generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)) + .thenReturn(mock(IrisModdedChunkGenerator.class)); + dimensions.when(() -> ModdedDimensionManager.level(server, DIMENSION)).thenReturn(level); + teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> { + requested.set(invocation.getArgument(1)); + return new CompletableFuture(); + }); + + assertEquals(1, IrisModdedCommands.tp(source, level, player)); + } + long after = System.nanoTime(); + + long deadline = requested.get().deadlineNanos(); + assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L)); + assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L)); + } + + @Test(timeout = 15_000L) + public void expiredTeleportReportsTheCallersDeadline() throws Exception { + MinecraftServer server = mock(MinecraftServer.class); + when(server.isSameThread()).thenReturn(true); + ResourceKey dimension = ResourceKey.create(Registries.DIMENSION, Identifier.parse(DIMENSION)); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(chunks.addTicketAndLoadWithRadius(any(), any(), anyInt())).thenReturn(new CompletableFuture<>()); + ServerLevel serverLevel = mock(ServerLevel.class); + when(serverLevel.getServer()).thenReturn(server); + when(serverLevel.dimension()).thenReturn(dimension); + when(serverLevel.getChunkSource()).thenReturn(chunks); + when(server.getLevel(dimension)).thenReturn(serverLevel); + ServerPlayer serverPlayer = mock(ServerPlayer.class); + when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID()); + NativeWorld level = mock(NativeWorld.class); + when(level.nativeHandle()).thenReturn(serverLevel); + + CompletableFuture teleport = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(serverPlayer), + new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), level, 8.5D, + Double.MIN_VALUE, 8.5D, System.nanoTime() + TimeUnit.SECONDS.toNanos(2L))); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> teleport.get(10L, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof TimeoutException); + assertTrue(failure.getCause().getMessage(), failure.getCause().getMessage().contains("within 2 seconds")); + } + + @Test + public void failureReportsItsRootCauseInsteadOfAnUnloadedDimension() { + String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester", + new CompletionException(new IllegalStateException("Exception generating new chunk")), true, true); + + assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON, + MessageArgument.untrusted("dimensionId", DIMENSION), + MessageArgument.untrusted("reason", "Exception generating new chunk")), message); + } + + @Test + public void failureWithoutMessageNamesTheFailureType() { + String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester", new TimeoutException(), true, true); + + assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON, + MessageArgument.untrusted("dimensionId", DIMENSION), + MessageArgument.untrusted("reason", "TimeoutException")), message); + } + + @Test + public void unloadedDimensionIsReportedOnlyWhenItIsGone() { + assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, + MessageArgument.untrusted("dimensionId", DIMENSION)), + IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, false, true)); + assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE, + MessageArgument.untrusted("dimensionId", DIMENSION), + MessageArgument.untrusted("value", "Tester")), + IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, false)); + assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED, + MessageArgument.untrusted("dimensionId", DIMENSION)), + IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, true)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java index 0e0279392..dfc35ba19 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java @@ -2,15 +2,30 @@ package art.arcane.iris.modded.service; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld; +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedLootApplier; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance; +import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleHooks; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; import art.arcane.volmlib.util.matter.MatterUpdate; import art.arcane.volmlib.util.matter.slices.UpdateMatter; import net.minecraft.SharedConstants; @@ -24,19 +39,33 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.material.FlowingFluid; import org.junit.Test; import org.mockito.InOrder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.LongConsumer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ModdedChunkUpdateServiceTest { @@ -70,6 +99,148 @@ public class ModdedChunkUpdateServiceTest { assertTrue(chunk.deleted()); } + @Test + public void loadingSavedBiomeDefersThePassAndForgetsOnlyCompletedLoot() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + IrisWorld world = mock(IrisWorld.class); + when(world.minHeight()).thenReturn(-64); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(world); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + ServerLevel level = mock(ServerLevel.class); + when(level.getMinY()).thenReturn(-64); + when(level.getMaxY()).thenReturn(319); + when(level.getHeight()).thenReturn(384); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.CHEST.defaultBlockState()); + ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk(); + SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException( + "Saved biome information is loading at chunk 0,0. Try again shortly.", true); + + try (MockedStatic loot = mockStatic(ModdedLootApplier.class)) { + loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> { + NativeBlockPoint position = invocation.getArgument(2); + if (position.x() == 2) { + throw loading; + } + return null; + }); + + SavedBiomeUnavailableException thrown = assertThrows(SavedBiomeUnavailableException.class, + () -> new ModdedChunkUpdateService().runUpdatePass( + engine, new ModdedPlatformWorld(level), 0, 0, chunk)); + + assertSame(loading, thrown); + } + assertFalse(chunk.deleted()); + verify(chunk.slice).set(1, 5, 1, null); + verify(chunk.slice, never()).set(2, 5, 2, null); + } + + @Test + public void loadingSavedBiomeLeavesTheChunkUnetchedWithoutAnErrorAndRetriesOnTheNextPass() { + ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk(); + Engine engine = updateEngine(chunk); + NativeWorld level = chestLevel(); + AtomicInteger loadingAttempts = new AtomicInteger(); + ModdedChunkUpdateService service = new ModdedChunkUpdateService(); + + try (MockedStatic loot = mockStatic(ModdedLootApplier.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> { + NativeBlockPoint position = invocation.getArgument(2); + if (position.x() == 2 && loadingAttempts.getAndIncrement() == 0) { + throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk 0,0.", true); + } + return null; + }); + + service.updateRegeneratedChunk(engine, level, 0, 0); + + assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); + assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); + assertFalse(chunk.deleted()); + logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never()); + logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); + + service.updateRegeneratedChunk(engine, level, 0, 0); + + assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); + assertTrue(chunk.deleted()); + } + } + + @Test + public void permanentSavedBiomeUnavailabilityCompletesThePassWithAWarning() { + ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk(); + Engine engine = updateEngine(chunk); + NativeWorld level = chestLevel(); + String reason = "This chunk has no exact saved Iris biome assignment for generation 3."; + + try (MockedStatic loot = mockStatic(ModdedLootApplier.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> { + NativeBlockPoint position = invocation.getArgument(2); + if (position.x() == 2) { + throw new SavedBiomeUnavailableException(reason, false); + } + return null; + }); + + new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0); + + assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); + assertTrue(chunk.deleted()); + logging.verify(() -> IrisLogging.warnOnce(anyString(), contains(reason))); + logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never()); + logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); + } + } + + @Test + public void failedChunkDoesNotStopTheLevelPassForItsOtherChunks() { + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + FailingMantleChunk failing = new FailingMantleChunk(); + ChestUpdateMantleChunk healthy = new ChestUpdateMantleChunk(); + @SuppressWarnings("unchecked") + Mantle mantle = mock(Mantle.class); + when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> failing.use()); + when(mantle.useChunk(4, 0)).thenAnswer((InvocationOnMock invocation) -> healthy.use()); + Engine engine = engineWith(mantle); + NativeWorld level = chestLevel(); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.worlds()).thenReturn(List.of(level)); + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + when(generator.engineIfBound()).thenReturn(engine); + + try (MockedStatic generators = mockStatic(NativeWorldGenerators.class); + MockedStatic loot = mockStatic(ModdedLootApplier.class); + MockedStatic logging = mockStatic(IrisLogging.class); + MockedConstruction maintenance = mockConstruction(NativeWorldMaintenance.class, + (NativeWorldMaintenance mocked, MockedConstruction.Context context) -> { + when(mocked.hasForcedChunks()).thenReturn(true); + doAnswer((InvocationOnMock invocation) -> { + LongConsumer action = invocation.getArgument(0); + action.accept(0L); + action.accept(4L); + return null; + }).when(mocked).forEachForcedChunk(any(LongConsumer.class)); + })) { + generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)).thenReturn(generator); + + new ModdedChunkUpdateService().onServerTick(server); + + assertFalse(failing.isFlagged(MantleFlag.ETCHED)); + assertTrue(healthy.isFlagged(MantleFlag.ETCHED)); + assertTrue(healthy.deleted()); + logging.verify(() -> IrisLogging.errorOnce(anyString(), anyString())); + } finally { + IrisSettings.settings = previousSettings; + } + } + @Test public void scansWhenPlayersArePresent() { assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false)); @@ -166,6 +337,94 @@ public class ModdedChunkUpdateServiceTest { } } + private static final class ChestUpdateMantleChunk extends MantleChunk { + @SuppressWarnings("unchecked") + private final MatterSlice slice = mock(MatterSlice.class); + private final Matter section = mock(Matter.class); + private boolean deleted; + + private ChestUpdateMantleChunk() { + super(1, 0, 0, emptyAdapter(), MantleHooks.NONE); + when(section.hasSlice(MatterUpdate.class)).thenReturn(true); + when(section.getSlice(MatterUpdate.class)).thenReturn(slice); + } + + @Override + public Matter get(int index) { + return section; + } + + @Override + public void iterate( + Class type, + Consumer4 iterator + ) { + if (type == MatterUpdate.class) { + iterator.accept(1, 5, 1, type.cast(UpdateMatter.ON)); + iterator.accept(2, 5, 2, type.cast(UpdateMatter.ON)); + } + } + + @Override + public void deleteSlices(Class type) { + if (type == MatterUpdate.class) { + deleted = true; + } + } + + private boolean deleted() { + return deleted; + } + } + + private static Engine updateEngine(ChestUpdateMantleChunk chunk) { + @SuppressWarnings("unchecked") + Mantle mantle = mock(Mantle.class); + when(mantle.isChunkLoaded(0, 0)).thenReturn(true); + when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED)); + when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use()); + return engineWith(mantle); + } + + private static Engine engineWith(Mantle mantle) { + IrisWorld world = mock(IrisWorld.class); + when(world.minHeight()).thenReturn(-64); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getMantle()).thenReturn(mantle); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(world); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + when(engine.getMantle()).thenReturn(engineMantle); + return engine; + } + + private static NativeWorld chestLevel() { + NativeBlockState chest = mock(NativeBlockState.class); + when(chest.isStorage()).thenReturn(true); + when(chest.isStorageChest()).thenReturn(true); + NativeWorld level = mock(NativeWorld.class); + when(level.name()).thenReturn("example:updates"); + when(level.minHeight()).thenReturn(-64); + when(level.maxHeight()).thenReturn(320); + when(level.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + when(level.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(chest); + return level; + } + + private static final class FailingMantleChunk extends MantleChunk { + private FailingMantleChunk() { + super(1, 0, 0, emptyAdapter(), MantleHooks.NONE); + } + + @Override + public void iterate( + Class type, + Consumer4 iterator + ) { + throw new IllegalStateException("corrupt " + type.getSimpleName() + " slice"); + } + } + @SuppressWarnings("unchecked") private static MantleDataAdapter emptyAdapter() { return (MantleDataAdapter) Proxy.newProxyInstance( diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index afc475c96..e871bc6b4 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -24,6 +24,7 @@ import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObjectPlacement; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.spi.IrisLogging; @@ -233,6 +234,7 @@ final class IrisBiomeLayerGenerator { data.add(layer.get(random, layerIndex + offset, (wx + offset) / zoom, offset, (wz - offset) / zoom, rdata)); } catch (Throwable error) { + GenerationFailures.rethrowEngineFailure(error); IrisLogging.reportError(error); } } @@ -330,6 +332,7 @@ final class IrisBiomeLayerGenerator { blocks.add(layer.get(random, layerIndex + offset, (x + offset) / zoom, offset, (z - offset) / zoom, data)); } catch (Throwable error) { + GenerationFailures.rethrowEngineFailure(error); IrisLogging.reportError(error); } } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java index 6c408f217..1a372cd96 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java @@ -18,6 +18,7 @@ package art.arcane.iris.generation.decoration; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.biome.IrisBiome; @@ -89,6 +90,7 @@ final class DecoratorCore { } } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } @@ -163,6 +165,7 @@ final class DecoratorCore { data.set(x, lowerY, z, lower); data.set(x, upperY, z, upper); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } return; @@ -356,6 +359,7 @@ final class DecoratorCore { data.set(xf, lowerY, zf, lower); data.set(xf, upperY, zf, upper); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } return; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index ffccce3ab..bc8178418 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.stream.ProvisionalSampling; @@ -713,7 +714,7 @@ public final class HydrologyTileCache implements AutoCloseable { public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) { if (closed.get()) { - throw new IllegalStateException("Hydrology tile cache is closed."); + throw new GenerationClosedException("Hydrology tile cache is closed."); } sharedCacheScope = Objects.requireNonNull(scope, "scope"); persistentStore = persistentRoot == null diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java index bd7929e59..172478b39 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.mantle; import java.io.UncheckedIOException; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.biome.FloatingIslandSample; @@ -231,6 +232,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { throw failure; } catch (Throwable e) { transaction.discard(); + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } @@ -326,6 +328,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { throw failure; } catch (Throwable e) { transaction.discard(); + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } @@ -438,6 +441,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { throw failure; } catch (Throwable e) { transaction.discard(); + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } @@ -611,11 +615,13 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveBottomObjects(target), data, sizeCache, warnedLargeObjects)); } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } return maxObjectExtent; @@ -649,6 +655,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } radius = Math.max(radius, reach); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index 32f3478c2..ca4dad733 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -22,6 +22,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.block.B; import art.arcane.iris.generation.cache.Cache; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.UpperDimensionContext; import art.arcane.iris.generation.runtime.Engine; @@ -475,6 +476,7 @@ public class MantleObjectComponent extends IrisMantleComponent { nullObjects += result.nullObjects(); errors += result.errors(); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Failed to place objects in the following biome: " + surfaceBiome.getName()); @@ -508,6 +510,7 @@ public class MantleObjectComponent extends IrisMantleComponent { nullObjects += result.nullObjects(); errors += result.errors(); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Failed to place cave objects in the following biome: " + caveBiome.getName()); @@ -538,6 +541,7 @@ public class MantleObjectComponent extends IrisMantleComponent { nullObjects += result.nullObjects(); errors += result.errors(); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Failed to place objects in the following region: " + region.getName()); @@ -571,6 +575,7 @@ public class MantleObjectComponent extends IrisMantleComponent { nullObjects += result.nullObjects(); errors += result.errors(); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Failed to place cave objects in the following region: " + region.getName()); @@ -760,6 +765,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " commit=" + commitResult); } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); IrisLogging.error("Failed to place procedural object '" + p.getName() + "' in " + scope); } @@ -988,6 +994,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " density=" + density); } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Regen object placement exception: chunk=" + chunkX + "," + chunkZ @@ -1192,6 +1199,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " density=" + density); } } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); errors++; IrisLogging.reportError(e); IrisLogging.error("Regen cave object placement exception: chunk=" + metricChunkX + "," + metricChunkZ @@ -1251,6 +1259,7 @@ public class MantleObjectComponent extends IrisMantleComponent { try { placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-biome-surface"); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); IrisLogging.error("Failed to place upper-dimension objects in biome " + upperBiome.getName() + ": " + i.getPlace().toString(", ") + " (" + e.getClass().getSimpleName() + ")"); @@ -1266,6 +1275,7 @@ public class MantleObjectComponent extends IrisMantleComponent { try { placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-region-surface"); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); IrisLogging.error("Failed to place upper-dimension objects in region " + upperRegion.getName() + ": " + i.getPlace().toString(", ") + " (" + e.getClass().getSimpleName() + ")"); @@ -1790,6 +1800,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } radius = Math.max(radius, reach); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java index b0d73e233..90b3ac9fb 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java @@ -29,6 +29,7 @@ import art.arcane.iris.integration.Identifier; import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.world.history.TransitionGenerationPlan; @@ -1495,6 +1496,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { try { setData(pos.getX(), pos.getY(), pos.getZ(), data); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); // Reached per position while an object writes past the edge of the writer window, which is // how a large object is clipped. Nothing here is actionable from outside the engine. IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString()); diff --git a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java index d0fe437c9..61216e507 100644 --- a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java @@ -23,7 +23,6 @@ import art.arcane.iris.generation.stage.IrisDecorantActuator; import art.arcane.iris.generation.stage.IrisTerrainNormalActuator; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineMode; -import art.arcane.iris.generation.runtime.EnginePlatformHooks; import art.arcane.iris.generation.runtime.EngineStage; import art.arcane.iris.generation.runtime.IrisEngineMode; import art.arcane.iris.generation.stage.IrisCarveModifier; @@ -32,17 +31,10 @@ import art.arcane.iris.generation.stage.IrisDepositModifier; import art.arcane.iris.generation.stage.IrisFloatingChildBiomeModifier; import art.arcane.iris.generation.stage.IrisPerfectionModifier; import art.arcane.iris.generation.stage.IrisPostModifier; -import art.arcane.iris.spi.IrisLogging; - -import java.util.concurrent.atomic.AtomicLong; public class ModeOverworld extends IrisEngineMode implements EngineMode { - private static final AtomicLong lastMaintenanceBypassLog = new AtomicLong(0L); - private final EnginePlatformHooks platformHooks; - public ModeOverworld(Engine engine) { super(engine); - platformHooks = engine.getPlatformHooks(); IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine()); IrisBiomeActuator biome = new IrisBiomeActuator(getEngine()); IrisDecorantActuator decorant = new IrisDecorantActuator(getEngine()); @@ -53,51 +45,17 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { IrisCustomModifier custom = new IrisCustomModifier(getEngine()); IrisFloatingChildBiomeModifier floatingChildBiomes = new IrisFloatingChildBiomeModifier(getEngine()); EngineStage sBiome = (x, z, k, p, m, c) -> biome.actuate(x, z, p, m, c); - EngineStage sGenMatter = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - generateTerrainMatter( - x >> 4, - z >> 4, - m || getEngine().isStudio(), - c); - }; + EngineStage sGenMatter = (x, z, k, p, m, c) -> generateTerrainMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c); EngineStage sTerrain = (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c); EngineStage sDecorant = (x, z, k, p, m, c) -> decorant.actuate(x, z, k, m, c); - EngineStage sCave = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - cave.modify(x >> 4, z >> 4, k, m, c); - }; - EngineStage sDeposit = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - deposit.modify(x, z, k, m, c); - }; - EngineStage sPost = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - post.modify(x, z, k, m, c); - }; - EngineStage sInsertMatter = (x, z, K, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - getMantle().insertMatter(x >> 4, z >> 4, K, m, c); - }; + EngineStage sCave = (x, z, k, p, m, c) -> cave.modify(x >> 4, z >> 4, k, m, c); + EngineStage sDeposit = (x, z, k, p, m, c) -> deposit.modify(x, z, k, m, c); + EngineStage sPost = (x, z, k, p, m, c) -> post.modify(x, z, k, m, c); + EngineStage sInsertMatter = (x, z, K, p, m, c) -> getMantle().insertMatter(x >> 4, z >> 4, K, m, c); EngineStage sFloatingTerrainSolid = (x, z, k, p, m, c) -> floatingChildBiomes.modify(x, z, k, m, c); EngineStage sFloatingDecorate = (x, z, k, p, m, c) -> floatingChildBiomes.decorateColumns(x, z, k, m, c); EngineStage sPerfection = (x, z, k, p, m, c) -> perfection.modify(x, z, k, m, c); - EngineStage sCustom = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages()) { - return; - } - custom.modify(x, z, k, m, c); - }; + EngineStage sCustom = (x, z, k, p, m, c) -> custom.modify(x, z, k, m, c); // Matter runs on the calling thread so its window fans out across the burst pool (a pool // thread would run every chunk of the window inline) while biome and terrain run alongside. @@ -114,11 +72,7 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { // the surface sInsertMatter writes), so parallel order is scheduler-dependent. The // production path already runs them inline in this order; sequential registration // makes studio (the only multicore path) match production and the goldenhash baseline. - registerStage((x, z, k, p, m, c) -> { - if (!shouldBypassMantleStages()) { - generateContentMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c); - } - }); + registerStage((x, z, k, p, m, c) -> generateContentMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c)); registerStage(sDeposit); registerStage(sInsertMatter); registerStage(sDecorant); @@ -128,17 +82,4 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { registerStage(sCustom); } } - - private boolean shouldBypassMantleStages() { - boolean active = platformHooks.shouldBypassMantleStages(getEngine()); - if (active) { - long now = System.currentTimeMillis(); - long last = lastMaintenanceBypassLog.get(); - if (now - last >= 5000L && lastMaintenanceBypassLog.compareAndSet(last, now)) { - IrisLogging.info("Maintenance regen bypass: skipping mantle-backed overworld stages for Folia safety."); - } - } - return active; - } - } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java index 5f563e06c..a5449c555 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java @@ -335,6 +335,7 @@ public final class DimensionTerrainContext implements DataProvider { } return new NoiseBounds(minimum, maximum); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); return ZERO_NOISE_BOUNDS; } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java index 44661074d..f8dabff8c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java @@ -94,7 +94,11 @@ public interface EnginePlatformHooks { return false; } - default boolean shouldBypassMantleStages(Engine engine) { + /** + * True while world maintenance forbids the mantle-backed stages. Generating without them would persist a + * chunk with no objects, caves or deposits, so the engine refuses the chunk instead. + */ + default boolean shouldRefuseGeneration(Engine engine) { return false; } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java new file mode 100644 index 000000000..fe6f0ee7d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java @@ -0,0 +1,7 @@ +package art.arcane.iris.generation.runtime; + +public final class GenerationClosedException extends IllegalStateException { + public GenerationClosedException(String message) { + super(message); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java new file mode 100644 index 000000000..17564f94c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import art.arcane.volmlib.util.mantle.MantleClosedException; + +import java.io.InterruptedIOException; +import java.nio.channels.ClosedChannelException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeoutException; + +/** + * Separates failures of the engine, its mantle, generation history, executors or the JVM from failures of one + * piece of content. Content loops (objects, decorators, layers) may skip a failed item because every rebuild + * skips it the same way. An engine failure is transient, so skipping would cache and persist terrain that a + * healthy engine never produces: it must fail the whole chunk instead. + */ +public final class GenerationFailures { + private static final int MAXIMUM_CAUSE_DEPTH = 32; + + private GenerationFailures() { + } + + public static boolean isEngineFailure(Throwable failure) { + if (Thread.currentThread().isInterrupted()) { + return true; + } + Throwable current = failure; + for (int depth = 0; current != null && depth < MAXIMUM_CAUSE_DEPTH; depth++, current = current.getCause()) { + if (current instanceof Error + || current instanceof GenerationClosedException + || current instanceof MantleClosedException + || current instanceof WrongEngineBroException + || current instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading() + || current instanceof InterruptedException + || current instanceof InterruptedIOException + || current instanceof ClosedChannelException + || current instanceof RejectedExecutionException + || current instanceof CancellationException + || current instanceof TimeoutException) { + return true; + } + } + return false; + } + + public static void rethrowEngineFailure(Throwable failure) { + if (!isEngineFailure(failure)) { + return; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new IllegalStateException("Iris generation stopped: " + failure, failure); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index f9f4b1b31..ffd5b3872 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -2044,6 +2044,7 @@ public class IrisComplex implements DataProvider { GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds); return bounds.noiseBounds; } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); long now = System.currentTimeMillis(); long last = lastBoundsFailureLog.get(); // The five second gate keeps this off the hot path; the once key keeps it out of a log diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 5fb5cd1e6..905f4ff78 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -86,6 +86,8 @@ import java.util.function.Supplier; @Data public class IrisEngine implements Engine { static final long SESSION_DRAIN_TIMEOUT_MILLIS = 15000L; + private static final long MAINTENANCE_REFUSAL_WARNING_MILLIS = 5000L; + private static final AtomicLong lastMaintenanceRefusalWarning = new AtomicLong(); private final AtomicInteger bud; private final AtomicInteger buds; @@ -1083,10 +1085,10 @@ public class IrisEngine implements Engine { throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine."); } if (retiringGenerationRuntimes.contains(required.runtime)) { - throw new IllegalStateException("Iris generation runtime binding is retiring."); + throw new GenerationClosedException("Iris generation runtime binding is retiring."); } if (!isGenerationRuntimeBindingLive(required)) { - throw new IllegalStateException("Iris generation runtime binding is closed or no longer owned."); + throw new GenerationClosedException("Iris generation runtime binding is closed or no longer owned."); } return threadState.open(required); } @@ -1485,6 +1487,9 @@ public class IrisEngine implements Engine { @Override public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException { awaitGenerationCacheWarm(); + if (platformHooks.shouldRefuseGeneration(this)) { + refuseMaintenanceGeneration(x >> 4, z >> 4); + } try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openGenerationHistoryCoordinateScope(x, z); GenerationSessionLease lease = acquireGenerationLease("chunk_generate"); @@ -1505,20 +1510,17 @@ public class IrisEngine implements Engine { activeMode.generate(x, z, blocks, vbiomes, multicore, lease.sessionId()); } - boolean skipRealFlag = platformHooks.shouldBypassMantleStages(this); - if (!skipRealFlag) { - MantleChunk chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4); - try { - synchronized (chunk) { - Matter section = chunk.get(0); - if (section != null) { - section.deleteSlice(ObjectContinuationBundle.class); - } - chunk.flag(MantleFlag.REAL, true); + MantleChunk chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4); + try { + synchronized (chunk) { + Matter section = chunk.get(0); + if (section != null) { + section.deleteSlice(ObjectContinuationBundle.class); } - } finally { - chunk.release(); + chunk.flag(MantleFlag.REAL, true); } + } finally { + chunk.release(); } getMetrics().getTotal().put(p.getMilliseconds()); generated.incrementAndGet(); @@ -1538,6 +1540,17 @@ public class IrisEngine implements Engine { } } + private void refuseMaintenanceGeneration(int chunkX, int chunkZ) throws GenerationSessionException { + long now = System.currentTimeMillis(); + long last = lastMaintenanceRefusalWarning.get(); + if (now - last >= MAINTENANCE_REFUSAL_WARNING_MILLIS && lastMaintenanceRefusalWarning.compareAndSet(last, now)) { + IrisLogging.warn("Iris refused chunk " + chunkX + "," + chunkZ + " in " + getWorld().name() + + ": world maintenance forbids mantle generation, the chunk stays ungenerated"); + } + throw new GenerationSessionException("Iris refused chunk " + chunkX + "," + chunkZ + " in " + + getWorld().name() + " during world maintenance.", true); + } + @Override public GenerationSessionManager getGenerationSessions() { return generationSessions; @@ -1682,7 +1695,7 @@ public class IrisEngine implements Engine { void requireRunning(String operation) { if (closed || closing.get() || lifecycleState != LifecycleState.RUNNING || runtime == null) { - throw new IllegalStateException("Cannot " + operation + " while Iris engine " + getWorld().name() + throw new GenerationClosedException("Cannot " + operation + " while Iris engine " + getWorld().name() + " is " + lifecycleState.name().toLowerCase() + "."); } } diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java index de24fa14d..75530bb34 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java @@ -21,6 +21,7 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.decoration.FloatingDecorator; import art.arcane.iris.generation.decoration.IrisSeaSurfaceDecorator; @@ -100,6 +101,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier " is_not_generated_by_iris = "{value} は Iris によって生成されていません。" teleport_failed_dimension_is_not_loaded = "テレポートに失敗しました: ディメンション {dimensionId} は読み込まれていません。" +teleport_failed_reason = "{dimensionId} へのテレポートに失敗しました: {reason}" +teleport_cancelled_player_offline = "{dimensionId} へのテレポートを中止しました: {value} はオフラインです。" +teleport_refused = "サーバーが {dimensionId} へのテレポートを拒否しました。" teleporting = "{value} を {dimensionId} にテレポートしています..." is_not_generated_by_iris_2 = "{value} は Iris によって生成されていません。" cannot_evacuate_primary_world_there_is_nowhere_send_players = "プライマリーワールドからは退避できません。プレイヤーの移動先がありません。" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index dcb44448c..93f39434e 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -772,6 +772,9 @@ opening_your_editor = "편집기에서 {value} {value2} 여는 중입니다." console_must_name_player_iris_tp_dimension_player = "콘솔은 플레이어를 지정해야 합니다: /iris tp " is_not_generated_by_iris = "{value} 에 의해 생성되지 않음 Iris." teleport_failed_dimension_is_not_loaded = "Teleport 실패: 차원 {dimensionId} 로드 되지 않습니다." +teleport_failed_reason = "{dimensionId}(으)로 순간이동 실패: {reason}" +teleport_cancelled_player_offline = "{dimensionId}(으)로 순간이동 취소됨: {value} 님이 더 이상 접속해 있지 않습니다." +teleport_refused = "서버가 {dimensionId}(으)로의 순간이동을 거부했습니다." teleporting = "팟캐스트 {value} 으로 {dimensionId}..." is_not_generated_by_iris_2 = "{value} 에 의해 생성되지 않음 Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "1 차 세계를 피할 수 없습니다. 플레이어에게 보내는 곳이 없습니다." diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index ec28e1cb6..1775faf79 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -774,6 +774,9 @@ opening_your_editor = "Atidaryti {value} {value2} redaktoriuje." console_must_name_player_iris_tp_dimension_player = "Konsolė turi pavadinti žaidėją: /iris tp " is_not_generated_by_iris = "{value} generuoja ne Iris." teleport_failed_dimension_is_not_loaded = "Teleportas nepavyko: dimensija {dimensionId} neįkrauta." +teleport_failed_reason = "Teleportavimas į {dimensionId} nepavyko: {reason}" +teleport_cancelled_player_offline = "Teleportavimas į {dimensionId} atšauktas: {value} nebėra prisijungęs." +teleport_refused = "Serveris atmetė teleportavimą į {dimensionId}." teleporting = "Teleportavimas {value} iki {dimensionId}..." is_not_generated_by_iris_2 = "{value} generuoja ne Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Negalima evakuoti pirminio pasaulio; nėra kur siųsti žaidėjų." diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index a204dfa86..6818e6baa 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -769,6 +769,9 @@ opening_your_editor = "Openen {value} {value2} in uw redacteur." console_must_name_player_iris_tp_dimension_player = "Console moet een speler een naam geven: /iris tp " is_not_generated_by_iris = "{value} wordt niet gegenereerd door Iris." teleport_failed_dimension_is_not_loaded = "Teleporteren mislukt: dimensie {dimensionId} is niet geladen." +teleport_failed_reason = "Teleporteren naar {dimensionId} mislukt: {reason}" +teleport_cancelled_player_offline = "Teleporteren naar {dimensionId} geannuleerd: {value} is niet meer online." +teleport_refused = "De server heeft het teleporteren naar {dimensionId} geweigerd." teleporting = "Teleporteren {value} tot {dimensionId}..." is_not_generated_by_iris_2 = "{value} wordt niet gegenereerd door Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Kan de primaire wereld niet evacueren; er is geen plek om spelers te sturen." diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index 5936cc4ae..c08cfd8d8 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -774,6 +774,9 @@ opening_your_editor = "Otwarcie {value} {value2} w twoim wydawcy." console_must_name_player_iris_tp_dimension_player = "Konsola musi nazwać gracza: /iris tp " is_not_generated_by_iris = "{value} nie jest generowany przez Iris." teleport_failed_dimension_is_not_loaded = "Teleport nie powiódł się: wymiar {dimensionId} nie jest załadowany." +teleport_failed_reason = "Teleportacja do {dimensionId} nie powiodła się: {reason}" +teleport_cancelled_player_offline = "Teleportacja do {dimensionId} anulowana: {value} nie jest już online." +teleport_refused = "Serwer odrzucił teleportację do {dimensionId}." teleporting = "Teleportowanie {value} do {dimensionId}..." is_not_generated_by_iris_2 = "{value} nie jest generowany przez Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Nie można ewakuować głównego świata; nie ma gdzie wysłać graczy." diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 5627c6c21..753610da6 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -769,6 +769,9 @@ opening_your_editor = "Abertura {value} {value2} no seu editor." console_must_name_player_iris_tp_dimension_player = "A consola deve nomear um jogador: /iris tp " is_not_generated_by_iris = "{value} não é gerado por Iris." teleport_failed_dimension_is_not_loaded = "Falha no teletransporte: dimensão {dimensionId} não está carregada." +teleport_failed_reason = "Falha no teletransporte para {dimensionId}: {reason}" +teleport_cancelled_player_offline = "Teletransporte para {dimensionId} cancelado: {value} já não está online." +teleport_refused = "O servidor recusou o teletransporte para {dimensionId}." teleporting = "Teletransportando {value} para {dimensionId}..." is_not_generated_by_iris_2 = "{value} não é gerado por Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Não é possível evacuar o mundo primário; não há nenhum lugar para enviar jogadores." diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index f305bd5f1..d5ac2aded 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -774,6 +774,9 @@ opening_your_editor = "Открытие {value} {value2} В вашем реда console_must_name_player_iris_tp_dimension_player = "Консоль должна называть игрока: /iris tp " is_not_generated_by_iris = "{value} не генерируется путем Iris." teleport_failed_dimension_is_not_loaded = "Телепорт провалился: измерение {dimensionId} не загружается." +teleport_failed_reason = "Не удалось телепортироваться в {dimensionId}: {reason}" +teleport_cancelled_player_offline = "Телепортация в {dimensionId} отменена: {value} больше не в сети." +teleport_refused = "Сервер отклонил телепортацию в {dimensionId}." teleporting = "телепортация {value} то {dimensionId}..." is_not_generated_by_iris_2 = "{value} не генерируется путем Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Нельзя эвакуировать основной мир, негде отправить игроков." diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index 5649ccba5..02163a561 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -774,6 +774,9 @@ opening_your_editor = "Açılış {value} {value2} editörünüzde." console_must_name_player_iris_tp_dimension_player = "Konsol bir oyuncu adı olmalıdır: /iris tp " is_not_generated_by_iris = "{value} tarafından üretilmiyor Iris." teleport_failed_dimension_is_not_loaded = "Teleport başarısız oldu: Boyut {dimensionId} yüklenemez." +teleport_failed_reason = "{dimensionId} boyutuna ışınlanma başarısız oldu: {reason}" +teleport_cancelled_player_offline = "{dimensionId} boyutuna ışınlanma iptal edildi: {value} artık çevrimiçi değil." +teleport_refused = "Sunucu {dimensionId} boyutuna ışınlanmayı reddetti." teleporting = "Telporting {value} tom için {dimensionId}..." is_not_generated_by_iris_2 = "{value} tarafından üretilmiyor Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "birincil dünyayı tahliye edemez; oyuncuları göndermek için hiçbir yer yoktur." diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 3c3b06cdb..616ac7557 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -772,6 +772,9 @@ opening_your_editor = "Mở {value} {value2} trong biên tập của anh." console_must_name_player_iris_tp_dimension_player = "Cần phải đặt tên người chơi: /iris tp " is_not_generated_by_iris = "{value} không được tạo ra bởi Iris." teleport_failed_dimension_is_not_loaded = "Chuyển tiếp thất bại: chiều {dimensionId} không nạp đạn." +teleport_failed_reason = "Dịch chuyển tới {dimensionId} thất bại: {reason}" +teleport_cancelled_player_offline = "Đã hủy dịch chuyển tới {dimensionId}: {value} không còn trực tuyến." +teleport_refused = "Máy chủ đã từ chối dịch chuyển tới {dimensionId}." teleporting = "Chuyển tiếp {value} phải {dimensionId}..." is_not_generated_by_iris_2 = "{value} không được tạo ra bởi Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Không thể sơ tán thế giới chính; không có nơi nào để gửi cầu thủ." diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index 471381179..ec1feb7bf 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -772,6 +772,9 @@ opening_your_editor = "开幕 {value} {value2} 在你的编辑。" console_must_name_player_iris_tp_dimension_player = "控制台必须给玩家命名 : /iris tp " is_not_generated_by_iris = "{value} 不由 Iris." teleport_failed_dimension_is_not_loaded = "Teleport 失败: 维度 {dimensionId} 未加载。" +teleport_failed_reason = "传送到 {dimensionId} 失败:{reason}" +teleport_cancelled_player_offline = "已取消传送到 {dimensionId}:{value} 已不在线。" +teleport_refused = "服务器拒绝了传送到 {dimensionId}。" teleporting = "远程传送 {value} 改为: {dimensionId}..." is_not_generated_by_iris_2 = "{value} 不由 Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "无法撤离主世界; 没有地方可以发送玩家 。" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index cb1ca9f36..400b91ce3 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -772,6 +772,9 @@ opening_your_editor = "開幕 {value} {value2} 在你的編輯。" console_must_name_player_iris_tp_dimension_player = "控制檯必須給玩家命名 : /iris tp " is_not_generated_by_iris = "{value} 不由 Iris." teleport_failed_dimension_is_not_loaded = "Teleport 失敗: 維度 {dimensionId} 未載入。" +teleport_failed_reason = "傳送到 {dimensionId} 失敗:{reason}" +teleport_cancelled_player_offline = "已取消傳送到 {dimensionId}:{value} 已不在線上。" +teleport_refused = "伺服器拒絕了傳送到 {dimensionId}。" teleporting = "遠端傳送 {value} 改為: {dimensionId}..." is_not_generated_by_iris_2 = "{value} 不由 Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "無法撤離主世界; 沒有地方可以傳送玩家 。" diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java new file mode 100644 index 000000000..4d497739e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java @@ -0,0 +1,157 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.cave.CarvingMode; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationClosedException; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleObjectComponentEngineFailureTest { + private IrisSettings previousSettings; + + @Before + public void bindSettings() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @After + public void restoreSettings() { + IrisSettings.settings = previousSettings; + } + + @Test + public void closedHistoryRouterDuringPlacementFailsTheChunkAndCachesNoPlan() { + GenerationClosedException closed = new GenerationClosedException("Generation-history runtime router is closed."); + Fixture fixture = new Fixture(closed); + + for (int attempt = 1; attempt <= 2; attempt++) { + try { + fixture.generate(); + fail("object placement swallowed a closed generation-history router"); + } catch (GenerationClosedException failure) { + assertSame(closed, failure); + } + assertEquals(attempt, fixture.placements.get()); + } + } + + @Test + public void objectSpecificFailureSkipsOnlyThatObject() { + Fixture fixture = new Fixture(new IllegalArgumentException("malformed object")); + + fixture.generate(); + + assertEquals(1, fixture.placements.get()); + } + + private static final class Fixture { + private final AtomicInteger placements = new AtomicInteger(); + private final MantleObjectComponent component; + private final MantleWriter writer; + private final ChunkContext context; + + @SuppressWarnings("unchecked") + private Fixture(RuntimeException placementFailure) { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + SeedManager seeds = mock(SeedManager.class); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getSeedManager()).thenReturn(seeds); + EngineMantle engineMantle = mock(EngineMantle.class); + IrisData data = mock(IrisData.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(data); + + IrisObject object = mock(IrisObject.class); + when(object.getLoadKey()).thenReturn("trees/closed-router"); + when(object.getW()).thenReturn(1); + when(object.getH()).thenReturn(1); + when(object.getD()).thenReturn(1); + doAnswer((InvocationOnMock invocation) -> { + placements.incrementAndGet(); + throw placementFailure; + }).when(object).place(anyInt(), anyInt(), anyInt(), any(IObjectPlacer.class), any(IrisObjectPlacement.class), + any(RNG.class), any(), any(), any()); + + IrisComplex complex = mock(IrisComplex.class); + IrisObjectPlacement placement = mock(IrisObjectPlacement.class); + when(placement.getChance()).thenReturn(2D); + when(placement.getCarvingSupport()).thenReturn(CarvingMode.SURFACE_ONLY); + when(placement.getPlace()).thenReturn(new KList<>("trees/closed-router")); + when(placement.getAllowedCollisions()).thenReturn(new KList<>()); + when(placement.getForbiddenCollisions()).thenReturn(new KList<>()); + when(placement.getDensity(any(RNG.class), anyDouble(), anyDouble(), any())).thenReturn(1); + when(placement.getObject(any(), any(RNG.class))).thenReturn(object); + when(placement.scaleObject(any(RNG.class), any(), any())).thenReturn(object); + + IrisBiome biome = new IrisBiome(); + biome.setLoadKey("closed-router-biome"); + biome.setObjects(new KList<>(placement)); + IrisRegion region = new IrisRegion(); + region.setLoadKey("closed-router-region"); + + ProceduralStream regions = mock(ProceduralStream.class); + when(regions.get(anyDouble(), anyDouble())).thenReturn(region); + ProceduralStream biomes = mock(ProceduralStream.class); + when(biomes.get(anyDouble(), anyDouble())).thenReturn(biome); + ProceduralStream heights = mock(ProceduralStream.class); + when(heights.get(anyDouble(), anyDouble())).thenReturn(64); + when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); + when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(true); + when(complex.getRegionStream()).thenReturn(regions); + when(complex.getTrueBiomeStream()).thenReturn(biomes); + when(complex.getRoundedHeighteightStream()).thenReturn(heights); + context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + + Mantle mantle = mock(Mantle.class); + when(mantle.getWorldHeight()).thenReturn(256); + writer = mock(MantleWriter.class); + when(writer.getMantle()).thenReturn(mantle); + when(writer.getEngine()).thenReturn(engine); + doAnswer((InvocationOnMock invocation) -> { + Runnable task = invocation.getArgument(2); + task.run(); + return null; + }).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + component = new MantleObjectComponent(engineMantle); + } + + private void generate() { + component.generateLayer(writer, 0, 0, context); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java new file mode 100644 index 000000000..375096bfc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.world.history.SavedBiomeUnavailableException; +import art.arcane.volmlib.util.mantle.MantleClosedException; +import org.junit.Test; + +import java.nio.channels.ClosedByInterruptException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.RejectedExecutionException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class GenerationFailuresTest { + @Test + public void engineAndLifecycleFailuresAreRecognizedThroughWrappers() { + assertTrue(GenerationFailures.isEngineFailure(new GenerationClosedException("router closed"))); + assertTrue(GenerationFailures.isEngineFailure(new MantleClosedException("Tectonic Plate is closed!"))); + assertTrue(GenerationFailures.isEngineFailure(new RejectedExecutionException("pool shut down"))); + assertTrue(GenerationFailures.isEngineFailure(new ClosedByInterruptException())); + assertTrue(GenerationFailures.isEngineFailure(new SavedBiomeUnavailableException("loading", true))); + assertTrue(GenerationFailures.isEngineFailure(new GenerationSessionException("sealed", true))); + assertTrue(GenerationFailures.isEngineFailure(new OutOfMemoryError())); + assertTrue(GenerationFailures.isEngineFailure(new CompletionException( + new IllegalStateException("placement failed", new GenerationClosedException("router closed"))))); + } + + @Test + public void contentFailuresRemainSkippable() { + assertFalse(GenerationFailures.isEngineFailure(new IllegalArgumentException("malformed object"))); + assertFalse(GenerationFailures.isEngineFailure(new NullPointerException("missing block"))); + assertFalse(GenerationFailures.isEngineFailure(new IllegalStateException("unrelated"))); + } + + @Test + public void permanentSavedBiomeUnavailabilityIsAContentFailure() { + assertFalse(GenerationFailures.isEngineFailure(new SavedBiomeUnavailableException( + "This chunk has no exact saved Iris biome assignment for generation 3.", false))); + assertFalse(GenerationFailures.isEngineFailure(new SavedBiomeUnavailableException( + "Unable to read the saved biome at chunk 0,0.", new IllegalArgumentException("bad header")))); + } + + @Test + public void anyFailureOnAnInterruptedThreadIsAnEngineFailure() { + Thread.currentThread().interrupt(); + try { + assertTrue(GenerationFailures.isEngineFailure(new IllegalArgumentException("malformed object"))); + } finally { + Thread.interrupted(); + } + } + + @Test + public void rethrowPropagatesEngineFailuresAndReturnsForContentFailures() { + GenerationClosedException closed = new GenerationClosedException("router closed"); + try { + GenerationFailures.rethrowEngineFailure(closed); + fail("engine failure was not rethrown"); + } catch (GenerationClosedException rethrown) { + assertSame(closed, rethrown); + } + + GenerationSessionException sealed = new GenerationSessionException("sealed", true); + try { + GenerationFailures.rethrowEngineFailure(sealed); + fail("checked engine failure was not rethrown"); + } catch (IllegalStateException wrapped) { + assertSame(sealed, wrapped.getCause()); + } + + GenerationFailures.rethrowEngineFailure(new IllegalArgumentException("malformed object")); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMaintenanceRefusalTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMaintenanceRefusalTest.java new file mode 100644 index 000000000..e8937edbd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMaintenanceRefusalTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisEngineMaintenanceRefusalTest { + @Test + @SuppressWarnings("unchecked") + public void maintenanceThatCannotRunMantleStagesRefusesTheChunkInsteadOfGeneratingIt() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(hooks.shouldRefuseGeneration(engine)).thenReturn(true); + Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks"); + platformHooks.setAccessible(true); + platformHooks.set(engine, hooks); + IrisWorld world = mock(IrisWorld.class); + when(world.name()).thenReturn("world"); + when(engine.getWorld()).thenReturn(world); + doCallRealMethod().when(engine).generate(anyInt(), anyInt(), any(), any(), anyBoolean()); + Hunk blocks = mock(Hunk.class); + Hunk biomes = mock(Hunk.class); + + GenerationSessionException refused = assertThrows(GenerationSessionException.class, + () -> engine.generate(32, 48, blocks, biomes, false)); + + assertTrue(refused.isExpectedTeardown()); + verify(engine, never()).acquireGenerationLease(anyString()); + verify(engine, never()).getMode(); + verifyNoInteractions(blocks, biomes); + } +}