From 76a07242b597f737f0a4dc8e5a67ae3c0c560f39 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 05:36:45 -0400 Subject: [PATCH 01/19] Fixed client mixin audit - Matches the volmlib$ handler names of the client world-open and world-type mixins - Keys expected mixins by their mixin config file and reports the target class name - Verifies every expected handler is an injector declared by its mixin class - Requires every registered volmlib mixin to be audited or deliberately unaudited --- .../arcane/iris/modded/ModdedMixinAudit.java | 26 +-- .../iris/modded/ModdedMixinAuditTest.java | 194 ++++++++++++++++++ 2 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java 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/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); + } + } +} From 3e4a2432bc621ac688a38d059f33154bcd6ff651 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 05:41:18 -0400 Subject: [PATCH 02/19] Semantic registry validation - Generated registry validation always re-renders recorded semantics with the current fixer - Validation canonicalizes the rendering like the installer - Renderer changes need no version bump - The renderer identity SPI, both identity strings and the identity-based source preference are gone - New captures record the fixer class as an informational renderer identity - The v1 history format is unchanged - The modded forced datapack, world check and datapack command use the runtime data version - That is the same data version validation uses - Tests cover a renderer change under an unchanged identity - Tests cover 26.3 bare natural_mob_spawns history against the overlay rendering - Tests cover semantic round trips for every supported fixer --- .../iris/probe/HeadlessNativePlatform.java | 3 +- .../iris/probe/BareMobSpawnHistoryTest.java | 156 ++++++++++++ .../probe/HeadlessGenerationRegistryTest.java | 3 - .../iris/modded/ModdedForcedDatapack.java | 9 +- .../iris/modded/ModdedGenerationRegistry.java | 14 +- .../arcane/iris/modded/ModdedPlatform.java | 3 +- .../modded/WorldCheckDimensionContract.java | 2 +- .../command/ModdedDatapackCommands.java | 4 +- .../iris/modded/ModdedForcedDatapackTest.java | 28 +++ .../platform/bukkit/nms/BukkitBinding.java | 2 +- .../bukkit/nms/BukkitGenerationRegistry.java | 4 +- .../GenerationRegistryContractFactory.java | 138 +--------- .../datapack/IrisDatapackCompilerTest.java | 5 - .../history/ArchivedRegistryCompilerTest.java | 5 - ...GenerationRegistryContractFactoryTest.java | 238 +++++++++++++++--- .../iris/spi/PlatformGenerationRegistry.java | 2 - 16 files changed, 409 insertions(+), 207 deletions(-) create mode 100644 adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java index 59e4d1374..942228c58 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java +++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java @@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl; -import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -49,7 +48,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries, return access; } throw new IllegalStateException("Generation registry contract requires loaded native registries"); - }), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity()); + }), "bukkit-generation-registry-v1"); nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries, () -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); }, message -> { throw new IllegalStateException("Unavailable native registry: " + message); })); diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java new file mode 100644 index 000000000..2adc1b3e7 --- /dev/null +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn; +import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType; +import art.arcane.iris.pack.datapack.v263.DataFixerV263; +import art.arcane.iris.spi.PlatformGenerationRegistry; +import art.arcane.iris.world.history.GenerationRegistryContract; +import art.arcane.iris.world.history.GenerationRegistryContractFactory; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.bukkit.Bukkit; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public final class BareMobSpawnHistoryTest { + private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1"; + private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns"; + private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED = + new GenerationRegistryContract.PhysicalResourceKey( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "iris:biomes/" + "c".repeat(64) + ); + private static HeadlessNativeRegistries loaded; + private static PlatformGenerationRegistry registry; + + @BeforeClass + public static void installOverlayRendering() throws IOException { + assertNull(Bukkit.getServer()); + loaded = HeadlessNativeRegistries.load(Map.of( + "data/iris/worldgen/biome/biomes/" + "c".repeat(64) + ".json", + spawningBiome().generateJson(new DataFixerV263()) + )); + registry = new HeadlessNativePlatform(new File("unused-native-platform"), loaded::registries) + .generationRegistry(); + } + + @AfterClass + public static void closeRegistries() throws IOException { + if (loaded != null) { + loaded.close(); + } + } + + @Test + public void bareMobSpawnHistoryValidatesAgainstTheInstalledOverlayRendering() throws IOException { + GenerationRegistryContract legacy = legacyContract(INSTALLED, spawningBiome()); + GenerationRegistryContract.GeneratedSource source = legacy.generatedSources().get(INSTALLED); + assertEquals(GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, legacy.fingerprintSchema()); + assertEquals(LEGACY_RENDERER_IDENTITY, source.rendererIdentity()); + assertNotEquals( + JsonParser.parseString(source.sourceJson()), + JsonParser.parseString(registry.generatedDefinition( + INSTALLED.registryKey(), + INSTALLED.resourceKey() + ).value()) + ); + + GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(legacy), + new DataFixerV263(), + registry + ); + + legacy.requireDefinitionsAvailableIn(available); + } + + @Test + public void bareMobSpawnHistoryStillRejectsDifferentSemantics() throws IOException { + GenerationRegistryContract changed = legacyContract(INSTALLED, spawningBiome().setTemperature(0.2D)); + + IOException failure = assertThrows(IOException.class, + () -> GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(changed), + new DataFixerV263(), + registry + )); + + assertEquals("Historical generated registry definition changed for minecraft:worldgen/biome / " + + INSTALLED.resourceKey() + ".", failure.getMessage()); + } + + @Test + public void bareMobSpawnHistoryStillRejectsAMissingDefinition() throws IOException { + GenerationRegistryContract missing = legacyContract(new GenerationRegistryContract.PhysicalResourceKey( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "iris:biomes/" + "d".repeat(64) + ), spawningBiome()); + + assertThrows(IOException.class, () -> GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(missing), + new DataFixerV263(), + registry + )); + } + + private static GenerationRegistryContract legacyContract( + GenerationRegistryContract.PhysicalResourceKey key, + IrisBiomeCustom biome + ) { + JsonObject rendered = JsonParser.parseString(biome.generateJson(new DataFixerV263())).getAsJsonObject(); + JsonObject attributes = rendered.getAsJsonObject("attributes"); + attributes.add(NATURAL_MOB_SPAWNS, attributes.getAsJsonObject(NATURAL_MOB_SPAWNS).get("argument")); + String captured = registry.canonicalDefinition(key.registryKey(), key.resourceKey(), rendered.toString()) + .value(); + String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(biome, null); + GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource( + GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic), + semantic, + LEGACY_RENDERER_IDENTITY, + GenerationRegistryContractFactory.fingerprintDefinition( + key, + PlatformGenerationRegistry.Definition.exactJson(captured), + "generated" + ), + captured + ); + return GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)), + Map.of(key, source) + ); + } + + private static IrisBiomeCustom spawningBiome() { + return new IrisBiomeCustom() + .setId("tester") + .setSpawnRarity(2) + .setSpawns(new KList<>( + new IrisBiomeCustomSpawn() + .setType("minecraft:zombie") + .setGroup(IrisBiomeCustomSpawnType.MONSTER) + .setMinCount(2) + .setMaxCount(4) + .setWeight(7), + new IrisBiomeCustomSpawn() + .setType("minecraft:cow") + .setGroup(IrisBiomeCustomSpawnType.CREATURE) + .setMinCount(3) + .setMaxCount(3) + .setWeight(5) + )); + } +} diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java index 5ee4ddab4..e8ecedcb7 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java @@ -1,7 +1,6 @@ package art.arcane.iris.probe; import art.arcane.iris.spi.PlatformGenerationRegistry; -import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl; import net.minecraft.data.registries.VanillaRegistries; import org.bukkit.Bukkit; import org.junit.Test; @@ -22,8 +21,6 @@ public final class HeadlessGenerationRegistryTest { HeadlessNativeTestRegistries::get); PlatformGenerationRegistry registry = platform.generationRegistry(); assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity()); - assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(), - registry.generatedDefinitionRendererIdentity()); assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome), registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test")); assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index ff4d78ce0..93f887393 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -314,12 +314,13 @@ public final class ModdedForcedDatapack { } catch (NoSuchAlgorithmException missing) { throw new IllegalStateException("SHA-256 is unavailable", missing); } + DataVersion dataVersion = DataVersion.getRuntime(); digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName() - + '|' + DataVersion.V26_3.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8)); + + '|' + dataVersion.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8)); String inputFingerprint = IrisDatapackCompiler.computeInputFingerprint( compilationPackRoots(), List.of(), - DataVersion.V26_3.get(), + dataVersion.get(), false ); digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); @@ -328,7 +329,7 @@ public final class ModdedForcedDatapack { private static void writeStagedPack(Path stagingDirectory) throws IOException { Map> seenBiomes = new LinkedHashMap<>(); - IDataFixer fixer = DataVersion.V26_3.get(); + IDataFixer fixer = DataVersion.getRuntime().get(); int packCount = 0; KList presetIds = new KList<>(); @@ -679,7 +680,7 @@ public final class ModdedForcedDatapack { } static void writePackMeta(Path packDirectory) throws IOException { - int packFormat = DataVersion.V26_3.getPackFormat(); + int packFormat = DataVersion.getRuntime().getPackFormat(); String json = "{\n" + " \"pack\": {\n" + " \"description\": \"Iris world generation biomes and dimension types for installed packs.\",\n" diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java index 050d4b984..976e6bdfa 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java @@ -9,12 +9,10 @@ import java.util.Objects; final class ModdedGenerationRegistry implements PlatformGenerationRegistry { private final NativeGenerationRegistry nativeRegistry; private final String runtimeIdentity; - private final String rendererIdentity; - ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, Identity identity) { + ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, String runtimeIdentity) { this.nativeRegistry = Objects.requireNonNull(nativeRegistry, "nativeRegistry"); - this.runtimeIdentity = requireText(identity.runtime(), "runtimeIdentity"); - this.rendererIdentity = requireText(identity.renderer(), "rendererIdentity"); + this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity"); } @Override @@ -22,11 +20,6 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry { return runtimeIdentity; } - @Override - public String generatedDefinitionRendererIdentity() { - return rendererIdentity; - } - @Override public String customBiomeResourceKey(String identitySha256) { return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256); @@ -75,7 +68,4 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry { } return required; } - - record Identity(String runtime, String renderer) { - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 3d795bfd5..24309bd2d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -357,8 +357,7 @@ public final class ModdedPlatform implements IrisPlatform { throw new IllegalStateException("Minecraft server is not ready for generation registry capture."); } return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer), - new ModdedGenerationRegistry.Identity("modded-generation-registry-v1", - "modded-generated-registry-json-v1|minecraft:" + BuildConstants.MINECRAFT_VERSION)); + "modded-generation-registry-v1"); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java index 1ee890883..22a70e57d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java @@ -55,7 +55,7 @@ final class WorldCheckDimensionContract { } static Dimension expectedDimensionContract(IrisDimension dimension) { - JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.V26_3.get())); + JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get())); return new Dimension( json.getInt("min_y"), json.getInt("height"), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index f58722f51..0d9df4119 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -165,7 +165,7 @@ public final class ModdedDatapackCommands { IrisDimension dimension = engine.getDimension(); String json; try { - json = dimension.getDimensionType().toJson(DataVersion.V26_3.get()); + json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get()); } catch (Throwable e) { ModdedIrisLog.error("Iris dimension type generation failed for {}", dimension.getLoadKey(), e); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_DIMENSION_TYPE_GENERATION_FAILED, MessageArgument.untrusted("value", level.name()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage())))); @@ -187,7 +187,7 @@ public final class ModdedDatapackCommands { } File mcmeta = new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/pack.mcmeta"); - int packFormat = DataVersion.V26_3.getPackFormat(); + int packFormat = DataVersion.getRuntime().getPackFormat(); String meta = "{\n" + " \"pack\": {\n" + " \"description\": \"Iris dimension types derived from the installed Iris packs.\",\n" diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java index ad1af8e24..8a016e11a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.v1217.DataFixerV1217; import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationRegistryContract; @@ -27,6 +28,8 @@ import art.arcane.iris.world.history.GenerationRegistryContractFactory; import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimensionType; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformGenerationRegistry; import java.lang.reflect.Constructor; import java.util.Set; @@ -48,6 +51,8 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ModdedForcedDatapackTest { @Test @@ -65,6 +70,29 @@ public class ModdedForcedDatapackTest { } } + @Test + public void metadataFollowsTheRuntimeDataVersionThatValidationUses() throws IOException { + IrisPlatform previous = IrisPlatforms.getOrNull(); + IrisPlatform runtime = mock(IrisPlatform.class); + when(runtime.minecraftVersion()).thenReturn("26.2"); + Path packDirectory = Files.createTempDirectory("iris-modded-runtime-pack-format"); + IrisPlatforms.unbind(); + IrisPlatforms.bind(runtime); + try { + ModdedForcedDatapack.writePackMeta(packDirectory); + JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta"))) + .getJSONObject("pack"); + assertEquals(DataVersion.getRuntime().getPackFormat(), pack.getInt("pack_format")); + assertEquals(DataVersion.V26_2.getPackFormat(), pack.getInt("pack_format")); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + deleteTree(packDirectory); + } + } + @Test public void retainedDimensionSourceRendersBeforeNativeRegistryExists() throws Exception { DataFixerV1217 fixer = new DataFixerV1217(); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBinding.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBinding.java index 2f55a4685..fdb74abaf 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBinding.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBinding.java @@ -55,7 +55,7 @@ public final class BukkitBinding implements INMSBinding { @Override public PlatformGenerationRegistry generationRegistry() { - return new BukkitGenerationRegistry(registry, "bukkit-generation-registry-v1", generation.generationRendererIdentity()); + return new BukkitGenerationRegistry(registry, "bukkit-generation-registry-v1"); } @Override diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGenerationRegistry.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGenerationRegistry.java index 52e0012cc..5c4c2049c 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGenerationRegistry.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitGenerationRegistry.java @@ -9,13 +9,11 @@ import java.util.Objects; public record BukkitGenerationRegistry( NativeGenerationRegistry registry, - String runtimeIdentity, - String generatedDefinitionRendererIdentity + String runtimeIdentity ) implements PlatformGenerationRegistry { public BukkitGenerationRegistry { registry = Objects.requireNonNull(registry, "registry"); runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity"); - generatedDefinitionRendererIdentity = requireText(generatedDefinitionRendererIdentity, "rendererIdentity"); } @Override diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java index c84f34c9d..2b035ab23 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java @@ -552,15 +552,14 @@ public final class GenerationRegistryContractFactory { for (Map.Entry entry : requiredContract.generatedSources().entrySet()) { - GenerationRegistryContract.GeneratedSource previous = generatedSources.get(entry.getKey()); + GenerationRegistryContract.GeneratedSource previous = generatedSources.putIfAbsent( + entry.getKey(), + entry.getValue() + ); if (previous != null && !sameGeneratedSemantic(previous, entry.getValue())) { throw new IOException("Retained generation epochs require conflicting generated sources for " + entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + "."); } - generatedSources.put( - entry.getKey(), - preferredGeneratedSource(previous, entry.getValue(), registry) - ); } } Map captured = new TreeMap<>(); @@ -596,18 +595,6 @@ public final class GenerationRegistryContractFactory { return GenerationRegistryContract.fromDefinitions(captured); } - public static String requireGeneratedSource( - GenerationRegistryContract contract, - GenerationRegistryContract.PhysicalResourceKey key - ) throws IOException { - return requireGeneratedSource( - contract, - key, - DataVersion.getRuntime().get(), - IrisPlatforms.get().registries().generationRegistry() - ); - } - public static String renderGeneratedSource( GenerationRegistryContract contract, GenerationRegistryContract.PhysicalResourceKey key, @@ -622,68 +609,9 @@ public final class GenerationRegistryContractFactory { + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); } requireGeneratedSemanticFingerprint(requiredKey, source, requiredContract.definitions().get(requiredKey)); - String sourceFingerprint = fingerprintDefinition(requiredKey, - PlatformGenerationRegistry.Definition.exactJson(source.sourceJson()), "generated"); - if (!source.renderedDefinitionSha256().equals(sourceFingerprint)) { - throw new IOException("Historical generated registry source changed for " - + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); - } return renderGeneratedSemantic(requiredKey, source, requiredFixer); } - public static String requireGeneratedSource( - GenerationRegistryContract contract, - GenerationRegistryContract.PhysicalResourceKey key, - IDataFixer fixer - ) throws IOException { - return requireGeneratedSource( - contract, - key, - fixer, - IrisPlatforms.get().registries().generationRegistry() - ); - } - - public static String requireGeneratedSource( - GenerationRegistryContract contract, - GenerationRegistryContract.PhysicalResourceKey key, - PlatformGenerationRegistry generationRegistry - ) throws IOException { - return requireGeneratedSource( - contract, - key, - DataVersion.getRuntime().get(), - generationRegistry - ); - } - - public static String requireGeneratedSource( - GenerationRegistryContract contract, - GenerationRegistryContract.PhysicalResourceKey key, - IDataFixer fixer, - PlatformGenerationRegistry generationRegistry - ) throws IOException { - GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "contract"); - GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key"); - IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); - PlatformGenerationRegistry registry = Objects.requireNonNull( - generationRegistry, - "generationRegistry" - ); - GenerationRegistryContract.GeneratedSource source = requiredContract.generatedSources().get(requiredKey); - if (source == null) { - throw new IOException("Historical generated registry source is missing for " - + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); - } - String expectedFingerprint = requiredContract.definitions().get(requiredKey); - if (expectedFingerprint == null) { - throw new IOException("Historical generated registry definition is missing for " - + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + "."); - } - requireGeneratedSemanticFingerprint(requiredKey, source, expectedFingerprint); - return canonicalGeneratedDefinition(requiredKey, source, requiredFixer, registry).value(); - } - public static String fingerprintDefinition( GenerationRegistryContract.PhysicalResourceKey key, PlatformGenerationRegistry.Definition definition, @@ -783,7 +711,7 @@ public final class GenerationRegistryContractFactory { DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA, GenerationEpochContractFactory.fingerprintDimensionType(dimension.getDimensionType()), GenerationEpochContractFactory.dimensionTypeSemanticJson(dimension.getDimensionType()), - registry.generatedDefinitionRendererIdentity(), + rendererIdentity(fixer), canonicalDefinition ); } @@ -833,7 +761,7 @@ public final class GenerationRegistryContractFactory { CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, generated.semanticFingerprint(), generated.semanticJson(), - registry.generatedDefinitionRendererIdentity(), + rendererIdentity(fixer), generated.definition() ); Set tags = biomeTags.computeIfAbsent(generated.physicalKey().resourceKey(), ignored -> new TreeSet<>()); @@ -882,7 +810,7 @@ public final class GenerationRegistryContractFactory { CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, generated.semanticFingerprint(), generated.semanticJson(), - registry.generatedDefinitionRendererIdentity(), + rendererIdentity(fixer), generated.definition() ); } @@ -1087,32 +1015,9 @@ public final class GenerationRegistryContractFactory { } } - private static GenerationRegistryContract.GeneratedSource preferredGeneratedSource( - GenerationRegistryContract.GeneratedSource previous, - GenerationRegistryContract.GeneratedSource candidate, - PlatformGenerationRegistry registry - ) { - if (previous == null) { - return candidate; - } - String currentRenderer = requireText( - registry.generatedDefinitionRendererIdentity(), - "Generated registry renderer identity" - ); - boolean previousMatches = previous.rendererIdentity().equals(currentRenderer); - boolean candidateMatches = candidate.rendererIdentity().equals(currentRenderer); - if (previousMatches != candidateMatches) { - return candidateMatches ? candidate : previous; - } - return generatedSourceOrder(candidate).compareTo(generatedSourceOrder(previous)) < 0 - ? candidate - : previous; - } - - private static String generatedSourceOrder(GenerationRegistryContract.GeneratedSource source) { - return source.rendererIdentity() + '\u0000' - + source.renderedDefinitionSha256() + '\u0000' - + source.sourceJson(); + // Recorded for diagnostics only. Validation always re-renders the semantics with the current fixer. + private static String rendererIdentity(IDataFixer fixer) { + return fixer.getClass().getName(); } private static boolean sameGeneratedSemantic( @@ -1130,36 +1035,19 @@ public final class GenerationRegistryContractFactory { IDataFixer fixer, PlatformGenerationRegistry registry ) throws IOException { - String currentRenderer = requireText( - registry.generatedDefinitionRendererIdentity(), - "Generated registry renderer identity" - ); - String renderedSource; - if (source.rendererIdentity().equals(currentRenderer)) { - renderedSource = source.sourceJson(); - } else { - renderedSource = renderGeneratedSemantic(key, source, fixer); - } - PlatformGenerationRegistry.Definition definition; + String renderedSource = renderGeneratedSemantic(key, source, fixer); try { - definition = registry.canonicalDefinition( + PlatformGenerationRegistry.Definition definition = registry.canonicalDefinition( key.registryKey(), key.resourceKey(), renderedSource ); requireGeneratedDefinition(definition, "historical generated registry definition"); + return definition; } catch (RuntimeException failure) { throw new IOException("Historical generated registry source is incompatible for " + key.registryKey() + " / " + key.resourceKey() + ".", failure); } - if (source.rendererIdentity().equals(currentRenderer)) { - String renderedFingerprint = fingerprintDefinition(key, definition, "generated"); - if (!source.renderedDefinitionSha256().equals(renderedFingerprint)) { - throw new IOException("Historical generated registry source changed for " - + key.registryKey() + " / " + key.resourceKey() + "."); - } - } - return definition; } private static String renderGeneratedSemantic( diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/IrisDatapackCompilerTest.java index c3af8d245..d841a2202 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/IrisDatapackCompilerTest.java @@ -675,11 +675,6 @@ public class IrisDatapackCompilerTest { return "bootstrap-test"; } - @Override - public String generatedDefinitionRendererIdentity() { - return "bootstrap-renderer-v1"; - } - @Override public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { return "iris:" + dimensionTypeKey; diff --git a/core/src/test/java/art/arcane/iris/world/history/ArchivedRegistryCompilerTest.java b/core/src/test/java/art/arcane/iris/world/history/ArchivedRegistryCompilerTest.java index 7f21f2840..005349482 100644 --- a/core/src/test/java/art/arcane/iris/world/history/ArchivedRegistryCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/ArchivedRegistryCompilerTest.java @@ -120,11 +120,6 @@ public class ArchivedRegistryCompilerTest { return "bootstrap-test"; } - @Override - public String generatedDefinitionRendererIdentity() { - return "bootstrap-renderer-v1"; - } - @Override public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) { return "iris:" + dimensionTypeKey; diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java index bdd7e4177..0a70f0edf 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java @@ -8,7 +8,11 @@ import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.pack.datapack.IDataFixer; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.biome.IrisBiomeCustomCategory; +import art.arcane.iris.generation.biome.IrisBiomeCustomParticle; +import art.arcane.iris.generation.biome.IrisBiomeCustomPrecipType; import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn; +import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimensionType; import art.arcane.iris.generation.terrain.IrisDimensionTypeOptions; @@ -510,7 +514,7 @@ public class GenerationRegistryContractFactoryTest { GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, semanticFingerprint, semantic, - registry.generatedDefinitionRendererIdentity(), + "test-renderer-v1", GenerationRegistryContractFactory.fingerprintDefinition( generated, definition, @@ -528,18 +532,18 @@ public class GenerationRegistryContractFactoryTest { GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions( Set.of(required), + firstFixer, registry ); required.requireDefinitionsAvailableIn(available); assertEquals( - source, - GenerationRegistryContractFactory.requireGeneratedSource( + JsonParser.parseString(source), + JsonParser.parseString(GenerationRegistryContractFactory.renderGeneratedSource( required, generated, - firstFixer, - registry - ) + firstFixer + )) ); registry.put(generated, PlatformGenerationRegistry.Definition.exactJson( @@ -547,33 +551,199 @@ public class GenerationRegistryContractFactoryTest { )); assertThrows( IOException.class, - () -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry) + () -> GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(required), + firstFixer, + registry + ) ); registry.remove(generated); assertThrows( IOException.class, - () -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry) + () -> GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(required), + firstFixer, + registry + ) + ); + } + + @Test + public void validatesTheCurrentRenderingWhenTheRendererChangedUnderTheSameIdentity() throws Exception { + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime-a"); + GenerationRegistryContract.PhysicalResourceKey generated = key( + GenerationRegistryContractFactory.BIOME_REGISTRY, + "iris:biomes/" + HASH_A + ); + IrisBiomeCustom customBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.5D); + String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(customBiome, null); + String capturedSource = customBiome.generateJson( + new TaggedBiomeFixer("first"), + new ContentGate(null, Map.of(), null) + ); + GenerationRegistryContract.GeneratedSource generatedSource = new GenerationRegistryContract.GeneratedSource( + GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic), + semantic, + "test-renderer-v1", + GenerationRegistryContractFactory.fingerprintDefinition( + generated, + PlatformGenerationRegistry.Definition.exactJson(capturedSource), + "generated" + ), + capturedSource + ); + GenerationRegistryContract required = GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(generated, GenerationRegistryContractFactory.fingerprintGeneratedSemantic( + generated, + generatedSource + )), + Map.of(generated, generatedSource) + ); + IDataFixer currentFixer = new TaggedBiomeFixer("second"); + String installed = GenerationRegistryContractFactory.renderGeneratedSource(required, generated, currentFixer); + assertNotEquals(JsonParser.parseString(capturedSource), JsonParser.parseString(installed)); + registry.put(generated, PlatformGenerationRegistry.Definition.exactJson(installed)); + + GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions( + Set.of(required), + currentFixer, + registry ); - FakeGenerationRegistry changedRenderer = new FakeGenerationRegistry("runtime-b") { - @Override - public String generatedDefinitionRendererIdentity() { - return "test-renderer-v2"; - } - }; - IDataFixer secondFixer = new TaggedBiomeFixer("second"); - String rerendered = GenerationRegistryContractFactory.requireGeneratedSource( - required, - generated, - secondFixer, - changedRenderer + required.requireDefinitionsAvailableIn(available); + } + + @Test + public void semanticRenderingMatchesTheCapturedRenderingForEverySupportedFixer() throws Exception { + IrisData data = mock(IrisData.class); + when(data.getContentGate()).thenReturn(new ContentGate(null, Map.of(), null)); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + IrisBiomeCustom biome = richCustomBiome(); + IrisDimensionTypeOptions options = new IrisDimensionTypeOptions() + .coordinateScale(4.0) + .ambientLight(0.25F) + .fixedTime(18000L) + .cloudHeight(-96) + .monsterSpawnBlockLightLimit(11) + .ultrawarm(IrisDimensionTypeOptions.TriState.TRUE) + .natural(IrisDimensionTypeOptions.TriState.FALSE) + .piglinSafe(IrisDimensionTypeOptions.TriState.TRUE) + .respawnAnchorWorks(IrisDimensionTypeOptions.TriState.TRUE) + .bedWorks(IrisDimensionTypeOptions.TriState.FALSE) + .raids(IrisDimensionTypeOptions.TriState.FALSE) + .skylight(IrisDimensionTypeOptions.TriState.FALSE) + .ceiling(IrisDimensionTypeOptions.TriState.TRUE); + FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime"); + GenerationRegistryContract.PhysicalResourceKey dimensionKey = key( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, + "iris:overworld_type" ); - assertTrue(rerendered.contains("second")); - assertNotEquals(source, rerendered); - changedRenderer.put(generated, PlatformGenerationRegistry.Definition.exactJson(rerendered)); - GenerationRegistryContract availableAfterUpgrade = GenerationRegistryContractFactory - .captureRequiredDefinitions(Set.of(required), secondFixer, changedRenderer); - required.requireDefinitionsAvailableIn(availableAfterUpgrade); + int fixers = 0; + for (DataVersion version : DataVersion.values()) { + if (version == DataVersion.UNSUPPORTED) { + continue; + } + fixers++; + IDataFixer fixer = version.get(); + GenerationRegistryContractFactory.CustomBiomeDefinition captured = GenerationRegistryContractFactory + .customBiomeDefinition(data, dimension, biome, fixer, registry); + assertCapturedRenderingRoundTrips( + version, + captured.physicalKey(), + GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA, + captured.semanticFingerprint(), + captured.semanticJson(), + captured.definition(), + fixer, + registry + ); + for (IDataFixer.Dimension base : IDataFixer.Dimension.values()) { + IrisDimensionType dimensionType = new IrisDimensionType(base, options, 192, 384, -128); + assertCapturedRenderingRoundTrips( + version, + dimensionKey, + GenerationRegistryContractFactory.DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA, + GenerationEpochContractFactory.fingerprintDimensionType(dimensionType), + GenerationEpochContractFactory.dimensionTypeSemanticJson(dimensionType), + registry.canonicalDefinition( + dimensionKey.registryKey(), + dimensionKey.resourceKey(), + dimensionType.toJson(fixer) + ), + fixer, + registry + ); + } + } + assertEquals(DataVersion.values().length - 1, fixers); + } + + private static void assertCapturedRenderingRoundTrips( + DataVersion version, + GenerationRegistryContract.PhysicalResourceKey key, + String sourceSchema, + String semanticFingerprint, + String semanticJson, + PlatformGenerationRegistry.Definition captured, + IDataFixer fixer, + PlatformGenerationRegistry registry + ) throws IOException { + GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource( + sourceSchema, + semanticFingerprint, + semanticJson, + "round-trip", + GenerationRegistryContractFactory.fingerprintDefinition(key, captured, "generated"), + captured.value() + ); + GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources( + Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)), + Map.of(key, source) + ); + String rendered = GenerationRegistryContractFactory.renderGeneratedSource(contract, key, fixer); + assertEquals( + version + " " + key.resourceKey(), + JsonParser.parseString(captured.value()), + JsonParser.parseString(registry.canonicalDefinition( + key.registryKey(), + key.resourceKey(), + rendered + ).value()) + ); + } + + private static IrisBiomeCustom richCustomBiome() { + String[] entities = { + "minecraft:zombie", "minecraft:cow", "minecraft:bat", "minecraft:axolotl", + "minecraft:glow_squid", "minecraft:squid", "minecraft:cod", "minecraft:allay" + }; + IrisBiomeCustomSpawnType[] groups = IrisBiomeCustomSpawnType.values(); + KList spawns = new KList<>(); + for (int index = 0; index < groups.length; index++) { + spawns.add(new IrisBiomeCustomSpawn() + .setType(entities[index % entities.length]) + .setGroup(groups[index]) + .setWeight(index + 1) + .setMinCount(index + 1) + .setMaxCount(index % 2 == 0 ? index + 3 : index + 1)); + } + return new IrisBiomeCustom() + .setId("round_trip") + .setTemperature(1.25D) + .setHumidity(0.3D) + .setDownfallType(IrisBiomeCustomPrecipType.snow) + .setCategory(IrisBiomeCustomCategory.forest) + .setSpawnRarity(7) + .setSkyColor("#112233") + .setFogColor("445566") + .setWaterColor("#778899") + .setWaterFogColor("#aabbcc") + .setGrassColor("#55aa33") + .setFoliageColor("#335511") + .setAmbientParticle(new IrisBiomeCustomParticle().setParticle("ash").setRarity(80)) + .setSpawns(spawns); } @Test @@ -621,18 +791,11 @@ public class GenerationRegistryContractFactoryTest { Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, generatedSource)), Map.of(key, generatedSource) ); - FakeGenerationRegistry upgraded = new FakeGenerationRegistry("runtime-b") { - @Override - public String generatedDefinitionRendererIdentity() { - return "test-renderer-v2"; - } - }; - String rerendered = GenerationRegistryContractFactory.requireGeneratedSource( + String rerendered = GenerationRegistryContractFactory.renderGeneratedSource( contract, key, - new TaggedBiomeFixer("second"), - upgraded + new TaggedBiomeFixer("second") ); assertTrue(rerendered.contains("second")); @@ -750,11 +913,6 @@ public class GenerationRegistryContractFactoryTest { return runtimeIdentity; } - @Override - public String generatedDefinitionRendererIdentity() { - return "test-renderer-v1"; - } - @Override public String customBiomeResourceKey(String identitySha256) { return "test:biomes/" + identitySha256; diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java b/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java index 1a5241fb5..456c10859 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformGenerationRegistry.java @@ -11,8 +11,6 @@ public interface PlatformGenerationRegistry extends NativeGenerationRegistry { String runtimeIdentity(); - String generatedDefinitionRendererIdentity(); - default String customBiomeResourceKey(String identitySha256) { return contentAddressedCustomBiomeResourceKey(identitySha256); } From 3a4da2086e918a0cbd9dcd6eafb5a9057b0fc144 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 05:51:10 -0400 Subject: [PATCH 03/19] Fail-closed world generation - Every Iris world the Bukkit resolver cannot serve gets a fail-closed generator - CraftServer no longer receives a throw that it turns into vanilla - A refusal banner logs the world, key, cause chain and what happens next - The reconciler uses a throwing resolution so a broken world fails without stopping the server - A failed enable on a server with Iris worlds keeps Iris enabled with a locked runtime - bukkit.yml bindings in vanilla slots count as Iris worlds for that lock - The shutdown-based vanilla fallback guard is gone - A recorded modded bind failure rethrows instead of rebinding on every chunk call - The first modded bind failure logs a banner - Persistent-dimension reinject failures log a banner and repeat to operators on join - Modded logins are refused while the configured primary Iris world is missing --- .../art/arcane/iris/FailedEnableLock.java | 73 +++++++ .../src/main/java/art/arcane/iris/Iris.java | 106 ++++------- .../java/art/arcane/iris/IrisBootstrap.java | 10 +- .../iris/world/BukkitWorldReconciler.java | 2 +- .../world/IrisFailClosedChunkGenerator.java | 6 + .../world/IrisWorldGeneratorResolver.java | 68 ++++++- .../art/arcane/iris/FailedEnableLockTest.java | 113 +++++++++++ .../IrisFailClosedChunkGeneratorTest.java | 56 ++++++ .../world/IrisWorldGeneratorResolverTest.java | 178 ++++++++++++------ .../iris/modded/IrisModdedChunkGenerator.java | 27 ++- .../iris/modded/ModdedPrimaryWorldRouter.java | 55 ++++++ .../iris/modded/ModdedProtocolHandler.java | 4 +- .../art/arcane/iris/modded/ModdedStartup.java | 44 ++++- .../iris/modded/ModdedBindFailureTest.java | 105 +++++++++++ .../modded/ModdedPrimaryWorldRouterTest.java | 142 ++++++++++++++ .../ModdedStartupReinjectFailureTest.java | 71 +++++++ .../lifecycle/BukkitWorldConfiguration.java | 32 +++- .../lifecycle/MissingWorldStorageLog.java | 2 +- .../safeguard/GenerationRefusalNotice.java | 58 ++++++ .../BukkitWorldConfigurationTest.java | 27 +++ .../GenerationRefusalNoticeTest.java | 68 +++++++ .../safeguard/RuntimeLockNoticeTest.java | 4 +- 22 files changed, 1100 insertions(+), 151 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java create mode 100644 core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java create mode 100644 core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java new file mode 100644 index 000000000..51488991a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -0,0 +1,73 @@ +package art.arcane.iris; + +import art.arcane.iris.platform.bootstrap.ServerProperties; +import art.arcane.iris.world.IrisStartupValidation; +import art.arcane.iris.world.IrisWorldStorage; +import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * Keeps a failed enable from handing Iris worlds to the vanilla generator. + *

+ * Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled + * generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its + * runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the + * level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and + * logging, never a service the failed enable may not have created. + */ +final class FailedEnableLock { + private FailedEnableLock() { + } + + /** + * @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before + */ + static boolean engage(Throwable failure, boolean irisWorldsPresent) { + if (!irisWorldsPresent) { + return false; + } + IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure)); + List notice = notice(failure); + try { + for (String line : notice) { + Iris.error(line); + } + Iris.reportError(failure); + } catch (Throwable loggingFailure) { + notice.forEach(System.err::println); + failure.addSuppressed(loggingFailure); + failure.printStackTrace(System.err); + } + return true; + } + + static List notice(Throwable failure) { + return GenerationRefusalNotice.compose( + "Iris failed to enable", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.", + "Every Iris world refuses to generate: server startup stops before the first one loads," + + " and no chunks are written.", + "Player logins are refused until this is fixed and the server restarts." + )); + } + + static boolean irisWorldsPresent() { + try { + return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML); + } catch (Throwable unreadable) { + Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable); + return true; + } + } + + static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException { + return IrisWorldStorage.hasManagedWorldStorage(levelRoot) + || BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index aefe52ff1..eb3389337 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -32,7 +32,6 @@ import art.arcane.iris.world.IrisStartupValidation; import art.arcane.iris.world.IrisStartupAdmissionListener; import art.arcane.iris.world.BukkitWorldReconciler; import art.arcane.iris.world.IrisWorldGeneratorResolver; -import art.arcane.iris.world.IrisWorldStorage; import art.arcane.iris.world.PendingWorldDeleteQueue; import art.arcane.iris.world.PendingWorldReplacementManager; import art.arcane.iris.configuration.SettingsHotloadWatch; @@ -200,6 +199,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false); private volatile boolean generatorDrainCompleted; + private volatile boolean enableFailed; private volatile PlaceholderRegistration papiRegistration; private volatile IrisPapiListener papiListener; private volatile IrisPapiState papiState; @@ -613,21 +613,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag(); } - /** - * @return false when the bootstrap was aborted (unsupported server version); the caller - * must bail out of onEnable without touching any further setup. - */ - private boolean enable() { - if (!INMS.isBound()) { - Throwable bindFailure = INMS.bindFailure(); - Iris.error("Iris cannot start: " + (bindFailure == null - ? "no NMS binding is available for this server version." - : bindFailure.getMessage())); - // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable - // synchronously and the loader then continues registering the half-enabled plugin. - J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); - return false; - } + private void enable() { EnableTimings timings = new EnableTimings(); alreadyDrained.set(false); postStopFinisherStarted.set(false); @@ -768,6 +754,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } J.s(() -> { + generatorResolver.markServerRunning(); pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); @@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { }); timings.mark("startupTasks"); timings.report(); - return true; } private static final class EnableTimings { @@ -910,20 +896,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onEnable() { - IrisPlatforms.bind(new BukkitPlatform()); + enableFailed = false; IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); - Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); - pendingWorldReplacements.registerPlatformEntryListener(); - boolean enabled; try { - enabled = enable(); + IrisPlatforms.bind(new BukkitPlatform()); + Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); + pendingWorldReplacements.registerPlatformEntryListener(); + if (!INMS.isBound()) { + refuseUnsupportedServer(); + return; + } + enable(); } catch (Throwable failure) { - refuseVanillaFallback(failure); - throw failure; - } - if (!enabled) { - refuseVanillaFallback(null); + if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + throw failure; + } + enableFailed = true; return; } BukkitGuiHost.install(); @@ -939,54 +928,31 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { reportLockedRuntime(); } + private void refuseUnsupportedServer() { + Throwable bindFailure = INMS.bindFailure(); + IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null + ? "no NMS binding is available for this server version." + : bindFailure.getMessage()), bindFailure); + if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + enableFailed = true; + return; + } + Iris.error(failure.getMessage()); + // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable + // synchronously and the loader then continues registering the half-enabled plugin. + J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); + } + private static void reportLockedRuntime() { String denial = IrisStartupValidation.denialReason().orElse(null); if (denial == null) { return; } - boolean managedStorage; - try { - managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot()); - } catch (Throwable unavailable) { - Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable); - managedStorage = false; - } - for (String line : RuntimeLockNotice.compose(denial, managedStorage)) { + for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) { Iris.error(line); } } - /** - * Stops a server whose Iris worlds would otherwise be generated by the vanilla generator. - *

- * A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to - * vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files. - * There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is - * damage control, not prevention: level creation runs in the same startup step that enables plugins, so - * spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention - * lives in IrisBootstrap, which refuses startup before any level is created. - */ - private static void refuseVanillaFallback(Throwable failure) { - File levelRoot; - try { - levelRoot = IrisWorldStorage.levelRoot(); - } catch (Throwable unavailable) { - return; - } - if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) { - return; - } - Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain."); - if (failure != null) { - Iris.reportError("Iris enable failed", failure); - } - try { - Bukkit.shutdown(); - } catch (Throwable unavailable) { - Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName()); - } - } - public BukkitDebugDump debugDump() { return debugDump; } @@ -1014,7 +980,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } IrisLanguage.shutdown(); teardownPapi(); - boolean serverStopping = IrisToolbelt.isServerStopping(); + // A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed + // enable always did instead of deferring to the post-stop finisher. + boolean serverStopping = !enableFailed && IrisToolbelt.isServerStopping(); boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); if (restartingAtStartupBoundary) { teardownRuntime("startup-boundary-restart", 30L); @@ -1457,6 +1425,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return generatorResolver.resolveDefaultWorldGenerator(worldName, id); } + public ChunkGenerator requireWorldGenerator(String worldName, String id) { + return generatorResolver.requireWorldGenerator(worldName, id); + } + public void splash() { Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes()); printPacks(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 1734ce4aa..0c40281bc 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -112,16 +112,16 @@ public final class IrisBootstrap implements PluginBootstrap { * Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from. *

* The server enumerates levels from {@code /dimensions//} on disk, so a world - * whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever - * {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla - * terrain straight into that world's region files. A pack snapshot that has gone missing therefore has - * to be found here, before any level is created, exactly as a corrupt {@code dimensions/.json} is. + * whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world + * it cannot generate with a fail-closed generator, which stops startup at that world's own level creation, + * after the levels before it have loaded. A pack snapshot that has gone missing is found here instead, + * before any level is created, exactly as a corrupt {@code dimensions/.json} is. *

* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and * its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither * is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has * already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder, - * or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported. + * or a move that failed) owns nothing to lose. Both are only reported here. */ static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException { Path levelRoot = startupPaths.levelRoot(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java index 91bba32e6..5d7fb0e72 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java @@ -728,7 +728,7 @@ public final class BukkitWorldReconciler { String logicalWorldName = IrisWorldStorage.logicalName(worldKey); String configuredWorldName = configuredWorldName(worldKey); Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); - ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension); + ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension); IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (generator == null || irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\"."); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java index 9309e4e60..020230bac 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java @@ -18,6 +18,7 @@ package art.arcane.iris.world; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.World; @@ -48,6 +49,11 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator { + "' remains locked: " + denialReason); } + static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) { + return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + + GenerationRefusalNotice.summary(failure)); + } + @Override public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) { throw refusal(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 6d7e88aae..9529f5ec5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -47,6 +47,7 @@ import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.GenerationRegistryContract; import art.arcane.iris.world.history.GenerationRegistryContractFactory; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin; import art.arcane.iris.world.task.J; @@ -90,6 +91,7 @@ public final class IrisWorldGeneratorResolver { private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean(); private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean(); private final Map pendingSnapshots = new ConcurrentHashMap<>(); + private volatile boolean serverRunning; public IrisWorldGeneratorResolver(VolmitPlugin plugin) { this.plugin = plugin; @@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver { return fallback.get(); } + /** + * The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every + * request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let + * the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null. + */ @Nullable public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) { if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Ignoring PlotSquared generator discovery probe"); return null; } + try { + return requireWorldGenerator(worldName, id); + } catch (Throwable failure) { + return refuseWorld(worldName, failure); + } + } + + /** + * Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer. + */ + public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) { if (isGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Generator discovery probe for loaded world " + worldName); return IrisFailClosedChunkGenerator.discoveryProbe(worldName); @@ -505,22 +523,56 @@ public final class IrisWorldGeneratorResolver { return startupLock; } Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id); - if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType(); - Iris.debug("Generator ID: " + id + " requested by bukkit/plugin"); + String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id; + Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin"); File levelRoot = IrisWorldStorage.levelRoot(); NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); requireWorldKeyAvailable(worldName, worldKey); requireOwnedWorld(worldName, levelRoot, worldKey); + return resolveFrozenWorldGenerator(worldName, dimension); + } + /** + * Startup worlds are created before the first server tick; anything asked for after it is a runtime load. + */ + public void markServerRunning() { + serverRunning = true; + } + + private ChunkGenerator refuseWorld(String worldName, Throwable failure) { + ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure); try { - return resolveFrozenWorldGenerator(worldName, id); - } catch (RuntimeException failure) { - Iris.reportError("Refusing to load configured Iris world '" + worldName - + "' because its frozen world-local pack snapshot could not be used.", failure); - Bukkit.shutdown(); - throw failure; + for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) { + Iris.error(line); + } + Iris.reportError(failure); + } catch (Throwable loggingFailure) { + failure.addSuppressed(loggingFailure); + failure.printStackTrace(System.err); } + return refused; + } + + private static String refusalKey(String worldName) { + try { + return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString(); + } catch (Throwable unresolved) { + return "unresolved key"; + } + } + + static List refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) { + return GenerationRefusalNotice.compose( + "Iris refused to generate world '" + worldName + "' (" + worldKey + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this world and does not let vanilla or any other generator" + + " write it; no chunks are written.", + serverRunning + ? "The world does not load; the server keeps running. Fix the cause, then load it again." + : "Server startup stops before this world loads. Fix the cause, then start the server again." + )); } @Nullable diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java new file mode 100644 index 000000000..c2ec0a824 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -0,0 +1,113 @@ +package art.arcane.iris; + +import art.arcane.iris.world.IrisStartupAdmissionListener; +import art.arcane.iris.world.IrisStartupValidation; +import art.arcane.iris.world.IrisWorldGeneratorResolver; +import com.destroystokyo.paper.profile.PlayerProfile; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.net.InetAddress; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; + +/** + * Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a + * disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has + * to leave Iris enabled and locked instead. + */ +public class FailedEnableLockTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void disableValidation() { + IrisStartupValidation.disable(); + } + + @Test + public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception { + IrisStartupValidation.begin(); + IllegalStateException failure = new IllegalStateException("service graph exploded"); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + assertTrue(FailedEnableLock.engage(failure, true)); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("service graph exploded")); + + ChunkGenerator generator = new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world", "overworld"); + assertNotNull("a null generator is the vanilla generator", generator); + IllegalStateException refusal = assertThrows(IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded")); + bukkit.verify(Bukkit::shutdown, never()); + } + + AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent( + "LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class)); + new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login); + assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult()); + } + + @Test + public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() { + IrisStartupValidation.begin(); + + try (MockedStatic iris = mockStatic(Iris.class)) { + assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false)); + } + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertFalse(denial, denial.contains("service graph exploded")); + } + + @Test + public void noticeSaysIrisStaysLockedAndWhatThatBlocks() { + String notice = String.join("\n", FailedEnableLock.notice( + new IllegalStateException("outer", new IllegalArgumentException("inner")))); + + assertTrue(notice, notice.contains("Iris failed to enable")); + assertTrue(notice, notice.contains("outer")); + assertTrue(notice, notice.contains("inner")); + assertTrue(notice, notice.contains("vanilla")); + assertTrue(notice, notice.contains("startup stops")); + assertTrue(notice, notice.contains("logins")); + } + + @Test + public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { + File levelRoot = temporaryFolder.newFolder("level", "world"); + File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml"); + + assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "Iris:overworld"); + yaml.save(bukkit); + assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + + assertTrue(bukkit.delete()); + assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); + assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java new file mode 100644 index 000000000..07e9639d7 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java @@ -0,0 +1,56 @@ +package art.arcane.iris.world; + +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and + * CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a + * broken Iris world from being created at all, so every fail-closed generator must keep throwing from it. + */ +public class IrisFailClosedChunkGeneratorTest { + @Test + public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() { + List generators = List.of( + IrisFailClosedChunkGenerator.discoveryProbe("world"), + IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"), + IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted"))); + + for (ChunkGenerator generator : generators) { + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'")); + assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class))); + assertThrows(IllegalStateException.class, + () -> generator.getFixedSpawnLocation(mock(World.class), new Random())); + assertFalse(generator.shouldGenerateNoise()); + } + } + + @Test + public void refusalCarriesTheWholeCauseChain() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed")); + + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure) + .getDefaultBiomeProvider(mock(WorldInfo.class))); + + assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java index f7f073c44..f9aa9ece6 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java @@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo; import org.mockito.MockedStatic; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator")); + assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator")); bukkit.verify(Bukkit::shutdown, never()); } } @@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest { } @Test - public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception { + public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception { File worldContainer = temporaryFolder.newFolder("external-create"); File levelRoot = new File(worldContainer, "world"); assertTrue(levelRoot.mkdirs()); try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("mvtest", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("mvtest", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest")); - assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create")); + assertTrue(refusal, refusal.contains("mvtest")); + assertTrue(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } @Test - public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception { + public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception { File worldContainer = temporaryFolder.newFolder("owned-broken"); File levelRoot = new File(worldContainer, "world"); assertTrue(levelRoot.mkdirs()); @@ -415,42 +404,103 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("world_iris_moon", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_iris_moon", "overworld")); - bukkit.verify(Bukkit::shutdown); + assertTrue(refusal, refusal.contains("world_iris_moon")); + bukkit.verify(Bukkit::shutdown, never()); + } + } + + /** + * The tester's failure: a world whose generation history no longer matches the live registries. CraftServer + * turns a throw here into the vanilla generator, so the history failure has to come back as a generator that + * refuses to create the level. + */ + @Test + public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception { + File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile(); + File levelRoot = new File(worldContainer, "world"); + File dimensionRoot = new File(levelRoot, "dimensions/iris/moon"); + assertTrue(new File(dimensionRoot, "iris/generation").mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic worlds = mockStatic(IrisWorlds.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L); + + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("moon", "overworld")); + + assertTrue(refusal, refusal.contains("'moon'")); + assertTrue(refusal, refusal.contains("generation history is unusable")); + bukkit.verify(Bukkit::shutdown, never()); } } @Test - public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception { + public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception { + File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile(); + File levelRoot = new File(worldContainer, "world"); + File dimensionRoot = new File(levelRoot, "dimensions/iris/moon"); + assertTrue(new File(dimensionRoot, "iris/generation").mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic worlds = mockStatic(IrisWorlds.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld")); + + assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable")); + bukkit.verify(Bukkit::shutdown, never()); + iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never()); + } + } + + @Test + public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed for iris:biomes/abc")); + + String startup = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( + "world_iris_moon", "iris:moon", failure, false)); + String runtime = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( + "world_iris_moon", "iris:moon", failure, true)); + + for (String notice : List.of(startup, runtime)) { + assertTrue(notice, notice.contains("world_iris_moon")); + assertTrue(notice, notice.contains("iris:moon")); + assertTrue(notice, notice.contains("Iris generation history is unusable at /srv/moon.")); + assertTrue(notice, notice.contains("Historical generated registry definition changed for iris:biomes/abc")); + assertTrue(notice, notice.contains("no chunks")); + } + assertTrue(startup, startup.contains("startup stops")); + assertTrue(runtime, runtime.contains("does not load")); + assertFalse(runtime, runtime.contains("startup stops")); + } + + @Test + public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception { File worldContainer = temporaryFolder.newFolder("vanilla-slot"); File levelRoot = new File(worldContainer, "world"); assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs()); try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("world_nether", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_nether", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether")); - assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create")); + assertTrue(refusal, refusal.contains("minecraft:the_nether")); + assertTrue(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } @@ -468,23 +518,39 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded)); + mockServer(bukkit, worldContainer, levelRoot, List.of(loaded)); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("irisworld", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("irisworld", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld")); + assertTrue(refusal, refusal.contains("iris:irisworld")); + assertTrue(refusal, refusal.contains("world_iris_irisworld")); bukkit.verify(Bukkit::shutdown, never()); } } + private static void mockServer(MockedStatic bukkit, File worldContainer, File levelRoot, List worlds) { + Server server = mock(Server.class); + when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); + bukkit.when(Bukkit::getWorlds).thenReturn(worlds); + } + + /** + * Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper + * calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created. + */ + private static String assertFailsClosed(Supplier resolution) { + ChunkGenerator vanillaFallback = mock(ChunkGenerator.class); + ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback); + assertNotSame(vanillaFallback, selected); + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> selected.getDefaultBiomeProvider(mock(WorldInfo.class))); + return refusal.getMessage(); + } + private static void writeValidPack(Path packRoot) throws Exception { Files.createDirectories(packRoot.resolve("dimensions")); Files.createDirectories(packRoot.resolve("regions")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 33d77d171..f47c8f235 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -59,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord; import art.arcane.iris.structure.nativegen.IrisStructurePolicy; @@ -454,11 +455,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat private Engine bindEngine(NativeWorld level) { requireBindingAllowed(); + // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the + // history verification on every chunk call and fail the same way. + engineBinding.throwIfFailed(dimensionKey); try { requireGlobalStructureGeneration( NativeModdedServer.forWorld(level).generateStructures(), dimensionKey); } catch (RuntimeException error) { - engineBinding.fail(error); + recordBindFailure(level, error); throw error; } // Cache the owning level so hot paths never scan the level map to find themselves. @@ -500,7 +504,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat engineBinding.complete(created); return created; } catch (Throwable error) { - engineBinding.fail(error); + recordBindFailure(level, error); if (error instanceof RuntimeException runtimeException) { throw runtimeException; } @@ -512,6 +516,25 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } } + private void recordBindFailure(NativeWorld level, Throwable error) { + engineBinding.fail(error); + for (String line : bindFailureNotice(level.name(), dimensionKey, error)) { + ModdedIrisLog.error(line); + } + } + + static List bindFailureNotice(String levelName, String dimensionKey, Throwable failure) { + return GenerationRefusalNotice.compose( + "Iris refused to generate " + levelName + " (generator " + dimensionKey + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this level and does not let vanilla or any other generator" + + " write it; no chunks are written.", + "A startup level stops the server; a runtime dimension stays unloaded." + + " Fix the cause, then start the server again." + )); + } + private void requireCompletedShutdown(Engine current) { if (current == null || !current.isClosing() || current.isClosed()) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 0f0ab2b27..7b4543f12 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -18,12 +18,15 @@ package art.arcane.iris.modded; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -36,6 +39,7 @@ public final class ModdedPrimaryWorldRouter { private static final Set routed = ConcurrentHashMap.newKeySet(); private static final Set inFlight = ConcurrentHashMap.newKeySet(); private static int tickCounter = 0; + private static volatile String loginRefusal; private ModdedPrimaryWorldRouter() { } @@ -43,6 +47,7 @@ public final class ModdedPrimaryWorldRouter { public static void clear() { routed.clear(); inFlight.clear(); + loginRefusal = null; } /** @@ -56,6 +61,20 @@ public final class ModdedPrimaryWorldRouter { } } + /** + * Disconnects a joining player while the configured primary world is missing. + * + * @return true when the player was refused + */ + public static boolean refuseIfUnavailable(NativeProtocolPlayer player) { + String refusal = loginRefusal; + if (refusal == null || player == null) { + return false; + } + player.disconnect(refusal); + return true; + } + public static void tick(NativeModdedServer server) { if (server == null) { return; @@ -68,17 +87,23 @@ public final class ModdedPrimaryWorldRouter { ModdedModConfig config = ModdedModConfig.get(); if (!config.routePlayersToPrimaryWorld()) { + loginRefusal = null; return; } String primary = config.primaryWorld(); if (primary.isBlank()) { + loginRefusal = null; return; } NativeWorld target = ModdedDimensionManager.level(server, primary); if (target == null) { + if (ModdedStartup.dimensionsRestored()) { + refuseLogins(server, primary); + } return; } + loginRefusal = null; NativeWorld overworld = server.overworld(); if (NativeDimensionRuntime.sameWorld(target, overworld)) { return; @@ -115,4 +140,34 @@ public final class ModdedPrimaryWorldRouter { } }); } + + /** + * Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a + * primary world that did not load refuses every player instead. + */ + private static void refuseLogins(NativeModdedServer server, String primary) { + String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players." + + " An operator has to check the server console."; + if (loginRefusal == null) { + for (String line : GenerationRefusalNotice.compose( + "Iris refused player logins: primary world '" + primary + "' is not loaded", + List.of("routePlayersToPrimaryWorld sends players to '" + primary + + "', which did not load at startup."), + List.of( + "Players are disconnected instead of playing in the overworld, so no chunks are" + + " written there.", + "Fix the dimension (see the Iris errors above) or clear primaryWorld in" + + " config/irisworldgen/modded.json, then start the server again." + ))) { + ModdedIrisLog.error(line); + } + } + loginRefusal = refusal; + List online = new ArrayList<>(); + // Disconnecting removes the player from the list being walked, so collect first. + server.forEachPlayer(online::add); + for (NativeProtocolPlayer player : online) { + player.disconnect(refusal); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java index 06e425ab2..f7219ff51 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java @@ -123,11 +123,11 @@ public final class ModdedProtocolHandler { } public static void onPlayerJoin(NativeProtocolPlayer player) { - if (player == null) { + if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) { return; } String sessionId = sessionId(player); - ModdedStartup.warnPackFailuresTo(player); + ModdedStartup.warnStartupFailuresTo(player); IrisSessionRegistry current = registry; ModdedProtocolTransport currentTransport = transport; if (current == null || currentTransport == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 80483b617..1f530cbbf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; @@ -36,6 +37,7 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; @@ -43,6 +45,7 @@ public final class ModdedStartup { private static final int COMPAT_BOOT_KEY_CAP = 3; private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final List DIMENSION_FAILURES = new CopyOnWriteArrayList<>(); private ModdedStartup() { } @@ -50,6 +53,7 @@ public final class ModdedStartup { public static void reset() { PREPARED.set(false); STARTED.set(false); + DIMENSION_FAILURES.clear(); } public static void prepareForStartup() { @@ -208,7 +212,7 @@ public final class ModdedStartup { } } - private static void reinjectPersistentDimensions(NativeModdedServer server) { + static void reinjectPersistentDimensions(NativeModdedServer server) { List dimensions = ModdedDimensionRegistryStore.loadForStartup(server); if (dimensions.isEmpty()) { @@ -233,7 +237,7 @@ public final class ModdedStartup { index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(), System.currentTimeMillis() - dimensionStartedAt); } catch (Throwable e) { - ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e); + reportDimensionFailure(dimension, e); if (e instanceof OutOfMemoryError outOfMemory) { throw outOfMemory; } @@ -243,6 +247,25 @@ public final class ModdedStartup { injected, dimensions.size(), System.currentTimeMillis() - startedAt); } + private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) { + List notice = GenerationRefusalNotice.compose( + "Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack() + + " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this dimension and does not let vanilla or any other generator" + + " write it; no chunks are written.", + "It stays unloaded. Fix the cause, then start the server again." + )); + for (String line : notice) { + ModdedIrisLog.error(line); + } + ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", + dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure); + DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: " + + GenerationRefusalNotice.summary(failure)); + } + private static long newestModificationMillis(Path root) { long newest = 0L; try (Stream walk = Files.walk(root)) { @@ -261,13 +284,24 @@ public final class ModdedStartup { } /** - * SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who - * can actually act on it when they join. + * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime + * dimension that is not loaded yet is still on its way. */ - public static void warnPackFailuresTo(NativeProtocolPlayer player) { + public static boolean dimensionsRestored() { + return STARTED.get(); + } + + /** + * SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in + * the console. Tell the operators who can actually act on it when they join. + */ + public static void warnStartupFailuresTo(NativeProtocolPlayer player) { if (player == null || !player.isGameMaster()) { return; } + for (String failure : DIMENSION_FAILURES) { + player.sendMessage(failure); + } for (Map.Entry entry : PackValidationRegistry.snapshot().entrySet()) { PackValidationResult result = entry.getValue(); if (result == null || result.isLoadable()) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java new file mode 100644 index 000000000..1a4ca2ccf --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java @@ -0,0 +1,105 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +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.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +/** + * A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator, + * and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind + * (history verification included) on every worker thread. + */ +public class ModdedBindFailureTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + field("boundLevel").set(generator, level); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(false); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + + IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine); + IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + + assertSame(recorded, second.getCause()); + assertSame(recorded, third.getCause()); + servers.verify(() -> NativeModdedServer.forWorld(level), times(1)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains("generate-structures=false"))), times(1)); + } + } + + @Test + public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() { + String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice( + "minecraft:overworld", + "overworld:overworld", + new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'", + new IOException("Historical generated registry definition changed")))); + + assertTrue(notice, notice.contains("minecraft:overworld")); + assertTrue(notice, notice.contains("overworld:overworld")); + assertTrue(notice, notice.contains("generation history is unusable")); + assertTrue(notice, notice.contains("registry definition changed")); + assertTrue(notice, notice.contains("no chunks")); + } + + private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS)); + field("engineBinding").set(generator, new ModdedEngineBinding(1L, TimeUnit.SECONDS)); + field("dimensionKey").set(generator, dimensionKey); + return generator; + } + + private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException { + NativeWorld level = mock(NativeWorld.class); + ServerLevel handle = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + when(level.nativeHandle()).thenReturn(handle); + when(level.name()).thenReturn(name); + when(handle.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator)); + return level; + } + + private static Field field(String name) throws NoSuchFieldException { + Field field = IrisModdedChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java new file mode 100644 index 000000000..70ad24fa7 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.function.Consumer; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A configured primary Iris world that did not load used to be skipped silently, leaving every player in the + * overworld generating terrain Iris was configured to replace. + */ +public class ModdedPrimaryWorldRouterTest { + private static final int TICK_INTERVAL = 20; + private static final String PRIMARY = "iris:primary"; + + @After + public void clearRouter() { + ModdedPrimaryWorldRouter.clear(); + } + + @Test + public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + evaluate(server); + + verify(online, times(2)).disconnect(contains(PRIMARY)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains(PRIMARY))), times(1)); + NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining)); + verify(joining).disconnect(contains(PRIMARY)); + } + } + + /** + * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is + * missing then is still loading, not broken. + */ + @Test + public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(false); + + evaluate(server); + + verify(online, never()).disconnect(anyString()); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + @Test + public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeWorld overworld = mock(NativeWorld.class); + when(server.overworld()).thenReturn(overworld); + onlinePlayers(server, mock(NativeProtocolPlayer.class)); + ModdedModConfig routed = config(true, PRIMARY); + ModdedModConfig unconfigured = config(true, ""); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + configs.when(ModdedModConfig::get).thenReturn(routed); + evaluate(server); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + + configs.when(ModdedModConfig::get).thenReturn(unconfigured); + evaluate(server); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + + configs.when(ModdedModConfig::get).thenReturn(routed); + evaluate(server); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld); + evaluate(server); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + private static void evaluate(NativeModdedServer server) { + for (int tick = 0; tick < TICK_INTERVAL; tick++) { + ModdedPrimaryWorldRouter.tick(server); + } + } + + @SuppressWarnings("unchecked") + private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) { + doAnswer(invocation -> { + invocation.getArgument(0, Consumer.class).accept(player); + return null; + }).when(server).forEachPlayer(any()); + } + + private static ModdedModConfig config(boolean route, String primary) { + ModdedModConfig config = mock(ModdedModConfig.class); + when(config.routePlayersToPrimaryWorld()).thenReturn(route); + when(config.primaryWorld()).thenReturn(primary); + return config; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java new file mode 100644 index 000000000..15dc419a4 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be + * one console line; it has to be a banner and has to reach the operators who can fix it. + */ +public class ModdedStartupReinjectFailureTest { + @After + public void resetStartup() { + ModdedStartup.reset(); + PackValidationRegistry.clear(); + } + + @Test + public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() { + NativeModdedServer server = mock(NativeModdedServer.class); + ModdedDimensionRegistryStore.PersistentDimension moon = + new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L); + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'"); + + try (MockedStatic store = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon)); + manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L)) + .thenThrow(failure); + + ModdedStartup.reinjectPersistentDimensions(server); + + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon") + && line.startsWith("Iris refused"))), times(1)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains("generation history is unusable"))), times(1)); + } + + NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class); + when(operator.isGameMaster()).thenReturn(true); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + ModdedStartup.warnStartupFailuresTo(operator); + ModdedStartup.warnStartupFailuresTo(player); + + verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon") + && message.contains("generation history is unusable"))); + verify(player, never()).sendMessage(anyString()); + + ModdedStartup.reset(); + NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class); + when(laterOperator.isGameMaster()).thenReturn(true); + ModdedStartup.warnStartupFailuresTo(laterOperator); + verify(laterOperator, never()).sendMessage(any()); + } +} diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java index 25196cae8..a34f8feda 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java @@ -372,8 +372,7 @@ public final class BukkitWorldConfiguration { + "\" is not a string."); } String configuredGenerator = generator.trim(); - if (!configuredGenerator.equalsIgnoreCase("Iris") - && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) { + if (!isIrisGenerator(configuredGenerator)) { continue; } @@ -395,6 +394,35 @@ public final class BukkitWorldConfiguration { return candidates; } + /** + * True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin + * for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla. + */ + public static boolean configuresIrisGenerator(File configurationFile) throws IOException { + Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath(); + if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + synchronized (MUTATION_LOCK) { + ConfigurationSection worlds = load(configurationFile).getConfigurationSection("worlds"); + if (worlds == null) { + return false; + } + for (String worldName : worlds.getKeys(false)) { + if (worlds.get(worldName + ".generator") instanceof String generator + && isIrisGenerator(generator.trim())) { + return true; + } + } + return false; + } + } + + private static boolean isIrisGenerator(String configuredGenerator) { + return configuredGenerator.equalsIgnoreCase("Iris") + || configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5); + } + public static GeneratorReplacement replaceIfMatching( File configurationFile, String worldName, diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java index 36d3557e4..173b53e2d 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java @@ -61,7 +61,7 @@ public final class MissingWorldStorageLog { } emit(new String[]{ IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region" - + " data; it will generate as vanilla terrain.", worldName, worldStorage), + + " data; Iris refuses to generate it and no other generator gets it.", worldName, worldStorage), IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.", worldName) }); diff --git a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java new file mode 100644 index 000000000..d182d42f1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java @@ -0,0 +1,58 @@ +package art.arcane.iris.world.safeguard; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * The framed console block Iris prints when it refuses to generate a world. Every platform prints the same shape + * so a refusal is found the same way in any server log. + */ +public final class GenerationRefusalNotice { + private static final String RULE = "=".repeat(78); + + private GenerationRefusalNotice() { + } + + public static List compose(String headline, List causes, List consequences) { + List lines = new ArrayList<>(causes.size() + consequences.size() + 3); + lines.add(RULE); + lines.add(Objects.requireNonNull(headline, "headline")); + for (int index = 0; index < causes.size(); index++) { + lines.add((index == 0 ? "Cause: " : "Caused by: ") + causes.get(index)); + } + lines.addAll(consequences); + lines.add(RULE); + return List.copyOf(lines); + } + + /** + * Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause + * (CompletionException, a rethrow with the same text) adds nothing and is skipped. + */ + public static List causes(Throwable failure) { + List causes = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Throwable current = failure; + while (current != null && seen.add(current)) { + Throwable cause = current.getCause(); + String message = current.getMessage(); + boolean repeatsCause = cause != null && cause.toString().equals(message); + if (!repeatsCause) { + String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim(); + if (causes.isEmpty() || !causes.getLast().equals(text)) { + causes.add(text); + } + } + current = cause; + } + return List.copyOf(causes); + } + + public static String summary(Throwable failure) { + return String.join("; ", causes(failure)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java index f28dc9715..31fc03985 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java @@ -53,6 +53,33 @@ public class BukkitWorldConfigurationTest { )), bindings); } + /** + * CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla + * slot included - is one a disabled Iris would hand to the vanilla generator. + */ + @Test + public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception { + File overworld = temporaryFolder.newFile("iris-overworld.yml"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "iris:overworld"); + yaml.set("worlds.world_nether.generator", "Other:generator"); + yaml.save(overworld); + File bare = temporaryFolder.newFile("iris-bare.yml"); + yaml = new YamlConfiguration(); + yaml.set("worlds.world_the_end.generator", "Iris"); + yaml.save(bare); + File other = temporaryFolder.newFile("other-only.yml"); + yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "Irish:overworld"); + yaml.set("worlds.world_nether.seed", 1337L); + yaml.save(other); + + assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld)); + assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare)); + assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other)); + assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml"))); + } + @Test public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception { File configuration = temporaryFolder.newFile("missing-binding-dimension.yml"); diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java new file mode 100644 index 000000000..fed73554b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.world.safeguard; + +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletionException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class GenerationRefusalNoticeTest { + @Test + public void causesListEveryDistinctMessageOutermostFirst() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed", + new IllegalArgumentException())); + + assertEquals(List.of( + "Iris generation history is unusable at /srv/moon.", + "Historical generated registry definition changed", + "IllegalArgumentException" + ), GenerationRefusalNotice.causes(failure)); + } + + @Test + public void wrappersThatOnlyRepeatTheirCauseAreSkipped() { + IllegalStateException root = new IllegalStateException("history drifted"); + CompletionException wrapper = new CompletionException(root); + + assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(wrapper)); + assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes( + new IllegalStateException("history drifted", root))); + } + + @Test + public void aCyclicCauseChainTerminates() { + IllegalStateException first = new IllegalStateException("first"); + IllegalStateException second = new IllegalStateException("second", first); + first.initCause(second); + + assertEquals(List.of("first", "second"), GenerationRefusalNotice.causes(first)); + } + + @Test + public void summaryJoinsTheCausesOnOneLine() { + assertEquals("outer; inner", GenerationRefusalNotice.summary( + new IllegalStateException("outer", new IOException("inner")))); + } + + @Test + public void noticeIsFramedAndNamesCauseChainAndConsequences() { + List lines = GenerationRefusalNotice.compose( + "Iris refused to generate world 'moon' (iris:moon)", + List.of("outer", "inner"), + List.of("No chunks are written to it.", "Server startup stops before this world loads.")); + + assertEquals(lines.getFirst(), lines.getLast()); + assertTrue(lines.getFirst(), lines.getFirst().chars().allMatch(character -> character == '=')); + assertEquals(List.of( + "Iris refused to generate world 'moon' (iris:moon)", + "Cause: outer", + "Caused by: inner", + "No chunks are written to it.", + "Server startup stops before this world loads." + ), lines.subList(1, lines.size() - 1)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java index ebf7bc19f..0838427e8 100644 --- a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java +++ b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java @@ -10,8 +10,8 @@ import static org.junit.Assert.assertTrue; /** * A boot that ends in Danger Mode used to say so only in the banner. The server then ran on with every * configured Iris world bound to a generator that throws on the first chunk, and nothing after the banner - * said that out loud. {@code refuseVanillaFallback} never covers this case because it only runs when enable - * itself fails. + * said that out loud. The failed-enable lock never covers this case because it only runs when enable itself + * fails. */ public class RuntimeLockNoticeTest { @Test From 7db46a8eb8ca1765204aada860371fec007b5023 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:16:57 -0400 Subject: [PATCH 04/19] Forge biome modifier validation - Pass the modded loader into the generation registry so biome modifier output stays out of validation - Cover Forge modifiers on climate, attributes, effects and features against the canonical definition --- .../forge/NativeForgeBiomeDefinitionTest.java | 100 ++++++++++++++++++ .../arcane/iris/modded/ModdedPlatform.java | 6 +- .../iris/modded/ModdedPlatformPathsTest.java | 6 ++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java diff --git a/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java b/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java new file mode 100644 index 000000000..d2273b95c --- /dev/null +++ b/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java @@ -0,0 +1,100 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.forge; + +import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.Lifecycle; +import com.mojang.serialization.MapCodec; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.attribute.EnvironmentAttributes; +import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.carver.WorldCarver; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraftforge.common.world.BiomeModifier; +import net.minecraftforge.common.world.ModifiableBiomeInfo; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class NativeForgeBiomeDefinitionTest { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String BIOME_KEY = "iris:biomes/forge_modified"; + private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4," + + "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() { + MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable()); + Holder.Reference ore = features.register( + ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")), + new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()), + RegistrationInfo.BUILT_IN); + features.freeze(); + MappedRegistry carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable()); + carvers.freeze(); + MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers)); + Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access), + JsonParser.parseString(SOURCE_JSON)).getOrThrow(); + Holder.Reference biome = biomes.register( + ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN); + biomes.freeze(); + biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore))); + NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, + new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller", + BooleanSupplier::getAsBoolean))::unmodifiedBiome); + + assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F); + assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF); + assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value())); + assertTrue(biome.value().getAttributes().contains(EnvironmentAttributes.NATURAL_MOB_SPAWNS)); + assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()), + JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value())); + } + + private record ModdedContent(Holder ore) implements BiomeModifier { + @Override + public void modify(Holder biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) { + if (phase != Phase.ADD) { + return; + } + builder.climateSettings().setTemperature(0.1F); + builder.effects().waterColor(0x102030); + builder.attributes().modify(EnvironmentAttributes.NATURAL_MOB_SPAWNS, MobSpawnSettingsModifier.overlay(), + new MobSpawnSettings.Builder().addSpawn(EntityTypes.ZOMBIE, 5, 1, 2).build()); + builder.generationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore); + } + + @Override + public MapCodec codec() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 24309bd2d..be4de0d5e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -61,7 +61,7 @@ public final class ModdedPlatform implements IrisPlatform { this.loader = loader; this.registries = new ModdedRegistries( NativeModdedServer.registryAccess(ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady), - ModdedPlatform::generationRegistry); + () -> generationRegistry(loader)); this.scheduler = new ModdedScheduler(); this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer); this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains")); @@ -352,11 +352,11 @@ public final class ModdedPlatform implements IrisPlatform { } } - private static PlatformGenerationRegistry generationRegistry() { + private static PlatformGenerationRegistry generationRegistry(NativeModdedLoader loader) { if (ModdedEngineBootstrap.currentServer() == null) { throw new IllegalStateException("Minecraft server is not ready for generation registry capture."); } - return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer), + return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer, loader), "modded-generation-registry-v1"); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java index c46775ab3..8fc4445a9 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java @@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.Mob; import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.block.state.BlockState; import org.junit.Rule; import org.junit.Test; @@ -91,6 +92,11 @@ public class ModdedPlatformPathsTest { public boolean checkSpawnPosition(Mob mob, ServerLevelAccessor level, EntitySpawnReason reason) { return false; } + + @Override + public Biome unmodifiedBiome(Biome biome) { + return biome; + } }); } From eefcbc40efa7dc49e3177608ab6989c5498e0c12 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:30:28 -0400 Subject: [PATCH 05/19] Fail-closed review fixes - Modded logins are refused only when this save's dimension registry lists the primary world - Otherwise Iris warns once and leaves routing idle - An unreadable dimension registry counts as expecting the primary world - A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace - The refusal consequence comes from whether startup worlds exist instead of the first tick - The first WorldLoadEvent or a hotload marks startup worlds as existing - A failed enable on a hotload says the server keeps running - Reconciler resolution failures report the refusal banner - Operators get a join notice for such failures until the world loads - The fail-closed generator is built even when the cause message cannot be read - Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow - The failed-enable teardown decision lives in FailedEnableLock with a test --- .../art/arcane/iris/FailedEnableLock.java | 25 ++- .../src/main/java/art/arcane/iris/Iris.java | 21 ++- .../iris/world/BukkitWorldReconciler.java | 25 ++- .../world/IrisFailClosedChunkGenerator.java | 13 +- .../world/IrisWorldGeneratorResolver.java | 49 +++--- .../iris/world/WorldRefusalReporter.java | 122 +++++++++++++++ .../art/arcane/iris/FailedEnableLockTest.java | 133 +++++++++++++++- .../BukkitWorldReconcilerRuntimeTest.java | 38 ++++- .../IrisFailClosedChunkGeneratorTest.java | 21 +++ .../world/IrisWorldGeneratorResolverTest.java | 31 +--- .../iris/world/WorldRefusalReporterTest.java | 145 ++++++++++++++++++ .../iris/modded/ModdedPrimaryWorldRouter.java | 36 ++++- .../modded/ModdedPrimaryWorldRouterTest.java | 69 ++++++++- 13 files changed, 644 insertions(+), 84 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java index 51488991a..cc332873f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -26,12 +26,12 @@ final class FailedEnableLock { /** * @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before */ - static boolean engage(Throwable failure, boolean irisWorldsPresent) { + static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) { if (!irisWorldsPresent) { return false; } IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure)); - List notice = notice(failure); + List notice = notice(failure, startupWorldsCreated); try { for (String line : notice) { Iris.error(line); @@ -45,14 +45,29 @@ final class FailedEnableLock { return true; } - static List notice(Throwable failure) { + /** + * Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes. + * A failed enable may have neither, so it tears down at once even while the server stops. + */ + static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) { + return !enableFailed && serverStopping; + } + + /** + * @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds + * already exist, so nothing stops + */ + static List notice(Throwable failure, boolean startupWorldsCreated) { return GenerationRefusalNotice.compose( "Iris failed to enable", GenerationRefusalNotice.causes(failure), List.of( "Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.", - "Every Iris world refuses to generate: server startup stops before the first one loads," - + " and no chunks are written.", + startupWorldsCreated + ? "Every Iris world that is not loaded yet refuses to load; the server keeps running" + + " and no chunks are written for them." + : "Every Iris world refuses to generate: server startup stops before the first one" + + " loads, and no chunks are written.", "Player logins are refused until this is fixed and the server restarts." )); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index eb3389337..25700400e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -34,6 +34,7 @@ import art.arcane.iris.world.BukkitWorldReconciler; import art.arcane.iris.world.IrisWorldGeneratorResolver; import art.arcane.iris.world.PendingWorldDeleteQueue; import art.arcane.iris.world.PendingWorldReplacementManager; +import art.arcane.iris.world.WorldRefusalReporter; import art.arcane.iris.configuration.SettingsHotloadWatch; import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.DatapackIngestService; @@ -754,7 +755,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } J.s(() -> { - generatorResolver.markServerRunning(); pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); @@ -900,6 +900,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); try { + generatorResolver.refusals().attach(this); IrisPlatforms.bind(new BukkitPlatform()); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); pendingWorldReplacements.registerPlatformEntryListener(); @@ -909,7 +910,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } enable(); } catch (Throwable failure) { - if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), + generatorResolver.refusals().startupWorldsCreated())) { throw failure; } enableFailed = true; @@ -933,7 +935,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null ? "no NMS binding is available for this server version." : bindFailure.getMessage()), bindFailure); - if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), + generatorResolver.refusals().startupWorldsCreated())) { enableFailed = true; return; } @@ -980,13 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } IrisLanguage.shutdown(); teardownPapi(); - // A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed - // enable always did instead of deferring to the post-stop finisher. - boolean serverStopping = !enableFailed && IrisToolbelt.isServerStopping(); + boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping()); boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); if (restartingAtStartupBoundary) { teardownRuntime("startup-boundary-restart", 30L); - } else if (serverStopping) { + } else if (deferToServerStop) { quiesceRuntimeForServerShutdown("onDisable"); startPostStopFinisher(); } else { @@ -1004,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); - if (!serverStopping || restartingAtStartupBoundary) { + if (!deferToServerStop || restartingAtStartupBoundary) { finishTerminalCleanup(); } } @@ -1429,6 +1430,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return generatorResolver.requireWorldGenerator(worldName, id); } + public WorldRefusalReporter worldRefusals() { + return generatorResolver.refusals(); + } + public void splash() { Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes()); printPacks(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java index 5d7fb0e72..dee36913c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java @@ -722,19 +722,30 @@ public final class BukkitWorldReconciler { return WorldIdentity.resolve(worldKey); } + /** + * A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice, + * then fails this load only. + */ @Override public CompletableFuture createWorld(NamespacedKey worldKey, String dimension, Long seed) { + String configuredWorldName = worldKey.toString(); + ChunkGenerator generator; + IrisDimension irisDimension; try { - String logicalWorldName = IrisWorldStorage.logicalName(worldKey); - String configuredWorldName = configuredWorldName(worldKey); - Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); - ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension); - IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); + configuredWorldName = configuredWorldName(worldKey); + Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension); + generator = plugin.requireWorldGenerator(configuredWorldName, dimension); + irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (generator == null || irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\"."); } - - Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "..."); + } catch (Throwable refusal) { + plugin.worldRefusals().report(configuredWorldName, worldKey, refusal); + return CompletableFuture.failedFuture(refusal); + } + try { + Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey) + + " using Iris:" + dimension + "..."); WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey) .generator(generator) .environment(BukkitEnvironment.from(irisDimension.getEnvironment())); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java index 020230bac..734ecb37a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java @@ -49,9 +49,18 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator { + "' remains locked: " + denialReason); } + /** + * Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot + * be summarized is named by its type instead. + */ static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) { - return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " - + GenerationRefusalNotice.summary(failure)); + String reason; + try { + reason = GenerationRefusalNotice.summary(failure); + } catch (Throwable unreadable) { + reason = failure.getClass().getName(); + } + return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason); } @Override diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 9529f5ec5..b4f4aeb3c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -47,7 +47,6 @@ import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.GenerationRegistryContract; import art.arcane.iris.world.history.GenerationRegistryContractFactory; -import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin; import art.arcane.iris.world.task.J; @@ -91,7 +90,7 @@ public final class IrisWorldGeneratorResolver { private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean(); private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean(); private final Map pendingSnapshots = new ConcurrentHashMap<>(); - private volatile boolean serverRunning; + private final WorldRefusalReporter refusals = new WorldRefusalReporter(); public IrisWorldGeneratorResolver(VolmitPlugin plugin) { this.plugin = plugin; @@ -533,48 +532,30 @@ public final class IrisWorldGeneratorResolver { return resolveFrozenWorldGenerator(worldName, dimension); } - /** - * Startup worlds are created before the first server tick; anything asked for after it is a runtime load. - */ - public void markServerRunning() { - serverRunning = true; + public WorldRefusalReporter refusals() { + return refusals; } private ChunkGenerator refuseWorld(String worldName, Throwable failure) { - ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure); + refusals.report(worldName, refusalKey(worldName), failure); try { - for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) { - Iris.error(line); - } Iris.reportError(failure); } catch (Throwable loggingFailure) { - failure.addSuppressed(loggingFailure); - failure.printStackTrace(System.err); + System.err.println("[Iris] Could not log the refusal of '" + worldName + "': " + + loggingFailure.getClass().getName()); } - return refused; + return IrisFailClosedChunkGenerator.refused(worldName, failure); } - private static String refusalKey(String worldName) { + @Nullable + private static NamespacedKey refusalKey(String worldName) { try { - return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString(); + return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()); } catch (Throwable unresolved) { - return "unresolved key"; + return null; } } - static List refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) { - return GenerationRefusalNotice.compose( - "Iris refused to generate world '" + worldName + "' (" + worldKey + ")", - GenerationRefusalNotice.causes(failure), - List.of( - "Iris does not generate this world and does not let vanilla or any other generator" - + " write it; no chunks are written.", - serverRunning - ? "The world does not load; the server keeps running. Fix the cause, then load it again." - : "Server startup stops before this world loads. Fix the cause, then start the server again." - )); - } - @Nullable private static ChunkGenerator startupLockedGenerator(String worldName) { Optional startupDenial = IrisStartupValidation.denialReason(); @@ -640,6 +621,14 @@ public final class IrisWorldGeneratorResolver { return; } NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); + if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) { + // Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run + // until that binding is gone. + throw new IllegalStateException("'" + worldName + "' (" + messageKey + + ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it." + + " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run" + + " /iris replace " + messageKey + " type= to replace it with Iris on the next restart."); + } throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") has no Iris world storage, so Iris cannot generate it." + " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java new file mode 100644 index 000000000..97620537e --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java @@ -0,0 +1,122 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.world; + +import art.arcane.iris.Iris; +import art.arcane.iris.localization.C; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and + * an operator notice on join for as long as the world stays unloaded, since the banner scrolls away. + */ +public final class WorldRefusalReporter implements Listener { + private static final String UNRESOLVED_KEY = "unresolved key"; + + private final Map operatorNotices = new ConcurrentSkipListMap<>(); + private volatile boolean startupWorldsCreated; + + /** + * Iris enabled on a running server (a hotload) finds the startup worlds already loaded. + */ + public void attach(Plugin plugin) { + if (!Bukkit.getWorlds().isEmpty()) { + startupWorldsCreated = true; + } + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + /** + * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent. + * A generator asked for after that belongs to a runtime create, which fails only its caller. + */ + public boolean startupWorldsCreated() { + return startupWorldsCreated; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldLoad(WorldLoadEvent event) { + startupWorldsCreated = true; + try { + operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString()); + } catch (RuntimeException unkeyed) { + Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by"); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + if (operatorNotices.isEmpty()) { + return; + } + Player player = event.getPlayer(); + if (!player.isOp() && !player.hasPermission("iris.all")) { + return; + } + for (String notice : operatorNotices.values()) { + player.sendMessage(C.RED + notice); + } + } + + /** + * Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting + * failure must not turn either into something else. + */ + public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) { + String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString(); + try { + for (String line : notice(worldName, key, failure, startupWorldsCreated)) { + Iris.error(line); + } + operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key + + ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure)); + } catch (Throwable reportingFailure) { + System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key + + "); the refusal could not be reported: " + reportingFailure.getClass().getName()); + } + } + + static List notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) { + return GenerationRefusalNotice.compose( + "Iris refused to generate world '" + worldName + "' (" + worldKey + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this world and does not let vanilla or any other generator" + + " write it; no chunks are written.", + startupWorldsCreated + ? "The world does not load; the server keeps running. Fix the cause, then load it again." + : "Server startup stops before this world loads. Fix the cause, then start the server again." + )); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java index c2ec0a824..15039e517 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -1,14 +1,20 @@ package art.arcane.iris; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.world.IrisStartupAdmissionListener; import art.arcane.iris.world.IrisStartupValidation; import art.arcane.iris.world.IrisWorldGeneratorResolver; +import art.arcane.iris.world.PendingWorldReplacementManager; +import art.arcane.iris.world.task.J; import com.destroystokyo.paper.profile.PlayerProfile; import org.bukkit.Bukkit; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.WorldInfo; +import org.bukkit.plugin.PluginManager; import org.junit.After; import org.junit.Rule; import org.junit.Test; @@ -16,7 +22,9 @@ import org.junit.rules.TemporaryFolder; import org.mockito.MockedStatic; import java.io.File; +import java.lang.reflect.Field; import java.net.InetAddress; +import java.util.List; import java.util.UUID; import static org.junit.Assert.assertEquals; @@ -24,6 +32,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -40,6 +51,7 @@ public class FailedEnableLockTest { @After public void disableValidation() { IrisStartupValidation.disable(); + IrisPlatforms.unbind(); } @Test @@ -49,7 +61,7 @@ public class FailedEnableLockTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - assertTrue(FailedEnableLock.engage(failure, true)); + assertTrue(FailedEnableLock.engage(failure, true, false)); String denial = IrisStartupValidation.denialReason().orElseThrow(); assertTrue(denial, denial.contains("service graph exploded")); @@ -74,7 +86,7 @@ public class FailedEnableLockTest { IrisStartupValidation.begin(); try (MockedStatic iris = mockStatic(Iris.class)) { - assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false)); + assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false)); } String denial = IrisStartupValidation.denialReason().orElseThrow(); @@ -84,7 +96,7 @@ public class FailedEnableLockTest { @Test public void noticeSaysIrisStaysLockedAndWhatThatBlocks() { String notice = String.join("\n", FailedEnableLock.notice( - new IllegalStateException("outer", new IllegalArgumentException("inner")))); + new IllegalStateException("outer", new IllegalArgumentException("inner")), false)); assertTrue(notice, notice.contains("Iris failed to enable")); assertTrue(notice, notice.contains("outer")); @@ -94,6 +106,82 @@ public class FailedEnableLockTest { assertTrue(notice, notice.contains("logins")); } + /** + * A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops. + */ + @Test + public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() { + String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true)); + + assertFalse(notice, notice.contains("startup stops")); + assertTrue(notice, notice.contains("keeps running")); + } + + /** + * IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside + * the guarded region, and the throw must not reach Paper. + */ + @Test + public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception { + IrisPlatforms.bind(mock(IrisPlatform.class)); + Iris plugin = plugin(); + + try (EnableFixture fixture = new EnableFixture(true)) { + plugin.onEnable(); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("already bound")); + ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld"); + assertNotNull("a null generator is the vanilla generator", generator); + IllegalStateException refusal = assertThrows(IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound")); + fixture.bukkit.verify(Bukkit::shutdown, never()); + } + } + + @Test + public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception { + IrisPlatforms.bind(mock(IrisPlatform.class)); + Iris plugin = plugin(); + + try (EnableFixture fixture = new EnableFixture(false)) { + IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable); + assertTrue(failure.getMessage(), failure.getMessage().contains("already bound")); + } + } + + @Test + public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception { + Iris plugin = plugin(); + set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class)); + + try (EnableFixture fixture = new EnableFixture(true); + MockedStatic nms = mockStatic(INMS.class); + MockedStatic scheduler = mockStatic(J.class)) { + nms.when(INMS::isBound).thenReturn(false); + nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9")); + + plugin.onEnable(); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("no NMS binding for 26.9")); + scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never()); + } + } + + /** + * Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never + * made, so a failed enable tears down at once even while the server stops. + */ + @Test + public void aFailedEnableNeverDefersItsTeardownToTheServerStop() { + assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true)); + assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false)); + assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true)); + assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false)); + } + @Test public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { File levelRoot = temporaryFolder.newFolder("level", "world"); @@ -110,4 +198,43 @@ public class FailedEnableLockTest { assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); } + + /** + * Paper builds plugins through its class loader, so the plugin under test is the real class with its fields + * set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing. + */ + private static Iris plugin() throws Exception { + Iris plugin = mock(Iris.class, CALLS_REAL_METHODS); + set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin)); + return plugin; + } + + private static void set(Iris plugin, String name, Object value) throws Exception { + Field field = Iris.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + /** + * irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here. + */ + private static final class EnableFixture implements AutoCloseable { + private final MockedStatic bukkit = mockStatic(Bukkit.class); + private final MockedStatic iris = mockStatic(Iris.class); + private final MockedStatic lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS); + + private EnableFixture(boolean irisWorlds) { + PluginManager plugins = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds); + } + + @Override + public void close() { + lock.close(); + iris.close(); + bukkit.close(); + } + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java index bf4f7c9d3..abef86a46 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java @@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationManifest; +import java.io.File; import java.io.IOException; import java.lang.reflect.Constructor; import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; @@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder; import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class BukkitWorldReconcilerRuntimeTest { @@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest { } } + /** + * The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs + * the same banner and operator notice as one CraftServer asked for. + */ + @Test + public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception { + NamespacedKey key = new NamespacedKey("iris", "probe"); + IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe."); + Iris plugin = mock(Iris.class); + WorldRefusalReporter refusals = mock(WorldRefusalReporter.class); + when(plugin.worldRefusals()).thenReturn(refusals); + when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal); + + try (MockedStatic iris = mockStatic(Iris.class); + MockedStatic storage = mockStatic(IrisWorldStorage.class)) { + storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world")); + storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe"); + storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe"); + + CompletableFuture created = backend(plugin).createWorld(key, "overworld", 1337L); + + ExecutionException failure = assertThrows(ExecutionException.class, created::get); + assertSame(refusal, failure.getCause()); + verify(refusals).report("world_iris_probe", key, refusal); + } + } + private RuntimeFixture fixture() throws Exception { Path container = temporaryFolder.newFolder().toPath(); Path root = container.resolve("world/dimensions/iris/probe"); @@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest { when(dimension.getLoadKey()).thenReturn("overworld"); when(engine.getData()).thenReturn(data); when(data.getDataFolder()).thenReturn(pack.toFile()); + return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack); + } + + private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception { Class backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend"); Constructor constructor = backendClass.getDeclaredConstructor(Iris.class); constructor.setAccessible(true); - BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class)); - return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack); + return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin); } private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java index 07e9639d7..f30538bb0 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java @@ -53,4 +53,25 @@ public class IrisFailClosedChunkGeneratorTest { assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable")); assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed")); } + + /** + * The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an + * unreadable cause still has to produce a generator. + */ + @Test + public void aCauseWhoseMessageCannotBeReadStillRefuses() { + IllegalStateException unreadable = new IllegalStateException() { + @Override + public String getMessage() { + throw new UnsupportedOperationException("message unavailable"); + } + }; + + ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable); + + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon")); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java index f9aa9ece6..59c2845c3 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java @@ -464,28 +464,10 @@ public class IrisWorldGeneratorResolverTest { } } - @Test - public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() { - IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", - new IOException("Historical generated registry definition changed for iris:biomes/abc")); - - String startup = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( - "world_iris_moon", "iris:moon", failure, false)); - String runtime = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( - "world_iris_moon", "iris:moon", failure, true)); - - for (String notice : List.of(startup, runtime)) { - assertTrue(notice, notice.contains("world_iris_moon")); - assertTrue(notice, notice.contains("iris:moon")); - assertTrue(notice, notice.contains("Iris generation history is unusable at /srv/moon.")); - assertTrue(notice, notice.contains("Historical generated registry definition changed for iris:biomes/abc")); - assertTrue(notice, notice.contains("no chunks")); - } - assertTrue(startup, startup.contains("startup stops")); - assertTrue(runtime, runtime.contains("does not load")); - assertFalse(runtime, runtime.contains("startup stops")); - } - + /** + * A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the + * binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace. + */ @Test public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception { File worldContainer = temporaryFolder.newFolder("vanilla-slot"); @@ -500,7 +482,10 @@ public class IrisWorldGeneratorResolverTest { .resolveDefaultWorldGenerator("world_nether", "overworld")); assertTrue(refusal, refusal.contains("minecraft:the_nether")); - assertTrue(refusal, refusal.contains("/iris create")); + assertTrue(refusal, refusal.contains("worlds.world_nether.generator")); + assertTrue(refusal, refusal.contains("bukkit.yml")); + assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=")); + assertFalse(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java new file mode 100644 index 000000000..7efd537a2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.world; + +import art.arcane.iris.Iris; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the + * server, a runtime create only fails its caller. The banner must not claim the wrong one. + */ +public class WorldRefusalReporterTest { + private static final NamespacedKey MOON = new NamespacedKey("iris", "moon"); + + @Test + public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() { + String banner = reportAndCapture(new WorldRefusalReporter()); + + assertTrue(banner, banner.contains("'moon'")); + assertTrue(banner, banner.contains("iris:moon")); + assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon.")); + assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc")); + assertTrue(banner, banner.contains("no chunks")); + assertTrue(banner, banner.contains("startup stops")); + } + + /** + * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent. + * A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps + * running when that create fails. + */ + @Test + public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld")))); + + String banner = reportAndCapture(reporter); + + assertTrue(banner, banner.contains("keeps running")); + assertFalse(banner, banner.contains("startup stops")); + } + + @Test + public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + Plugin plugin = mock(Plugin.class); + PluginManager plugins = mock(PluginManager.class); + World overworld = world(NamespacedKey.minecraft("overworld")); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + + reporter.attach(plugin); + } + + assertTrue(reporter.startupWorldsCreated()); + verify(plugins).registerEvents(reporter, plugin); + String banner = reportAndCapture(reporter); + assertTrue(banner, banner.contains("keeps running")); + } + + @Test + public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + reportAndCapture(reporter); + Player operator = player(true); + Player member = player(false); + + reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty())); + reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty())); + + verify(operator).sendMessage(contains("iris:moon")); + verify(member, never()).sendMessage(anyString()); + + reporter.onWorldLoad(new WorldLoadEvent(world(MOON))); + Player later = player(true); + reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty())); + verify(later, never()).sendMessage(anyString()); + } + + @Test + public void aFailureWhileReportingNeverEscapes() { + Throwable unreadable = new IllegalStateException() { + @Override + public String getMessage() { + throw new UnsupportedOperationException("message unavailable"); + } + }; + + try (MockedStatic iris = mockStatic(Iris.class)) { + new WorldRefusalReporter().report("moon", MOON, unreadable); + } + } + + private static String reportAndCapture(WorldRefusalReporter reporter) { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed for iris:biomes/abc")); + List lines = new ArrayList<>(); + try (MockedStatic iris = mockStatic(Iris.class)) { + iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> { + lines.add(invocation.getArgument(0, String.class)); + return null; + }); + reporter.report("moon", MOON, failure); + } + return String.join("\n", lines); + } + + private static World world(NamespacedKey key) { + World world = mock(World.class); + when(world.getKey()).thenReturn(key); + when(world.getName()).thenReturn(key.getKey()); + return world; + } + + private static Player player(boolean operator) { + Player player = mock(Player.class); + when(player.isOp()).thenReturn(operator); + return player; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 7b4543f12..5658c9dad 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -40,6 +40,7 @@ public final class ModdedPrimaryWorldRouter { private static final Set inFlight = ConcurrentHashMap.newKeySet(); private static int tickCounter = 0; private static volatile String loginRefusal; + private static volatile String absentPrimary; private ModdedPrimaryWorldRouter() { } @@ -48,6 +49,7 @@ public final class ModdedPrimaryWorldRouter { routed.clear(); inFlight.clear(); loginRefusal = null; + absentPrimary = null; } /** @@ -99,7 +101,7 @@ public final class ModdedPrimaryWorldRouter { NativeWorld target = ModdedDimensionManager.level(server, primary); if (target == null) { if (ModdedStartup.dimensionsRestored()) { - refuseLogins(server, primary); + judgeMissingPrimary(server, primary); } return; } @@ -141,9 +143,37 @@ public final class ModdedPrimaryWorldRouter { }); } + /** + * primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists + * it expected it here, so only that save has a primary world that failed to load. + */ + private static void judgeMissingPrimary(NativeModdedServer server, String primary) { + if (loginRefusal == null && primary.equals(absentPrimary)) { + return; + } + if (loginRefusal == null && !expectedInThisSave(server, primary)) { + absentPrimary = primary; + ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not" + + " exist in this save, so players are not routed. Create it here with /iris world replace-overworld" + + " or clear primaryWorld."); + return; + } + refuseLogins(server, primary); + } + + private static boolean expectedInThisSave(NativeModdedServer server, String primary) { + try { + return ModdedDimensionRegistryStore.get(server, primary) != null; + } catch (RuntimeException unreadable) { + ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '" + + primary + "'", unreadable); + return true; + } + } + /** * Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a - * primary world that did not load refuses every player instead. + * primary world this save expected and did not load refuses every player instead. */ private static void refuseLogins(NativeModdedServer server, String primary) { String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players." @@ -152,7 +182,7 @@ public final class ModdedPrimaryWorldRouter { for (String line : GenerationRefusalNotice.compose( "Iris refused player logins: primary world '" + primary + "' is not loaded", List.of("routePlayersToPrimaryWorld sends players to '" + primary - + "', which did not load at startup."), + + "', which this save's dimension registry expects but did not load at startup."), List.of( "Players are disconnected instead of playing in the overworld, so no chunks are" + " written there.", diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java index 70ad24fa7..03ec17cfc 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -37,7 +37,7 @@ public class ModdedPrimaryWorldRouterTest { } @Test - public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() { + public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() { NativeModdedServer server = mock(NativeModdedServer.class); NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); onlinePlayers(server, online); @@ -45,10 +45,12 @@ public class ModdedPrimaryWorldRouterTest { try (MockedStatic configs = mockStatic(ModdedModConfig.class); MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); MockedStatic startup = mockStatic(ModdedStartup.class); MockedStatic log = mockStatic(ModdedIrisLog.class)) { configs.when(ModdedModConfig::get).thenReturn(routed); manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted()); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); evaluate(server); @@ -63,6 +65,37 @@ public class ModdedPrimaryWorldRouterTest { } } + /** + * primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another + * save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused. + */ + @Test + public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + evaluate(server); + + verify(online, never()).disconnect(anyString()); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1)); + log.verify(() -> ModdedIrisLog.error(anyString()), never()); + } + } + /** * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is * missing then is still loading, not broken. @@ -100,8 +133,10 @@ public class ModdedPrimaryWorldRouterTest { try (MockedStatic configs = mockStatic(ModdedModConfig.class); MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); MockedStatic startup = mockStatic(ModdedStartup.class); MockedStatic log = mockStatic(ModdedIrisLog.class)) { + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted()); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); configs.when(ModdedModConfig::get).thenReturn(routed); evaluate(server); @@ -119,6 +154,38 @@ public class ModdedPrimaryWorldRouterTest { } } + /** + * A registry that cannot be read cannot prove the primary world was never here, so it counts as expected. + */ + @Test + public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)) + .thenThrow(new IllegalStateException("registry could not be read")); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + + verify(online).disconnect(contains(PRIMARY)); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + private static ModdedDimensionRegistryStore.PersistentDimension persisted() { + return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); + } + private static void evaluate(NativeModdedServer server) { for (int tick = 0; tick < TICK_INTERVAL; tick++) { ModdedPrimaryWorldRouter.tick(server); From 54f3595ba7e4e0cac34842e0b463e06660d25806 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:04:09 -0400 Subject: [PATCH 06/19] Propagated engine failures - GenerationClosedException covers closed history routers, stages, saved biome runtimes and admissions - It also covers ownership stores, hydrology caches and engine runtimes - GenerationFailures tells engine, lifecycle, executor and JVM failures from content failures - Engine failures rethrow from object, cave object, procedural, floating, upper and decorator catch sites - Biome layer, noise bound, rotation and mantle write catch sites rethrow them too - Only content failures are skipped - A source plan is never cached or persisted without an object a healthy engine places - A transient engine failure no longer turns imported features off --- .../modded/ModdedImportedFeatureStage.java | 3 + .../biome/IrisBiomeLayerGenerator.java | 3 + .../generation/decoration/DecoratorCore.java | 4 + .../hydrology/HydrologyTileCache.java | 3 +- .../mantle/MantleFloatingObjectComponent.java | 7 + .../mantle/MantleObjectComponent.java | 11 ++ .../iris/generation/mantle/MantleWriter.java | 2 + .../runtime/DimensionTerrainContext.java | 1 + .../runtime/GenerationClosedException.java | 7 + .../runtime/GenerationFailures.java | 62 +++++++ .../iris/generation/runtime/IrisComplex.java | 1 + .../iris/generation/runtime/IrisEngine.java | 6 +- .../stage/IrisFloatingChildBiomeModifier.java | 5 + .../NativeStructureOwnershipStore.java | 5 +- .../object/IrisObjectPlacementRunner.java | 3 + .../structure/object/IrisObjectRotation.java | 2 + .../world/history/GenerationAdmission.java | 4 +- .../iris/world/history/GenerationHistory.java | 3 +- .../GenerationHistoryRuntimeRouter.java | 13 +- .../iris/world/history/SavedBiomeRuntime.java | 5 +- ...antleObjectComponentEngineFailureTest.java | 156 ++++++++++++++++++ .../runtime/GenerationFailuresTest.java | 67 ++++++++ 22 files changed, 357 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java create mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java 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/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/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/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..7e13bc7b4 --- /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 + || 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..b89324d35 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 @@ -1083,10 +1083,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); } @@ -1682,7 +1682,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()); + 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(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(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..2ce54ed01 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java @@ -0,0 +1,67 @@ +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 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")); + } +} From 259f8852382186d38a1f9a366be687ebef6aae78 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:04:18 -0400 Subject: [PATCH 07/19] Modded shutdown after levels close - Stops downloads, protocol, pregen, tools and services at server stopping - Keeps engines, mantle, generation history, the generation pool and burst pools open while the loader drains chunk generation - Closes each engine when its level unloads and sweeps the rest at server stopped - Logs a failed level unload instead of aborting the loader's level close loop --- .../iris/modded/ModdedEngineBootstrap.java | 28 +++--- .../iris/modded/ModdedLifecycleCallbacks.java | 1 + .../arcane/iris/modded/ModdedScheduler.java | 2 +- .../iris/modded/ModdedWorldEngines.java | 6 +- .../ModdedEngineBootstrapShutdownTest.java | 89 +++++++++++++++++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java 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..5e8588d54 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 @@ -122,7 +122,7 @@ public final class ModdedEngineBootstrap { 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(); @@ -180,15 +180,9 @@ 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); } } @@ -205,6 +199,16 @@ 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. + */ + public static void stopped() { + 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 +230,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/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/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index e49d6e537..5f28bcf03 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,7 @@ 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. + // Shutdown stage that always runs (ModdedEngineBootstrap.stopped): 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/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..7ec0850e3 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java @@ -0,0 +1,89 @@ +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.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 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.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)); + } + + 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(); + } +} From 5cadd9b8c4fa4a57304624a86b3159d6c4f06a2e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:04:19 -0400 Subject: [PATCH 08/19] Deferred saved biome updates - Defers a chunk update pass while saved biome information is loading and retries it on a later pass - Forgets only the updates already applied so a retry never refills a chest - Propagates engine failures from loot application instead of marking the chunk done --- .../service/ModdedChunkUpdateService.java | 44 ++++++++-- .../service/ModdedChunkUpdateServiceTest.java | 88 +++++++++++++++++++ 2 files changed, 127 insertions(+), 5 deletions(-) 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..884174eb3 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; @@ -195,6 +198,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 +323,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; @@ -338,6 +371,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { try { ModdedLootApplier.apply(engine, level, pos, state, chunk); } catch (Throwable e) { + GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); } } else { 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..5b69b0b62 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 @@ -4,13 +4,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.modded.ModdedLootApplier; 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.terrain.NativeBlockPoint; import art.arcane.volmlib.util.function.Consumer4; 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,6 +28,7 @@ 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.MockedStatic; import java.lang.reflect.Proxy; import java.util.ArrayList; @@ -31,12 +36,17 @@ import java.util.List; 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.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +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 +80,44 @@ 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(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 scansWhenPlayersArePresent() { assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false)); @@ -166,6 +214,46 @@ 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; + } + } + @SuppressWarnings("unchecked") private static MantleDataAdapter emptyAdapter() { return (MantleDataAdapter) Proxy.newProxyInstance( From 82fa69611e1c635639d009e9ad6a2101d50ed604 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:04:19 -0400 Subject: [PATCH 09/19] Accurate teleport failures - Waits for the destination chunk without a wall-clock deadline so a cold first chunk no longer fails the teleport - Reports the failure cause, an unloaded dimension, an offline player or a refused teleport separately - Adds the new messages to every locale --- .../modded/command/IrisModdedCommands.java | 38 +++++++++++++-- .../localization/ModdedCommandMessages.java | 15 ++++++ .../IrisModdedCommandsTeleportTest.java | 48 +++++++++++++++++++ core/src/main/resources/languages/de_DE.toml | 3 ++ core/src/main/resources/languages/es_ES.toml | 3 ++ core/src/main/resources/languages/fi_FI.toml | 3 ++ core/src/main/resources/languages/fr_FR.toml | 3 ++ core/src/main/resources/languages/he_IL.toml | 3 ++ core/src/main/resources/languages/it_IT.toml | 3 ++ core/src/main/resources/languages/ja-JP.toml | 3 ++ core/src/main/resources/languages/ko_KR.toml | 3 ++ core/src/main/resources/languages/lt_LT.toml | 3 ++ core/src/main/resources/languages/nl_NL.toml | 3 ++ core/src/main/resources/languages/pl_PL.toml | 3 ++ core/src/main/resources/languages/pt_PT.toml | 3 ++ core/src/main/resources/languages/ru_RU.toml | 3 ++ core/src/main/resources/languages/tr_TR.toml | 3 ++ core/src/main/resources/languages/vi_VI.toml | 3 ++ core/src/main/resources/languages/zh_CN.toml | 3 ++ core/src/main/resources/languages/zh_TW.toml | 3 ++ 20 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java 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..3c791cd44 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,6 +60,7 @@ 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; @@ -132,8 +133,7 @@ public final class IrisModdedCommands { String dimensionId = level.name(); 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))); + server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, 0L)); teleport.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success) && failure == null) { return; @@ -142,14 +142,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/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..5cfc72fe4 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.modded.localization.ModdedCommandMessages; +import art.arcane.volmlib.util.localization.MessageArgument; +import org.junit.Test; + +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertEquals; + +public class IrisModdedCommandsTeleportTest { + private static final String DIMENSION = "irisworldgen:1"; + + @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/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index 016735991..cf9ca7168 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -769,6 +769,9 @@ opening_your_editor = "Öffne {value} {value2} in deinem Editor." console_must_name_player_iris_tp_dimension_player = "Die Konsole muss einen Spieler benennen: /iris tp " is_not_generated_by_iris = "{value} wird nicht von Iris generiert." teleport_failed_dimension_is_not_loaded = "Teleportation fehlgeschlagen: Dimension {dimensionId} ist nicht geladen." +teleport_failed_reason = "Teleport nach {dimensionId} fehlgeschlagen: {reason}" +teleport_cancelled_player_offline = "Teleport nach {dimensionId} abgebrochen: {value} ist nicht mehr online." +teleport_refused = "Teleport nach {dimensionId} wurde vom Server abgelehnt." teleporting = "Teleportiere {value} nach {dimensionId}..." is_not_generated_by_iris_2 = "{value} wird nicht von Iris generiert." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Die primäre Welt kann nicht evakuiert werden, da es kein Ziel für die Spieler gibt." diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 74785879a..09341bda9 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -769,6 +769,9 @@ opening_your_editor = "Abriendo {value} {value2} en tu editor." console_must_name_player_iris_tp_dimension_player = "La consola debe indicar un jugador: /iris tp " is_not_generated_by_iris = "{value} no se genera por Iris." teleport_failed_dimension_is_not_loaded = "El teletransporte falló: la dimensión {dimensionId} no está cargada." +teleport_failed_reason = "El teletransporte a {dimensionId} falló: {reason}" +teleport_cancelled_player_offline = "Teletransporte a {dimensionId} cancelado: {value} ya no está conectado." +teleport_refused = "El servidor rechazó el teletransporte a {dimensionId}." teleporting = "Teletransportando a {value} a {dimensionId}..." is_not_generated_by_iris_2 = "{value} no se genera por Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "No se puede evacuar el mundo principal porque no hay adónde enviar a los jugadores." diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index e361bf777..399ec90d3 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -774,6 +774,9 @@ opening_your_editor = "Avaaminen {value} {value2} Päätoimittajassasi." console_must_name_player_iris_tp_dimension_player = "Konsolin on nimettävä pelaaja: /iris tp " is_not_generated_by_iris = "{value} ei ole tuotettu Iris." teleport_failed_dimension_is_not_loaded = "Teleportti epäonnistui: ulottuvuus {dimensionId} ei ole ladattu." +teleport_failed_reason = "Teleporttaus ulottuvuuteen {dimensionId} epäonnistui: {reason}" +teleport_cancelled_player_offline = "Teleporttaus ulottuvuuteen {dimensionId} peruttiin: {value} ei ole enää paikalla." +teleport_refused = "Palvelin hylkäsi teleporttauksen ulottuvuuteen {dimensionId}." teleporting = "Telesiirtäminen {value} - {dimensionId}..." is_not_generated_by_iris_2 = "{value} ei ole tuotettu Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Ensisijaista maailmaa ei voida evakuoida; pelaajia ei voi lähettää minnekään." diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 7a1a7c063..3f35a01e7 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -769,6 +769,9 @@ opening_your_editor = "Ouverture de {value} {value2} dans votre éditeur." console_must_name_player_iris_tp_dimension_player = "La console doit indiquer un joueur : /iris tp " is_not_generated_by_iris = "{value} n'est pas généré par Iris." teleport_failed_dimension_is_not_loaded = "Échec de la téléportation : la dimension {dimensionId} n'est pas chargée." +teleport_failed_reason = "Échec de la téléportation vers {dimensionId} : {reason}" +teleport_cancelled_player_offline = "Téléportation vers {dimensionId} annulée : {value} n'est plus connecté." +teleport_refused = "Le serveur a refusé la téléportation vers {dimensionId}." teleporting = "Téléportation de {value} vers {dimensionId}..." is_not_generated_by_iris_2 = "{value} n'est pas généré par Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Impossible d'évacuer le monde principal, car aucun autre monde ne peut accueillir les joueurs." diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index 11c001580..4b0b85b79 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.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/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 71d53a917..30b6cd439 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -769,6 +769,9 @@ opening_your_editor = "Apertura di {value} {value2} nel tuo editor." console_must_name_player_iris_tp_dimension_player = "La console deve specificare un giocatore: /iris tp " is_not_generated_by_iris = "{value} non è generato da Iris." teleport_failed_dimension_is_not_loaded = "Teletrasporto non riuscito: la dimensione {dimensionId} non è caricata." +teleport_failed_reason = "Teletrasporto verso {dimensionId} non riuscito: {reason}" +teleport_cancelled_player_offline = "Teletrasporto verso {dimensionId} annullato: {value} non è più online." +teleport_refused = "Il server ha rifiutato il teletrasporto verso {dimensionId}." teleporting = "Teletrasporto di {value} verso {dimensionId}..." is_not_generated_by_iris_2 = "{value} non è generato da Iris." cannot_evacuate_primary_world_there_is_nowhere_send_players = "Impossibile evacuare il mondo principale: non esiste una destinazione per i giocatori." diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index 29998c0d9..4c5015852 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.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 = "テレポートに失敗しました: ディメンション {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 = "無法撤離主世界; 沒有地方可以傳送玩家 。" From dd4633ad3add19b869fcfeb35821bc3f82f21ccf Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:50:18 -0400 Subject: [PATCH 10/19] Isolated chunk update failures - Treats only loading saved biome information as an engine failure - Leaves a container without loot and warns once when its saved biome cannot be recovered - Keeps a failed chunk unetched and retries it without stopping the rest of the level's update pass - Ends a level's pass only when its engine or mantle is closed - Covers the deferred, permanent and isolated update paths through updateChunk and the server tick --- .../service/ModdedChunkUpdateService.java | 26 ++- .../service/ModdedChunkUpdateServiceTest.java | 173 +++++++++++++++++- .../runtime/GenerationFailures.java | 2 +- .../runtime/GenerationFailuresTest.java | 8 + 4 files changed, 203 insertions(+), 6 deletions(-) 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 884174eb3..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 @@ -152,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); } } }); @@ -160,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) { @@ -370,6 +384,10 @@ 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); 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 5b69b0b62..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,14 +2,25 @@ 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; @@ -28,11 +39,15 @@ 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; @@ -41,9 +56,13 @@ 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; @@ -99,7 +118,7 @@ public class ModdedChunkUpdateServiceTest { "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(invocation -> { + loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> { NativeBlockPoint position = invocation.getArgument(2); if (position.x() == 2) { throw loading; @@ -118,6 +137,110 @@ public class ModdedChunkUpdateServiceTest { 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)); @@ -254,6 +377,54 @@ public class ModdedChunkUpdateServiceTest { } } + 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/runtime/GenerationFailures.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java index 7e13bc7b4..17564f94c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java @@ -31,7 +31,7 @@ public final class GenerationFailures { || current instanceof GenerationClosedException || current instanceof MantleClosedException || current instanceof WrongEngineBroException - || current instanceof SavedBiomeUnavailableException + || current instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading() || current instanceof InterruptedException || current instanceof InterruptedIOException || current instanceof ClosedChannelException 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 index 2ce54ed01..375096bfc 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java @@ -34,6 +34,14 @@ public class GenerationFailuresTest { 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(); From ea516b4a590ec0f8d7f6468e100371d5e1da3ee4 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:50:18 -0400 Subject: [PATCH 11/19] Refused maintenance generation - A chunk is refused while Folia world maintenance forbids mantle stages - Such chunks no longer end up missing objects, caves, deposits or their REAL flag - Refused chunks warn at most every 5 seconds - The overworld mode no longer bypasses mantle stages --- .../runtime/BukkitEnginePlatformHooks.java | 2 +- .../iris/generation/mode/ModeOverworld.java | 73 ++----------------- .../runtime/EnginePlatformHooks.java | 6 +- .../iris/generation/runtime/IrisEngine.java | 37 +++++++--- .../IrisEngineMaintenanceRefusalTest.java | 49 +++++++++++++ 5 files changed, 87 insertions(+), 80 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineMaintenanceRefusalTest.java 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/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/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/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index b89324d35..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; @@ -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; 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); + } +} From 782a85c26a2244edacca65c8629b3c7754f9cc21 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:50:18 -0400 Subject: [PATCH 12/19] Modded teardown fallback - Closes engines, pools and scheduler a previous server left open when the next server starts - Flushes them at JVM exit once the stopping server thread has ended - Corrects the scheduler shutdown comment --- .../iris/modded/ModdedEngineBootstrap.java | 58 +++++++++++++++++++ .../arcane/iris/modded/ModdedScheduler.java | 3 +- .../ModdedEngineBootstrapShutdownTest.java | 22 +++++++ 3 files changed, 82 insertions(+), 1 deletion(-) 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 5e8588d54..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,6 +126,8 @@ public final class ModdedEngineBootstrap { } public static void start(NativeModdedServer server) { + finishPreviousServer("server start"); + registerExitFlush(); captureInitialSpawn(server); currentServer = server; bind(); @@ -150,6 +159,7 @@ public final class ModdedEngineBootstrap { } public static void serverAboutToStart(NativeModdedServer server) { + finishPreviousServer("server start"); captureInitialSpawn(server); ModdedStartup.prepareForStartup(); } @@ -188,6 +198,8 @@ public final class ModdedEngineBootstrap { 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)); @@ -206,8 +218,54 @@ public final class ModdedEngineBootstrap { * 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); 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 5f28bcf03..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.stopped): 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/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java index 7ec0850e3..72134991d 100644 --- 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 @@ -4,6 +4,8 @@ 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; @@ -14,6 +16,8 @@ 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; @@ -23,6 +27,8 @@ 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; @@ -70,6 +76,22 @@ public class ModdedEngineBootstrapShutdownTest { 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); From 2d5af3b61e49e27a37aba5a02027c38809ce9ad8 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:50:18 -0400 Subject: [PATCH 13/19] Bounded teleport deadline - /iris tp waits up to 120 seconds for the destination chunk - A refusing generator or removed dimension still fails the command - Tests cover the deadline and the reported timeout limit --- .../modded/command/IrisModdedCommands.java | 6 +- .../IrisModdedCommandsTeleportTest.java | 97 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) 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 3c791cd44..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 @@ -67,6 +67,9 @@ 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 +136,8 @@ public final class IrisModdedCommands { String dimensionId = level.name(); NativeModdedServer server = source.server(); CompletableFuture teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination( - server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, 0L)); + server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D, + System.nanoTime() + TimeUnit.SECONDS.toNanos(TELEPORT_DEADLINE_SECONDS))); teleport.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success) && failure == null) { return; 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 index 5cfc72fe4..a6abc2183 100644 --- 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 @@ -1,18 +1,115 @@ 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", From 8cf9e01d9018c1d0daa11d08ca3dcb945c5f52ba Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:50:18 -0400 Subject: [PATCH 14/19] Engine failure test style - Fixes an indentation slip and types the answer lambdas explicitly --- .../mantle/MantleObjectComponentEngineFailureTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index fdccc19d6..4d497739e 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentEngineFailureTest.java @@ -22,6 +22,7 @@ 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; @@ -89,7 +90,7 @@ public class MantleObjectComponentEngineFailureTest { Engine engine = mock(Engine.class); when(engine.getDimension()).thenReturn(dimension); when(engine.getSeedManager()).thenReturn(seeds); - EngineMantle engineMantle = mock(EngineMantle.class); + EngineMantle engineMantle = mock(EngineMantle.class); IrisData data = mock(IrisData.class); when(engineMantle.getEngine()).thenReturn(engine); when(engineMantle.getData()).thenReturn(data); @@ -99,7 +100,7 @@ public class MantleObjectComponentEngineFailureTest { when(object.getW()).thenReturn(1); when(object.getH()).thenReturn(1); when(object.getD()).thenReturn(1); - doAnswer(invocation -> { + doAnswer((InvocationOnMock invocation) -> { placements.incrementAndGet(); throw placementFailure; }).when(object).place(anyInt(), anyInt(), anyInt(), any(IObjectPlacer.class), any(IrisObjectPlacement.class), @@ -141,7 +142,7 @@ public class MantleObjectComponentEngineFailureTest { writer = mock(MantleWriter.class); when(writer.getMantle()).thenReturn(mantle); when(writer.getEngine()).thenReturn(engine); - doAnswer(invocation -> { + doAnswer((InvocationOnMock invocation) -> { Runnable task = invocation.getArgument(2); task.run(); return null; From 96a400b58533332b93b5f650179be641a9d7e40a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 07:13:23 -0400 Subject: [PATCH 15/19] Fail-closed modded decoration - A generator with a refused or stopping engine refuses imported feature settings - Those settings no longer fall back to vanilla ones - Decoration fails when a sealed runtime has no settled feature table - Decoration fails when the feature table was retired before placement - Stacked initial spawns fail when the surface spawn biome lookup is rejected - Those spawns no longer fall back to the chunk's top biome - Bind failures from a stopping runtime stay out of the refusal memo and banner - The next bind after such a failure retries --- .../iris/modded/IrisModdedChunkGenerator.java | 29 ++++---- .../arcane/iris/modded/ModdedBiomePolicy.java | 20 +++--- .../modded/ModdedImportedFeatureStage.java | 33 ++++++--- .../iris/modded/ModdedBindFailureTest.java | 65 +++++++++++++++++ .../iris/modded/ModdedBiomePolicyTest.java | 24 +++++++ .../ModdedImportedFeatureStageTest.java | 71 ++++++++++++++++++- .../runtime/GenerationFailures.java | 39 ++++++---- .../runtime/GenerationFailuresTest.java | 28 ++++++++ 8 files changed, 258 insertions(+), 51 deletions(-) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index f47c8f235..647aafd92 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -73,6 +73,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy; @@ -517,6 +518,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } private void recordBindFailure(NativeWorld level, Throwable error) { + // A runtime that is stopping says nothing about the world: fail this call, let the next bind try again. + if (GenerationFailures.isShutdownFailure(error)) { + ModdedIrisLog.warn("Iris bind of {} ({}) stopped with its runtime: {}", level.name(), dimensionKey, + error.toString()); + return; + } engineBinding.fail(error); for (String line : bindFailureNotice(level.name(), dimensionKey, error)) { ModdedIrisLog.error(line); @@ -588,21 +595,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } } - Engine engineOrNull() { - requireBindingAllowed(); - Engine cached = engine; - requireCompletedShutdown(cached); - if (cached != null && !cached.isClosed()) { - return cached; - } - try { - return engine(); - } catch (Throwable ignored) { - return null; - } - } - - Engine structureEngineOrNull() { + /** + * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose + * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator. + */ + Engine boundEngineOrNull() { requireBindingAllowed(); engineBinding.throwIfFailed(dimensionKey); Engine cached = engine; @@ -1007,7 +1004,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat @Override public NativeModdedBiomePolicy biomePolicy(NativeBiomeSourceAccess source) { ModdedBiomePolicy policy = new ModdedBiomePolicy<>(source); - policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::structureEngineOrNull, this::awaitStructureEngine, + policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::boundEngineOrNull, this::awaitStructureEngine, this::allowsGenerationHistoryBypass, this::configuredStructureBiomeKeys, IrisModdedChunkGenerator::retainedBiomeKeys, this::visibleBiomeSeed)); return policy; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java index 83d69e3c3..7cba27969 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java @@ -266,7 +266,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { @Override public H getNoiseBiome(int quartX, int quartY, int quartZ, S sampler) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return access.serializedNoise(quartX, quartY, quartZ, sampler); } @@ -288,7 +288,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { @Override public NativeBiomeSourcePolicy.BiomeLocation findClosestBiome3d(ClosestQuery query) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return query.unboundFallback().get(); } @@ -355,7 +355,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public H getVisibleNoiseBiome(int quartX, int quartY, int quartZ, S sampler) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return access.serializedNoise(quartX, quartY, quartZ, sampler); } @@ -411,7 +411,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public H getVisibleSurfaceBiome(int blockX, int blockZ) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return null; } @@ -420,15 +420,15 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { try (historyScope) { GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome"); if (lease == null) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup was rejected during an engine transition"); } try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { if (!isReady(engine)) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup has no active engine runtime"); } NativeBiomeSourceAccess.RegistryView registry = biomeRegistry(); if (registry == null) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup has no biome registry"); } int quartX = ((blockX) >> 2); int quartZ = ((blockZ) >> 2); @@ -463,7 +463,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public boolean isStructureReachable(Iterable biomes) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return isStructureReachable(biomes, possibleStructureBiomeKeys()); } @@ -494,7 +494,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { int radius = query.radius(); S sampler = query.sampler(); int minQuartY = ((y - radius) >> 2); - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return query.fallback().get(); } @@ -874,7 +874,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { return engine != null && !engine.isClosed() && engine.getComplex() != null; } - private Engine engineOrNull() { + private Engine boundEngineOrNull() { RuntimeCallbacks current = runtime; return current == null ? null : current.currentEngine().get(); } 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 b4a162b54..0c73b4fed 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.GenerationClosedException; import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy; @@ -96,10 +97,12 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe * the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome * whose datapack JSON declares none by design. Real registry biomes pass straight through. * - *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. + *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. So is an + * unbound generator, which only world creation and codec validation ask. A bound generator whose engine is + * refused or stopping throws: BiomeFilter would otherwise drop every imported feature of a custom biome. */ public NativeFeatureTable currentTable() { - Engine engine = generator == null ? null : generator.engineOrNull(); + Engine engine = generator == null ? null : generator.boundEngineOrNull(); return engine == null ? null : featureTables.get(engine.getCacheID()); } @@ -112,8 +115,15 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe * the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen * thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose * cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor. + * A sealed runtime keeps the table it already settled but cannot build one, and decorating without it + * would persist a chunk with no imported features, so it fails the chunk instead. */ public void prepare(Engine engine) { + if ((engine.isClosed() || engine.isClosing()) + && !settled(engine.getCacheID(), biomeSource.packGeneration())) { + throw new GenerationClosedException("Iris imported features cannot prepare runtime " + + engine.getCacheID() + " while its engine is closing."); + } prepare(engine, true); } @@ -128,7 +138,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe } private void prepare(Engine engine, boolean waitForBuild) { - if (engine == null || engine.isClosed() || engine.isClosing()) { + if (engine.isClosed() || engine.isClosing()) { return; } int runtimeIdentity = engine.getCacheID(); @@ -204,17 +214,24 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe inertGenerations.put(runtimeIdentity, generation); } + /** + * Null only for a runtime whose features are settled off. A table retired or superseded since this chunk's + * prepare fails the chunk: skipping the pass would persist it without imported features. + */ public NativeFeatureTable placementTable(Engine engine) { int runtimeIdentity = engine.getCacheID(); + long generation = biomeSource.packGeneration(); NativeFeatureTable table = featureTables.get(runtimeIdentity); - if (table == null) { + if (table == null && Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity))) { WorldCheckFeaturePlacement.recordFeaturesOff(); return null; } - if (table.generation() != biomeSource.packGeneration()) { - featureTables.remove(runtimeIdentity, table); - inertGenerations.remove(runtimeIdentity); - return null; + if (table == null || table.generation() != generation) { + if (table != null) { + featureTables.remove(runtimeIdentity, table); + } + throw new GenerationClosedException("Iris imported feature table for runtime " + runtimeIdentity + + " was retired before decoration."); } return generator == null ? null : table; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java index 1a4ca2ccf..40b207410 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.MantleClosedException; import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import net.minecraft.server.level.ServerChunkCache; @@ -16,14 +17,22 @@ import org.mockito.MockedStatic; import java.io.IOException; import java.lang.reflect.Field; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.Assert.assertNotSame; 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.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -63,6 +72,62 @@ public class ModdedBindFailureTest { } } + /** + * A bind that fails because the runtime is stopping (level unload, quit to title, server stop) says nothing about + * the world: it fails the chunk that asked, but the next bind tries again and no refusal is announced. + */ + @Test + public void aShutdownFailureDuringBindIsNeitherRememberedNorAnnouncedAsARefusal() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + field("boundLevel").set(generator, level); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(true); + MantleClosedException closed = new MantleClosedException("Tectonic Plate is closed!"); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any())).thenThrow(closed); + + assertSame(closed, assertThrows(MantleClosedException.class, generator::engine)); + assertSame(closed, assertThrows(MantleClosedException.class, generator::engine)); + + engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2)); + log.verify(() -> ModdedIrisLog.error(anyString()), never()); + } + } + + /** + * Unloading a level resets its binding, so a refusal recorded in one load never carries into the next load of the + * same generator (a runtime dimension removal that rolls back, or a level loaded again in the same JVM). + */ + @Test + public void aRecordedRefusalDoesNotOutliveTheUnloadOfItsLevel() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + doNothing().when((NativeModdedChunkGenerator) field("nativeGenerator").get(generator)).clearCaches(); + field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class)); + field("announced").set(generator, new AtomicBoolean()); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(false); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + + IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + generator.unbindEngine(level); + IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + + assertNotSame(first, second.getCause()); + servers.verify(() -> NativeModdedServer.forWorld(level), times(2)); + engines.verify(() -> ModdedWorldEngines.evictOrThrow(level)); + } + } + @Test public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() { String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice( diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java index 644e24040..4511d8a3a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.modded; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess; @@ -84,6 +85,29 @@ public class ModdedBiomePolicyTest { assertSame(forest, policy.requiredStructureBiome(6000, -30, -6000, null)); } + /** + * Stacked initial mob spawning asks for the visible surface biome. A null answer made it spawn from the chunk's + * top biome instead, so a sealed or unready engine has to fail the chunk. + */ + @Test + public void surfaceSpawnBiomeRefusesAnEngineInTransitionInsteadOfAnsweringNothing() throws Exception { + Engine sealed = mock(Engine.class); + when(sealed.isClosing()).thenReturn(true); + when(sealed.acquireGenerationLease(anyString())).thenThrow(new GenerationSessionException("sealed", true)); + Engine unready = mock(Engine.class); + when(unready.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop()); + + assertThrows(IllegalStateException.class, () -> boundTo(sealed).getVisibleSurfaceBiome(8, 8)); + assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8)); + } + + private static ModdedBiomePolicy boundTo(Engine engine) { + ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>())); + policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true, + Set::of, bound -> Set.of(), () -> 0L)); + return policy; + } + private static ModdedBiomePolicy policy(List entries, AtomicReference> configured) { ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(entries)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java index 5bb349ddf..8b0759862 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSource; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl; import org.junit.Test; @@ -11,6 +12,9 @@ import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -56,17 +60,78 @@ public class ModdedImportedFeatureStageTest { } @Test - public void closingRuntimeDoesNotBuildFeatureTables() { + public void aSealedRuntimeWithoutASettledTableRefusesDecorationInsteadOfSkippingFeatures() { NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); Engine engine = mock(Engine.class); + when(engine.getCacheID()).thenReturn(7); when(engine.isClosing()).thenReturn(true); ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); - stage.prepare(engine); stage.prepareWithoutWaiting(engine); + assertThrows(GenerationClosedException.class, () -> stage.prepare(engine)); - verify(source, never()).packGeneration(); verify(engine, never()).getDimension(); assertFalse(stage.active()); } + + @Test + public void aSealedRuntimeKeepsDecoratingWithTheTableItAlreadySettled() { + NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); + Engine engine = disabledFeatures(7); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); + stage.bind(mock(IrisModdedChunkGenerator.class)); + stage.prepare(engine); + + when(engine.isClosing()).thenReturn(true); + stage.prepare(engine); + + assertNull(stage.placementTable(engine)); + } + + @Test + public void aTableRetiredBetweenPrepareAndPlacementFailsTheChunk() { + NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); + Engine engine = disabledFeatures(7); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); + stage.bind(mock(IrisModdedChunkGenerator.class)); + + stage.prepare(engine); + stage.evictRuntime(7); + assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine)); + + stage.prepare(engine); + when(source.packGeneration()).thenReturn(2L); + assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine)); + } + + @Test + public void anUnboundGeneratorAnswersVanillaGenerationSettings() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + when(generator.boundEngineOrNull()).thenReturn(null); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class)); + stage.bind(generator); + + assertNull(stage.currentTable()); + } + + @Test + public void aFailedBindFailsTheGenerationSettingsInsteadOfDroppingImportedFeatures() { + IllegalStateException refused = new IllegalStateException("Iris generator 'overworld:overworld' failed to bind"); + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + when(generator.boundEngineOrNull()).thenThrow(refused); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class)); + stage.bind(generator); + + assertSame(refused, assertThrows(IllegalStateException.class, stage::currentTable)); + } + + private static Engine disabledFeatures(int runtimeIdentity) { + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(new IrisDimension().setImportedFeatures(new IrisImportedFeatureControl())); + when(engine.getCacheID()).thenReturn(runtimeIdentity); + return engine; + } } 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 index 17564f94c..0ecab8a9c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java @@ -8,6 +8,7 @@ import java.nio.channels.ClosedChannelException; import java.util.concurrent.CancellationException; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; /** * Separates failures of the engine, its mantle, generation history, executors or the JVM from failures of one @@ -22,22 +23,32 @@ public final class GenerationFailures { } public static boolean isEngineFailure(Throwable failure) { - if (Thread.currentThread().isInterrupted()) { - return true; - } + return isShutdownFailure(failure) || hasCause(failure, (Throwable cause) -> cause instanceof Error + || cause instanceof WrongEngineBroException + || cause instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading() + || cause instanceof TimeoutException); + } + + /** + * The engine failures that only mean a runtime, pool or thread is stopping. They say nothing about the world, so + * nothing may remember them against it once the next runtime is up. + */ + public static boolean isShutdownFailure(Throwable failure) { + return Thread.currentThread().isInterrupted() || hasCause(failure, (Throwable cause) -> + cause instanceof GenerationClosedException + || cause instanceof MantleClosedException + || cause instanceof GenerationSessionException session && session.isExpectedTeardown() + || cause instanceof InterruptedException + || cause instanceof InterruptedIOException + || cause instanceof ClosedChannelException + || cause instanceof RejectedExecutionException + || cause instanceof CancellationException); + } + + private static boolean hasCause(Throwable failure, Predicate match) { 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) { + if (match.test(current)) { return true; } } 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 index 375096bfc..14dd8d479 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.volmlib.util.mantle.MantleClosedException; import org.junit.Test; +import java.io.IOException; import java.nio.channels.ClosedByInterruptException; import java.util.concurrent.CompletionException; import java.util.concurrent.RejectedExecutionException; @@ -52,6 +53,33 @@ public class GenerationFailuresTest { } } + @Test + public void shutdownFailuresAreTheLifecycleSubsetOfEngineFailures() { + assertTrue(GenerationFailures.isShutdownFailure(new GenerationClosedException("router closed"))); + assertTrue(GenerationFailures.isShutdownFailure(new MantleClosedException("Tectonic Plate is closed!"))); + assertTrue(GenerationFailures.isShutdownFailure(new RejectedExecutionException("pool shut down"))); + assertTrue(GenerationFailures.isShutdownFailure(new ClosedByInterruptException())); + assertTrue(GenerationFailures.isShutdownFailure(new GenerationSessionException("sealed", true))); + assertTrue(GenerationFailures.isShutdownFailure(new IllegalStateException("bind failed", + new CompletionException(new InterruptedException())))); + + assertFalse(GenerationFailures.isShutdownFailure(new OutOfMemoryError())); + assertFalse(GenerationFailures.isShutdownFailure(new NoClassDefFoundError("art/arcane/Missing"))); + assertFalse(GenerationFailures.isShutdownFailure(new GenerationSessionException("wrong engine"))); + assertFalse(GenerationFailures.isShutdownFailure(new IllegalStateException( + "Iris generation history is unusable", new IOException("registry definition changed")))); + } + + @Test + public void anyFailureOnAnInterruptedThreadIsAShutdownFailure() { + Thread.currentThread().interrupt(); + try { + assertTrue(GenerationFailures.isShutdownFailure(new IllegalStateException("history unusable"))); + } finally { + Thread.interrupted(); + } + } + @Test public void rethrowPropagatesEngineFailuresAndReturnsForContentFailures() { GenerationClosedException closed = new GenerationClosedException("router closed"); From f55fde17d478aa879b724e4a2214f7196779c374 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 07:13:27 -0400 Subject: [PATCH 16/19] Shared teleport deadline - Give primary world routing and Iris Vision teleports the 120 second first-chunk deadline /iris tp uses --- .../iris/modded/ModdedPrimaryWorldRouter.java | 3 +- .../iris/modded/ModdedTeleportDeadline.java | 37 ++++++++++++ .../modded/command/IrisModdedCommands.java | 6 +- .../modded/command/ModdedVisionOverlay.java | 5 +- .../modded/ModdedPrimaryWorldRouterTest.java | 47 ++++++++++++++++ .../command/ModdedVisionOverlayTest.java | 56 +++++++++++++++++++ 6 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 5658c9dad..861f73b37 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -29,7 +29,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -124,7 +123,7 @@ public final class ModdedPrimaryWorldRouter { try { CompletableFuture teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), - System.nanoTime() + TimeUnit.SECONDS.toNanos(10))); + ModdedTeleportDeadline.fromNow())); teleport.whenComplete((success, failure) -> { inFlight.remove(id); if (Boolean.TRUE.equals(success) && failure == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java new file mode 100644 index 000000000..e4cba1d4f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java @@ -0,0 +1,37 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import java.util.concurrent.TimeUnit; + +/** + * Deadline for a teleport that waits on its destination chunk. 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). + */ +public final class ModdedTeleportDeadline { + private static final long DEADLINE_SECONDS = 120L; + + private ModdedTeleportDeadline() { + } + + public static long fromNow() { + return System.nanoTime() + TimeUnit.SECONDS.toNanos(DEADLINE_SECONDS); + } +} 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 3771034dd..1be62bae4 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 @@ -36,6 +36,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.iris.modded.ModdedScheduler; +import art.arcane.iris.modded.ModdedTeleportDeadline; import art.arcane.iris.modded.ModdedWorldgenIds; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KMap; @@ -67,9 +68,6 @@ 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; @@ -137,7 +135,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(TELEPORT_DEADLINE_SECONDS))); + ModdedTeleportDeadline.fromNow())); teleport.whenComplete((success, failure) -> { if (Boolean.TRUE.equals(success) && failure == null) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index 8b8af635f..64460392a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -25,6 +25,7 @@ import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.studio.render.RenderType; import art.arcane.iris.modded.ModdedIrisLog; +import art.arcane.iris.modded.ModdedTeleportDeadline; import java.awt.Desktop; import java.io.File; @@ -32,7 +33,6 @@ import java.util.List; import java.util.Optional; import art.arcane.volmlib.nativelib.view.WorldView; import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.function.Consumer; public final class ModdedVisionOverlay implements GuiOverlay { @@ -63,8 +63,7 @@ public final class ModdedVisionOverlay implements GuiOverlay { world.execute(() -> { int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; Optional operation = world.teleport(opener, - new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D, - System.nanoTime() + TimeUnit.SECONDS.toNanos(10L))); + new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D, ModdedTeleportDeadline.fromNow())); if (operation.isEmpty()) { return; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java index 03ec17cfc..72c128b6b 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -2,14 +2,21 @@ package art.arcane.iris.modded; 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.NativeWorldTeleport; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import org.junit.After; 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.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -182,6 +189,46 @@ public class ModdedPrimaryWorldRouterTest { } } + /** + * Routing waits on the primary world's first chunk, which on a cold world also loads the pack and binds the + * engine, so it gets the same bounded deadline as /iris tp instead of timing out and retrying every second. + */ + @Test + public void routingWaitsForAColdPrimaryWorldWithinTheTeleportDeadline() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeWorld overworld = mock(NativeWorld.class); + when(overworld.nativeHandle()).thenReturn(new Object()); + NativeWorld primary = mock(NativeWorld.class); + when(primary.nativeHandle()).thenReturn(new Object()); + when(server.overworld()).thenReturn(overworld); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + when(player.id()).thenReturn(UUID.randomUUID()); + when(player.isInWorld(overworld)).thenReturn(true); + onlinePlayers(server, player); + ModdedModConfig routed = config(true, PRIMARY); + AtomicReference requested = new AtomicReference<>(); + + long before = System.nanoTime(); + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary); + teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> { + requested.set(invocation.getArgument(1)); + return new CompletableFuture(); + }); + + evaluate(server); + } + long after = System.nanoTime(); + + assertSame(primary, requested.get().world()); + 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)); + } + private static ModdedDimensionRegistryStore.PersistentDimension persisted() { return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java new file mode 100644 index 000000000..43cb8e65d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java @@ -0,0 +1,56 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.view.WorldView; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ModdedVisionOverlayTest { + /** + * A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same + * bounded deadline as /iris tp. + */ + @Test + public void teleportWaitsForAColdDestinationWithinTheTeleportDeadline() { + WorldView world = mock(WorldView.class); + doAnswer((InvocationOnMock invocation) -> { + invocation.getArgument(0, Runnable.class).run(); + return null; + }).when(world).execute(any()); + UUID opener = UUID.randomUUID(); + AtomicReference requested = new AtomicReference<>(); + when(world.teleport(eq(opener), any())).thenAnswer((InvocationOnMock invocation) -> { + requested.set(invocation.getArgument(1)); + return Optional.empty(); + }); + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(130); + + long before = System.nanoTime(); + new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener)).teleport(-3.5D, 17.25D); + long after = System.nanoTime(); + + WorldView.Destination destination = requested.get(); + assertEquals(-3.5D, destination.x(), 0D); + assertEquals(68D, destination.y(), 0D); + assertEquals(17.5D, destination.z(), 0D); + assertTrue("deadline must outlast a cold first chunk", + destination.deadlineNanos() - before >= TimeUnit.SECONDS.toNanos(60L)); + assertTrue("deadline must be bounded", destination.deadlineNanos() - after <= TimeUnit.SECONDS.toNanos(120L)); + } +} From b1fbede36404bcae6c95d1f4425df151cc78ed7f Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 08:44:07 -0400 Subject: [PATCH 17/19] Maintenance after evacuation - Mantle-bypass maintenance starts only after players leave the world - This covers studio close, studio disable, world remove, world unload and create rollback - Maintenance ends only when it was entered - Folia no longer treats a refused chunk requested during evacuation as a chunk system crash --- .../art/arcane/iris/command/CommandIris.java | 14 +++++-- .../art/arcane/iris/studio/StudioSVC.java | 4 +- .../studio/object/StudioOpenCoordinator.java | 18 +++++++-- .../art/arcane/iris/world/IrisCreator.java | 13 +++++-- .../lifecycle/IrisWorldRemovalService.java | 22 ++++++++--- ...tudioOpenCoordinatorCloseSequenceTest.java | 38 ++++++++++++++++--- .../IrisWorldRemovalServiceTest.java | 16 +++++++- 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java index f3c15c99b..1c09dfd01 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java @@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor { return; } PlatformChunkGenerator generator = IrisToolbelt.access(world); - IrisToolbelt.beginWorldMaintenance(world, "world-unload", true); + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + // system failure, so it starts only once no player is left to request one. + AtomicBoolean maintenanceEntered = new AtomicBoolean(false); try { AtomicBoolean terminalTimeout = new AtomicBoolean(false); CompletableFuture sequence = IrisToolbelt.evacuateAsync(world) @@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor { if (!Boolean.TRUE.equals(evacuated)) { return CompletableFuture.completedFuture(false); } + IrisToolbelt.beginWorldMaintenance(world, "world-unload", true); + maintenanceEntered.set(true); return WorldLifecycleService.get().unloadAsync(world, true); }) .thenCompose(unloaded -> { @@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor { }); guardUnloadCompletion(sequence, terminalTimeout, world.getName()) .whenComplete((unloaded, throwable) -> { - IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + if (maintenanceEntered.get()) { + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + } lease.close(); Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable); if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { @@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor { J.s(response); }); } catch (Exception e) { - IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + if (maintenanceEntered.get()) { + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + } lease.close(); responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index cd43d7c16..9ebc47227 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -1098,12 +1098,14 @@ public class StudioSVC implements IrisService { } private void destroyStudioWorld(World world, PlatformChunkGenerator generator) { - IrisToolbelt.beginWorldMaintenance(world, "studio-disable", true); try { IrisToolbelt.evacuate(world); } catch (Throwable e) { IrisLogging.reportError("Failed to evacuate studio world \"" + world.getName() + "\" during shutdown cleanup.", e); } + // Entered after the evacuation: maintenance that forbids mantle stages refuses every chunk, and Folia + // treats a refused chunk as a chunk system failure. + IrisToolbelt.beginWorldMaintenance(world, "studio-disable", true); try { WorldLifecycleService.get().unloadAsync(world, false) .thenCompose(unloaded -> { diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java index 4acd70858..95985aeac 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java @@ -459,12 +459,14 @@ public final class StudioOpenCoordinator { ) { AtomicBoolean unloadConfirmed = new AtomicBoolean(false); AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder); - if (world != null) { - IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); - } CompletableFuture sequence = sequenceStudioClose( () -> evacuateWorldFamily(worldName, world), + () -> { + if (world != null) { + IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); + } + }, () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> deleteFolder @@ -491,14 +493,22 @@ public final class StudioOpenCoordinator { }); } + /** + * Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + * system failure, so it is entered only once no player is left to request one. + */ static CompletableFuture sequenceStudioClose( Supplier> evacuate, + Runnable enterMaintenance, Supplier> unload, Supplier> closeGenerator, Supplier> deleteFolders ) { return invokePhase(evacuate) - .thenCompose(ignored -> invokePhase(unload)) + .thenCompose(ignored -> { + enterMaintenance.run(); + return invokePhase(unload); + }) .thenCompose(ignored -> invokePhase(closeGenerator)) .thenCompose(ignored -> invokePhase(deleteFolders)); } diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java index e8d90b201..2040c60a9 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java @@ -756,12 +756,17 @@ public class IrisCreator { } } if (activeWorld != null) { - IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); + boolean maintenanceEntered = false; try { PlatformChunkGenerator generator = IrisToolbelt.access(activeWorld); boolean evacuated = Boolean.TRUE.equals(IrisToolbelt.evacuateAsync(activeWorld) .get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)); - if (!evacuated) { + if (evacuated) { + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk + // as a chunk system failure, so it starts only once no player is left to request one. + IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); + maintenanceEntered = true; + } else { safeToDelete = false; failure.addSuppressed(new IllegalStateException( "Rollback could not evacuate world \"" + name + "\".")); @@ -789,7 +794,9 @@ public class IrisCreator { ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\"."); } } finally { - IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true); + if (maintenanceEntered) { + IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true); + } } } diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java index 81ffb3db5..bbc80f374 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java @@ -152,15 +152,21 @@ public final class IrisWorldRemovalService { boolean deleteFiles, AtomicBoolean terminal ) { + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + // system failure, so it starts only once no player is left to request one. + AtomicBoolean maintenanceEntered = new AtomicBoolean(false); CompletableFuture operation = phase( - RemovalStatus.RESOLUTION_FAILED, - () -> backend.beginMaintenance(resolved).thenApply(ignored -> resolved), + RemovalStatus.TELEPORT_FAILED, + () -> backend.evacuatePlayers(resolved).thenApply(ignored -> resolved), terminal ) .thenCompose(activeWorld -> phase( - RemovalStatus.TELEPORT_FAILED, - () -> backend.evacuatePlayers(activeWorld) - .thenApply(ignored -> activeWorld), + RemovalStatus.RESOLUTION_FAILED, + () -> { + CompletableFuture begun = backend.beginMaintenance(activeWorld); + maintenanceEntered.set(true); + return begun.thenApply(ignored -> activeWorld); + }, terminal )) .thenCompose(activeWorld -> phase( @@ -198,7 +204,7 @@ public final class IrisWorldRemovalService { ); })); - return operation.handle((result, throwable) -> backend.endMaintenance(resolved) + return operation.handle((result, throwable) -> endMaintenance(resolved, maintenanceEntered.get()) .handle((ignored, cleanupFailure) -> { if (cleanupFailure != null) { Throwable unwrappedCleanup = unwrap(cleanupFailure); @@ -216,6 +222,10 @@ public final class IrisWorldRemovalService { })).thenCompose(result -> result); } + private CompletableFuture endMaintenance(ResolvedWorld resolved, boolean entered) { + return entered ? backend.endMaintenance(resolved) : CompletableFuture.completedFuture(null); + } + private CompletableFuture validateResolvedWorld(ResolvedWorld resolvedWorld) { if (!resolvedWorld.managed()) { return CompletableFuture.failedFuture(new RemovalFailure( diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java index d093be1b1..0ff45e3d0 100644 --- a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java @@ -24,12 +24,13 @@ public class StudioOpenCoordinatorCloseSequenceTest { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> phase(phases, "unload"), () -> phase(phases, "close-generator"), () -> phase(phases, "delete-folders") ).join(); - assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); } @Test @@ -40,6 +41,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { try { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> { phases.add("unload"); return CompletableFuture.failedFuture(failure); @@ -52,7 +54,31 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload"), phases); + } + + @Test + public void evacuationFailureNeverEntersMaintenance() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("evacuation failed"); + + try { + StudioOpenCoordinator.sequenceStudioClose( + () -> { + phases.add("evacuate"); + return CompletableFuture.failedFuture(failure); + }, + () -> phases.add("maintenance"), + () -> phase(phases, "unload"), + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders") + ).join(); + fail("Expected evacuation failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + + assertEquals(List.of("evacuate"), phases); } @Test @@ -63,6 +89,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { try { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> phase(phases, "unload"), () -> { phases.add("close-generator"); @@ -75,7 +102,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "unload", "close-generator"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator"), phases); } @Test @@ -87,6 +114,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { MockedStatic logging = mockStatic(IrisLogging.class)) { CompletableFuture close = StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> { phases.add("unload"); return unload; @@ -98,13 +126,13 @@ public class StudioOpenCoordinatorCloseSequenceTest { warning.get().run(); assertFalse(close.isDone()); assertFalse(unload.isDone()); - assertEquals(List.of("evacuate", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload"), phases); configurator.verifyNoInteractions(); unload.complete(null); close.join(); - assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); configurator.verifyNoInteractions(); } } diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java index a09448002..702768a96 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java @@ -67,12 +67,26 @@ public class IrisWorldRemovalServiceTest { assertTrue(result.succeeded()); assertTrue(result.registryChanged()); assertEquals( - List.of("resolve", "begin", "evacuate", "unload", "close", "unregister", "unregisterRegistry", "end"), + List.of("resolve", "evacuate", "begin", "unload", "close", "unregister", "unregisterRegistry", "end"), backend.operations ); assertTrue(coordinator.isIdle()); } + @Test + public void mantleBypassMaintenanceStartsOnlyAfterPlayersLeave() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("evacuation-failed")); + backend.evacuation = CompletableFuture.failedFuture(new IllegalStateException("no destination world")); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("evacuation-failed", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.TELEPORT_FAILED, result.status()); + assertEquals(List.of("resolve", "evacuate"), backend.operations); + assertTrue(coordinator.isIdle()); + } + @Test public void configurationFailureStopsBeforeDeletionAndRetainsCause() throws Exception { LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); From c7cca231550cfb03bbc67e8259212882d27d8acb Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 08:44:07 -0400 Subject: [PATCH 18/19] Startup refusal fixes - Bootstrap audits vanilla slots that hold an Iris world - Startup stops when such a slot has unusable history - Startup also stops when bukkit.yml no longer binds such a slot to Iris - The audit detail carries the history read cause - File system failures are named by type - Bootstrap failures print the refusal banner instead of a bare stack trace - A vanilla slot holding an Iris world is told to restore its history and keep its binding - The vanilla slot remedy adds deleting level.dat when the level was never created - A failed enable locks only for bukkit.yml worlds the server loads --- .../art/arcane/iris/FailedEnableLock.java | 2 +- .../java/art/arcane/iris/IrisBootstrap.java | 54 +++++++++--- .../world/IrisWorldGeneratorResolver.java | 41 ++++++++- .../art/arcane/iris/FailedEnableLockTest.java | 6 +- .../IrisBootstrapFailureContractTest.java | 27 ++++++ ...IrisBootstrapWorldStorageContractTest.java | 75 ++++++++++++++++ .../world/IrisWorldGeneratorResolverTest.java | 70 +++++++++++++++ .../arcane/iris/world/IrisWorldStorage.java | 43 +++++++++ .../lifecycle/BukkitWorldConfiguration.java | 87 +++++++++++++++---- .../world/lifecycle/HuskWorldQuarantine.java | 43 +-------- .../safeguard/GenerationRefusalNotice.java | 10 ++- .../BukkitWorldConfigurationTest.java | 56 ++++++++++-- .../GenerationRefusalNoticeTest.java | 15 ++++ 13 files changed, 448 insertions(+), 81 deletions(-) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java index cc332873f..426ac062d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -83,6 +83,6 @@ final class FailedEnableLock { static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException { return IrisWorldStorage.hasManagedWorldStorage(levelRoot) - || BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration); + || BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath()); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 0c40281bc..642b0d235 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion; import io.papermc.paper.ServerBuildInfo; import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult; import art.arcane.iris.platform.bootstrap.SlimJar; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.PluginBootstrap; import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent; @@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -115,7 +117,9 @@ public final class IrisBootstrap implements PluginBootstrap { * whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world * it cannot generate with a fail-closed generator, which stops startup at that world's own level creation, * after the levels before it have loaded. A pack snapshot that has gone missing is found here instead, - * before any level is created, exactly as a corrupt {@code dimensions/.json} is. + * before any level is created, exactly as a corrupt {@code dimensions/.json} is. An Iris world in a + * vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it + * with the vanilla generator without ever asking Iris. *

* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and * its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither @@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap { levelRoot ); StringBuilder unusable = new StringBuilder(); + StringBuilder unbound = new StringBuilder(); for (IrisWorldStorageEntry entry : entries) { switch (entry.state()) { case PRESENT -> { @@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap { .append(": ") .append(entry.detail() == null ? "storage is unusable" : entry.detail()) .append(')'); + case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ") + .append(entry.configuredWorldName()) + .append(" (") + .append(entry.storagePath()) + .append("): set worlds.") + .append(entry.configuredWorldName()) + .append(".generator to Iris: in bukkit.yml, or move the folder aside to start a new" + + " vanilla world there"); } } - if (unusable.isEmpty()) { - return; + List refusals = new ArrayList<>(2); + if (!unusable.isEmpty()) { + refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: " + + unusable + ". Restore each world folder from a backup, or move it aside; a world with no" + + " folder is reported and skipped, and the server starts."); + } + if (!unbound.isEmpty()) { + refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla" + + " terrain into them: " + unbound + "."); + } + if (!refusals.isEmpty()) { + throw new IllegalStateException(String.join(" ", refusals)); } - throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla" - + " terrain over it: " + unusable - + ". Restore each world folder from a backup, or move it aside; a world with no folder is" - + " reported and skipped, and the server starts."); } static void armStartupFailure(BootstrapContext context, Throwable failure) { @@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap { context.getLifecycleManager().registerEventHandler(eventType, event -> { throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure); }); + List notice = startupRefusalNotice(failure); try { - context.getLogger().error( - "Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.", - failure - ); + for (String line : notice) { + context.getLogger().error(line); + } + context.getLogger().warn("Iris bootstrap failure", failure); } catch (Throwable loggingFailure) { + notice.forEach(System.err::println); failure.addSuppressed(loggingFailure); failure.printStackTrace(System.err); } } + static List startupRefusalNotice(Throwable failure) { + return GenerationRefusalNotice.compose( + "Iris stopped server startup before any world loads", + GenerationRefusalNotice.causes(failure), + List.of( + "Startup stops at datapack discovery: no world loads and no chunks are written.", + "Fix the cause, then start the server again. Paper's --safeMode hint does not apply:" + + " Iris worlds must not load without Iris." + )); + } + private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) { try { return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index b4f4aeb3c..9fe77c6a3 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; +import java.util.stream.Stream; /** * Pack validation, dimension lookup, and the world generator / biome provider resolution that the @@ -622,12 +623,21 @@ public final class IrisWorldGeneratorResolver { } NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) { + if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) { + throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose" + + " generation history is missing from " + + GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot() + + ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName + + ".generator in bukkit.yml: without it the server generates vanilla terrain into this world."); + } // Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run // until that binding is gone. throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it." - + " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run" - + " /iris replace " + messageKey + " type= to replace it with Iris on the next restart."); + + " Remove worlds." + worldName + ".generator from bukkit.yml" + + leftoverLevelDataRemoval(levelRoot) + + ", start the server, then run /iris replace " + messageKey + + " type= to replace it with Iris on the next restart."); } throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") has no Iris world storage, so Iris cannot generate it." @@ -635,6 +645,33 @@ public final class IrisWorldGeneratorResolver { + " type=; Iris registers them with Multiverse itself."); } + /** + * A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start + * from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal. + */ + private static String leftoverLevelDataRemoval(File levelRoot) { + Path level = levelRoot.toPath(); + Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld"); + if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"), + LinkOption.NOFOLLOW_LINKS) + || hasEntries(overworld.resolve("region")) + || hasEntries(level.resolve("region"))) { + return ""; + } + return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)"; + } + + private static boolean hasEntries(Path directory) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(directory)) { + return entries.findAny().isPresent(); + } catch (IOException unreadable) { + return true; + } + } + private static boolean hasGenerationStorage(File dimensionRoot) { GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath()); return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS) diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java index 15039e517..25eaf2d03 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -183,13 +183,17 @@ public class FailedEnableLockTest { } @Test - public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { + public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception { File levelRoot = temporaryFolder.newFolder("level", "world"); File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml"); assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.removed_iris_world.generator", "Iris:overworld"); + yaml.save(bukkit); + assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + yaml.set("worlds.world.generator", "Iris:overworld"); yaml.save(bukkit); assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java index ea0e6a038..8e58e530c 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java @@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType; import net.kyori.adventure.text.logger.slf4j.ComponentLogger; import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisBootstrapFailureContractTest { @@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest { assertSame(cause, failure.getCause()); } + @Test + public void bootstrapFailureIsReportedWithTheRefusalBanner() { + BootstrapContext context = mock(BootstrapContext.class); + ComponentLogger logger = mock(ComponentLogger.class); + when(context.getLogger()).thenReturn(logger); + when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager()); + IllegalStateException failure = new IllegalStateException("Iris world storage is unusable", + new IOException("Invalid generation manifest: manifest.json")); + + IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType()); + + ArgumentCaptor lines = ArgumentCaptor.forClass(String.class); + verify(logger, atLeastOnce()).error(lines.capture()); + String banner = String.join("\n", lines.getAllValues()); + assertTrue(banner, banner.startsWith("=".repeat(78))); + assertTrue(banner, banner.contains("Cause: Iris world storage is unusable")); + assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json")); + assertTrue(banner, banner.contains("no chunks are written")); + assertTrue(banner, banner.contains("--safeMode")); + } + @SuppressWarnings("unchecked") private static LifecycleEventType lifecycleEventType() { return (LifecycleEventType) mock(LifecycleEventType.class); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java index 101e43b1d..78919ab63 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java @@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest { assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon")); } + /** + * A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken + * history stops startup before the level exists instead of reaching the level-load refusal. + */ + @Test + public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception { + Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data")); + Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region")); + Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + + assertTrue(failure.getMessage(), + failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld") + .toString())); + assertTrue(failure.getMessage(), failure.getMessage().contains("generation history")); + assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds.")); + } + + @Test + public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception { + Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath(); + Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation"); + Files.createDirectories(generation); + Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + + assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest")); + assertFalse(failure.getMessage(), failure.getMessage().contains("\n")); + } + + /** + * Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator, + * which writes vanilla terrain into it. + */ + @Test + public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception { + Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack")); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + + assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator")); + assertTrue(failure.getMessage(), + failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld") + .toString())); + } + + @Test + public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception { + Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region")); + Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain"); + Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris")); + Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot); + + IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)); + } + private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception { return BukkitStartupPaths.resolve(serverRoot, new String[0]); } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java index 59c2845c3..c945dcbbe 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java @@ -490,6 +490,76 @@ public class IrisWorldGeneratorResolverTest { } } + /** + * The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is + * gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it. + */ + @Test + public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception { + File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot"); + File levelRoot = new File(worldContainer, "world"); + File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld"); + assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs()); + assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs()); + assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world", "overworld")); + + assertTrue(refusal, refusal.contains("minecraft:overworld")); + assertTrue(refusal, refusal.contains("is an Iris world")); + assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath())); + assertFalse(refusal, refusal.contains("Remove worlds.world.generator")); + assertFalse(refusal, refusal.contains("/iris replace")); + } + } + + /** + * A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a + * level.dat that has no world generation settings next to it. + */ + @Test + public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception { + File worldContainer = temporaryFolder.newFolder("never-created"); + File levelRoot = new File(worldContainer, "world"); + assertTrue(levelRoot.mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world", "overworld")); + + assertTrue(refusal, refusal.contains("Remove worlds.world.generator")); + assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath())); + } + } + + @Test + public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception { + File worldContainer = temporaryFolder.newFolder("existing-vanilla"); + File levelRoot = new File(worldContainer, "world"); + File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat"); + assertTrue(settings.getParentFile().mkdirs()); + assertTrue(settings.createNewFile()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world", "overworld")); + + assertTrue(refusal, refusal.contains("Remove worlds.world.generator")); + assertFalse(refusal, refusal.contains("level.dat")); + } + } + @Test public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception { File worldContainer = temporaryFolder.newFolder("duplicate-key"); diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java index 98c4bec63..4ffc516bb 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java @@ -18,11 +18,14 @@ import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.stream.Stream; public final class IrisWorldStorage { private static final String IRIS_NAMESPACE = "iris"; private static final String DEFAULT_LEVEL_NAME = "world"; + private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); + private static final String APPLE_DOUBLE_PREFIX = "._"; /** * Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain @@ -327,6 +330,46 @@ public final class IrisWorldStorage { return target.toFile(); } + /** + * True when the world folder's {@code iris/} entry holds anything of Iris' own, which makes the folder an Iris + * world whatever its dimension namespace. + *

+ * The desktop file managers recreate a directory to hold their own metadata while a delete is still in + * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder + * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are + * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on + * the list all count as content. + */ + public static boolean holdsIrisContent(Path dimensionRoot) { + Path irisRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").resolve(IRIS_NAMESPACE); + if (Files.isSymbolicLink(irisRoot)) { + return true; + } + if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) { + return true; + } + try (Stream entries = Files.list(irisRoot)) { + return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry)); + } catch (IOException unreadable) { + return true; + } + } + + private static boolean isOperatingSystemMetadata(Path entry) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + Path name = entry.getFileName(); + if (name == null) { + return false; + } + String fileName = name.toString(); + return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX); + } + /** * True when the level root holds at least one Iris-managed world directory. Used by the failure paths * that have to decide whether letting the server continue would put vanilla terrain into Iris storage. diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java index a34f8feda..e3acf3413 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java @@ -4,6 +4,7 @@ import art.arcane.iris.world.IrisWorldStorage; import art.arcane.iris.world.WorldSlotKey; import art.arcane.iris.generation.runtime.IrisEngineMantle; import art.arcane.iris.world.history.GenerationHistoryPaths; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import org.bukkit.NamespacedKey; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -26,11 +27,16 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; public final class BukkitWorldConfiguration { private static final String DEFAULT_WORLD_CONTAINER = "."; private static final Object MUTATION_LOCK = new Object(); + private static final List VANILLA_SLOTS = List.of( + NamespacedKey.minecraft("overworld"), + NamespacedKey.minecraft("the_nether"), + NamespacedKey.minecraft("the_end")); private BukkitWorldConfiguration() { } @@ -160,7 +166,8 @@ public final class BukkitWorldConfiguration { } /** - * Classifies the world storage behind every Iris entry in bukkit.yml. + * Classifies the world storage behind every Iris entry in bukkit.yml, and behind every vanilla slot of the level + * that holds an Iris world, bound or not. *

* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops * anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose @@ -178,22 +185,34 @@ public final class BukkitWorldConfiguration { Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot") .toAbsolutePath() .normalize(); - Path configurationPath = requiredConfigurationFile.toPath(); - if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { - return List.of(); - } Path worldContainer = requiredLevelRoot.getParent(); if (worldContainer == null) { throw new IOException("Selected level root has no world container: " + requiredLevelRoot); } synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = Files.exists(requiredConfigurationFile.toPath(), LinkOption.NOFOLLOW_LINKS) + ? load(requiredConfigurationFile) + : new YamlConfiguration(); List entries = new ArrayList<>(); - for (IrisWorldCandidate candidate : readIrisWorldCandidates( - load(requiredConfigurationFile), - requiredLevelName - )) { + for (IrisWorldCandidate candidate : readIrisWorldCandidates(configuration, requiredLevelName)) { entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate)); } + for (NamespacedKey vanillaSlot : VANILLA_SLOTS) { + Path dimensionRoot = expectedDimensionRoot(requiredLevelRoot, vanillaSlot); + if (!IrisWorldStorage.holdsIrisContent(dimensionRoot)) { + continue; + } + String configuredName = IrisWorldStorage.configuredWorldName(vanillaSlot, requiredLevelName); + if (configuration.get("worlds." + configuredName + ".generator") instanceof String generator + && isIrisGenerator(generator.trim())) { + entries.add(classifyStorage(worldContainer, requiredLevelRoot, + new IrisWorldCandidate(configuredName, vanillaSlot, generator.trim()))); + } else { + entries.add(new IrisWorldStorageEntry(configuredName, + new WorldSlotKey(vanillaSlot.getNamespace(), vanillaSlot.getKey()), dimensionRoot, + IrisWorldStorageState.UNBOUND, "bukkit.yml does not name Iris as its generator")); + } + } return List.copyOf(entries); } } @@ -249,12 +268,21 @@ public final class BukkitWorldConfiguration { hasWorldData(resolvedRoot) ? IrisWorldStorageState.UNUSABLE : IrisWorldStorageState.EMPTY, - unusablePack.getMessage()); + describe(unusablePack)); } return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot, IrisWorldStorageState.PRESENT, null); } + /** + * The cause chain on one line: why a history cannot be read sits below the failure that names the world. + */ + private static String describe(Throwable failure) { + return GenerationRefusalNotice.causes(failure).stream() + .map((String cause) -> cause.lines().findFirst().orElse(cause)) + .collect(Collectors.joining("; ")); + } + /** * True when the world folder holds something a vanilla generator could overwrite: an {@code iris/} * directory marking it as an Iris world whose pack snapshot broke, or stored chunk data. @@ -395,11 +423,19 @@ public final class BukkitWorldConfiguration { } /** - * True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin - * for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla. + * True when a bukkit.yml world the server loads names Iris as its generator: the level's overworld, nether or + * end, or a world whose dimension folder exists. CraftServer asks a plugin for a generator only for worlds it + * creates, so these are the ones a disabled Iris would hand to vanilla; an entry for a world that is never + * created is not. */ - public static boolean configuresIrisGenerator(File configurationFile) throws IOException { + public static boolean configuresLoadingIrisWorld(File configurationFile, Path levelRoot) throws IOException { Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath(); + Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); + Path levelName = root.getFileName(); + Path worldContainer = root.getParent(); + if (levelName == null || worldContainer == null) { + throw new IOException("Selected level root has no world container: " + root); + } if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { return false; } @@ -410,7 +446,8 @@ public final class BukkitWorldConfiguration { } for (String worldName : worlds.getKeys(false)) { if (worlds.get(worldName + ".generator") instanceof String generator - && isIrisGenerator(generator.trim())) { + && isIrisGenerator(generator.trim()) + && loadsAtStartup(worldContainer, root, levelName.toString(), worldName)) { return true; } } @@ -418,6 +455,21 @@ public final class BukkitWorldConfiguration { } } + private static boolean loadsAtStartup(Path worldContainer, Path levelRoot, String levelName, String worldName) { + if (worldName.equals(levelName) + || worldName.equals(levelName + "_nether") + || worldName.equals(levelName + "_the_end")) { + return true; + } + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName); + } catch (IllegalArgumentException unmappable) { + return false; + } + return hasDimensionDirectory(worldContainer, levelRoot, worldName, worldKey); + } + private static boolean isIrisGenerator(String configuredGenerator) { return configuredGenerator.equalsIgnoreCase("Iris") || configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5); @@ -766,7 +818,12 @@ public final class BukkitWorldConfiguration { * A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot * generate it and nothing else may. */ - UNUSABLE + UNUSABLE, + /** + * An Iris world in a vanilla slot whose bukkit.yml entry does not name Iris, so the server would build + * it with the vanilla generator. + */ + UNBOUND } public record IrisWorldStorageEntry( diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java index 6fc105721..89b6db1bd 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java @@ -16,7 +16,6 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -41,8 +40,6 @@ public final class HuskWorldQuarantine { private static final String[] CHUNK_DATA_DIRECTORIES = { "region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME }; - private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); - private static final String APPLE_DOUBLE_PREFIX = "._"; private static final DateTimeFormatter STAMP = DateTimeFormatter .ofPattern("yyyyMMdd'T'HHmmss") .withZone(ZoneOffset.UTC); @@ -117,7 +114,7 @@ public final class HuskWorldQuarantine { if (!isReadableDirectory(dimensionRoot)) { return false; } - if (holdsIrisContent(dimensionRoot.resolve("iris"))) { + if (IrisWorldStorage.holdsIrisContent(dimensionRoot)) { return false; } for (String directory : CHUNK_DATA_DIRECTORIES) { @@ -128,44 +125,6 @@ public final class HuskWorldQuarantine { return true; } - /** - * True when the {@code iris/} entry holds anything of Iris' own. - *

- * The desktop file managers recreate a directory to hold their own metadata while a delete is still in - * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder - * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are - * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on - * the list all count as content. - */ - private static boolean holdsIrisContent(Path irisRoot) { - if (Files.isSymbolicLink(irisRoot)) { - return true; - } - if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) { - return false; - } - if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) { - return true; - } - try (Stream entries = Files.list(irisRoot)) { - return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry)); - } catch (IOException unreadable) { - return true; - } - } - - private static boolean isOperatingSystemMetadata(Path entry) { - if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { - return false; - } - Path name = entry.getFileName(); - if (name == null) { - return false; - } - String fileName = name.toString(); - return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX); - } - /** * True when the path is a real directory that can actually be listed. The permission bits alone are not * the answer: what matters is whether Iris can see everything in it before deciding it holds nothing. diff --git a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java index d182d42f1..b10741189 100644 --- a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java +++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java @@ -1,5 +1,6 @@ package art.arcane.iris.world.safeguard; +import java.nio.file.FileSystemException; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; @@ -31,7 +32,8 @@ public final class GenerationRefusalNotice { /** * Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause - * (CompletionException, a rethrow with the same text) adds nothing and is skipped. + * (CompletionException, a rethrow with the same text) adds nothing and is skipped. A file system failure carries + * only a path, so its type is named with it. */ public static List causes(Throwable failure) { List causes = new ArrayList<>(); @@ -42,7 +44,11 @@ public final class GenerationRefusalNotice { String message = current.getMessage(); boolean repeatsCause = cause != null && cause.toString().equals(message); if (!repeatsCause) { - String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim(); + String text = message == null || message.isBlank() + ? current.getClass().getSimpleName() + : current instanceof FileSystemException + ? current.getClass().getSimpleName() + ": " + message.trim() + : message.trim(); if (causes.isEmpty() || !causes.getLast().equals(text)) { causes.add(text); } diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java index 31fc03985..d76dc9250 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java @@ -54,11 +54,13 @@ public class BukkitWorldConfigurationTest { } /** - * CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla - * slot included - is one a disabled Iris would hand to the vanilla generator. + * CraftServer only asks a plugin for a generator when bukkit.yml names it, so a world bound to Iris - a vanilla + * slot included - is one a disabled Iris would hand to the vanilla generator. An entry for a world the server + * never loads is not. */ @Test - public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception { + public void detectsIrisBindingsOnlyForWorldsTheServerLoads() throws Exception { + Path levelRoot = temporaryFolder.newFolder("loading", "world").toPath(); File overworld = temporaryFolder.newFile("iris-overworld.yml"); YamlConfiguration yaml = new YamlConfiguration(); yaml.set("worlds.world.generator", "iris:overworld"); @@ -73,11 +75,22 @@ public class BukkitWorldConfigurationTest { yaml.set("worlds.world.generator", "Irish:overworld"); yaml.set("worlds.world_nether.seed", 1337L); yaml.save(other); + File leftovers = temporaryFolder.newFile("leftovers.yml"); + yaml = new YamlConfiguration(); + yaml.set("worlds.oldiris.generator", "Iris:overworld"); + yaml.set("worlds.world_iris_gone.generator", "Iris:overworld"); + yaml.save(leftovers); - assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld)); - assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare)); - assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other)); - assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml"))); + assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(overworld, levelRoot)); + assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(bare, levelRoot)); + assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(other, levelRoot)); + assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld( + new File(temporaryFolder.getRoot(), "absent.yml"), levelRoot)); + assertFalse("no world folder, so the server never loads it", + BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot)); + + Files.createDirectories(levelRoot.resolve("dimensions/iris/gone")); + assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot)); } @Test @@ -205,6 +218,35 @@ public class BukkitWorldConfigurationTest { assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history")); } + /** + * A vanilla slot holding an Iris world is audited like any Iris world while bukkit.yml binds it to Iris, and is + * reported unbound when it does not, since the server would then build it with the vanilla generator. + */ + @Test + public void auditCoversVanillaSlotsThatHoldIrisWorlds() throws Exception { + File configuration = temporaryFolder.newFile("audit-vanilla-slots.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-vanilla-slots", "world").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/minecraft/overworld/iris/engine-data")); + Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_nether/iris/pack")); + Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_end/region")); + Files.writeString(levelRoot.resolve("dimensions/minecraft/the_end/region/r.0.0.mca"), "terrain"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "Iris:overworld"); + yaml.set("worlds.world_the_end.generator", "Iris:overworld"); + yaml.save(configuration); + + List audited = + BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot); + + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world")); + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNBOUND, stateOf(audited, "world_nether")); + assertEquals( + levelRoot.resolve("dimensions/minecraft/the_nether").toAbsolutePath().normalize(), + entryOf(audited, "world_nether").storagePath()); + assertFalse("a vanilla world bound to Iris is the generator resolver's refusal", + audited.stream().anyMatch(entry -> entry.configuredWorldName().equals("world_the_end"))); + } + @Test public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception { File configuration = temporaryFolder.newFile("audit-file-slot.yml"); diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java index fed73554b..057963b3a 100644 --- a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java +++ b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.world.safeguard; import org.junit.Test; import java.io.IOException; +import java.nio.file.NoSuchFileException; import java.util.List; import java.util.concurrent.CompletionException; @@ -33,6 +34,20 @@ public class GenerationRefusalNoticeTest { new IllegalStateException("history drifted", root))); } + /** + * A file system failure's message is only the path, which says nothing about what went wrong with it. + */ + @Test + public void fileSystemFailuresAreNamedByTheirType() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new NoSuchFileException("/srv/moon/iris/generation/epochs/a/epoch.json")); + + assertEquals(List.of( + "Iris generation history is unusable at /srv/moon.", + "NoSuchFileException: /srv/moon/iris/generation/epochs/a/epoch.json" + ), GenerationRefusalNotice.causes(failure)); + } + @Test public void aCyclicCauseChainTerminates() { IllegalStateException first = new IllegalStateException("first"); From 94c179fbd4351b7c7ff73b2a777e21329839d87c Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 08:44:08 -0400 Subject: [PATCH 19/19] Fabric original biomes - Capture original biomes at Fabric mod init so generated biome definitions exclude Fabric biome modifications - Test the generated definition against a biome modified in place the way Fabric does it --- .../iris/fabric/IrisFabricBootstrap.java | 1 + .../NativeFabricBiomeDefinitionTest.java | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java index ed8a29270..2357ced88 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java @@ -17,6 +17,7 @@ public final class IrisFabricBootstrap implements ModInitializer { public void onInitialize() { NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions( "irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe)); + loader.captureOriginalBiomes(); NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION; NativeFabricPackSources.bind(ModdedForcedDatapack::repositorySource); ModdedEngineBootstrap.bootCommon(loader, "Fabric", () -> NativeFabricBootstrap.registerGenerator(generator)); diff --git a/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java b/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java new file mode 100644 index 000000000..85fd92a73 --- /dev/null +++ b/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java @@ -0,0 +1,108 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.fabric; + +import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions; +import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions; +import com.google.gson.JsonParser; +import com.mojang.serialization.JsonOps; +import com.mojang.serialization.Lifecycle; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.MappedRegistry; +import net.minecraft.core.RegistrationInfo; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryOps; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.carver.WorldCarver; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class NativeFabricBiomeDefinitionTest { + private static final String BIOME_REGISTRY = "minecraft:worldgen/biome"; + private static final String BIOME_KEY = "iris:biomes/fabric_modified"; + private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4," + + "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}"; + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception { + MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable()); + Holder.Reference ore = features.register( + ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")), + new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()), + RegistrationInfo.BUILT_IN); + features.freeze(); + MappedRegistry carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable()); + carvers.freeze(); + MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable()); + RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers)); + Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access), + JsonParser.parseString(SOURCE_JSON)).getOrThrow(); + NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller", + BooleanSupplier::getAsBoolean)); + loader.recordOriginalBiome(source); + Holder.Reference biome = biomes.register( + ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN); + biomes.freeze(); + addFeatureInPlace(biome.value().getGenerationSettings(), ore); + warmInPlace(biome.value()); + NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome); + + assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F); + assertTrue(biome.value().getGenerationSettings().features() + .get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore)); + assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()), + JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value())); + } + + /** + * What a Fabric biome modification that adds an ore does: it replaces the feature list of the registered biome's + * own generation settings. + */ + private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder feature) + throws ReflectiveOperationException { + List> steps = new ArrayList<>(settings.features()); + while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) { + steps.add(HolderSet.direct()); + } + steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature)); + Field features = BiomeGenerationSettings.class.getDeclaredField("features"); + features.setAccessible(true); + features.set(settings, List.copyOf(steps)); + } + + /** + * What a Fabric weather modification does: it replaces the registered biome's climate settings. + */ + private static void warmInPlace(Biome biome) throws ReflectiveOperationException { + Constructor climate = Biome.ClimateSettings.class.getDeclaredConstructor( + boolean.class, float.class, Biome.TemperatureModifier.class, float.class); + climate.setAccessible(true); + Field climateSettings = Biome.class.getDeclaredField("climateSettings"); + climateSettings.setAccessible(true); + climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F)); + } +}