diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 3a5ae6c9d..890b776d9 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -114,6 +114,7 @@ dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') // registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris: // fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets // IrisFabricBootstrap register the CHUNK_GENERATOR codec in diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 67df127f7..7991cfed5 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -131,6 +131,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly(libs.spigot) { transitive = false 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 1f9a15586..d72be750e 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 @@ -331,34 +331,113 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (server == null) { return null; } - // Snapshot, never server.getAllLevels(): this runs off the server thread from data queries. - for (ServerLevel level : ModdedServerLevels.levels(server)) { + ServerLevel resolved = resolveBoundLevel(server, ModdedServerLevels.levels(server)); + if (resolved != null) { + boundLevel = resolved; + } + return resolved; + } + + ServerLevel resolveBoundLevel(MinecraftServer server, List snapshot) { + for (ServerLevel level : snapshot) { if (level.getChunkSource().getGenerator() == this) { - boundLevel = level; return level; } } - return null; + ServerLevel overworld = server.getLevel(Level.OVERWORLD); + return overworld != null && overworld.getChunkSource().getGenerator() == this ? overworld : null; } Engine engine() { - requireBindingAllowed(); - Engine cached = engine; - requireCompletedShutdown(cached); - if (cached != null && !cached.isClosed()) { + Engine cached = readyEngine(); + if (cached != null) { return cached; } ServerLevel level = boundLevel(); if (level == null) { throw new IllegalStateException("Iris generator '" + dimensionKey + "' has no bound ServerLevel yet"); } - return bindEngine(level); + return bindGenerationLevel(level); + } + + private Engine engine(ResourceKey levelKey) { + Engine cached = readyEngine(); + if (cached != null) { + return cached; + } + ServerLevel level = boundLevel; + if (level == null) { + level = requirePublishedLevel(ModdedEngineBootstrap.currentServer(), levelKey); + } else { + requireGeneratorLevel(level, levelKey); + } + return bindGenerationLevel(level); + } + + private Engine engine(ServerLevel generationLevel) { + Engine cached = readyEngine(); + if (cached != null) { + return cached; + } + ServerLevel level = boundLevel == null ? generationLevel : boundLevel; + requireGeneratorLevel(level, generationLevel.dimension()); + return bindGenerationLevel(level); + } + + private Engine readyEngine() { + requireBindingAllowed(); + Engine cached = engine; + requireCompletedShutdown(cached); + if (cached != null && !cached.isClosed()) { + return cached; + } + return null; + } + + private Engine bindGenerationLevel(ServerLevel level) { + bindLevel(level); + Engine bound = readyEngine(); + if (bound == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' completed generation binding without a ready engine"); + } + return bound; + } + + ServerLevel requirePublishedLevel(MinecraftServer server, ResourceKey levelKey) { + if (server == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' cannot resolve level '" + levelKey.identifier() + "': server is unavailable"); + } + ServerLevel level = server.getLevel(levelKey); + if (level == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' has no published ServerLevel for '" + levelKey.identifier() + "'"); + } + requireGeneratorLevel(level, levelKey); + return level; + } + + private void requireGeneratorLevel(ServerLevel level, ResourceKey levelKey) { + if (!levelKey.equals(level.dimension())) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' resolved level '" + + level.dimension().identifier() + "' while binding '" + levelKey.identifier() + "'"); + } + ChunkGenerator publishedGenerator = level.getChunkSource().getGenerator(); + if (publishedGenerator != this) { + throw new IllegalStateException("Published ServerLevel '" + levelKey.identifier() + + "' does not use Iris generator '" + dimensionKey + "'"); + } } synchronized void bindLevel(ServerLevel level) { if (level.getChunkSource().getGenerator() != this) { throw new IllegalArgumentException("ServerLevel does not use Iris generator '" + dimensionKey + "'"); } + Engine current = engineIfBound(); + if (boundLevel == level && current != null && current.getComplex() != null) { + return; + } requireCompletedShutdown(engine); unloading = false; Engine bound = bindEngine(level); @@ -853,7 +932,6 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { Engine current = engine(); - // Self-heal for an engine bound through a data-query path instead of bindLevel; a no-op once prepared. importedFeatures.prepare(current); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { @@ -867,7 +945,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { - Engine current = engine(); + Engine current = engine(levelKey); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_structures"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { Map previousStarts = new HashMap<>(chunk.getAllStarts()); @@ -891,7 +969,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void createReferences(WorldGenLevel level, StructureManager structureManager, ChunkAccess chunk) { - Engine current = engine(); + Engine current = engine(level.getLevel()); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { NativeStructureReferenceRepair.createReferences( diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationBootstrapBindingTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationBootstrapBindingTest.java new file mode 100644 index 000000000..e53978e84 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationBootstrapBindingTest.java @@ -0,0 +1,150 @@ +package art.arcane.iris.modded; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.ChunkGenerator; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +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 ModdedGenerationBootstrapBindingTest { + private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources"; + + @Test + public void publishedLevelRequiresCurrentGeneratorIdentity() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + MinecraftServer server = mock(MinecraftServer.class); + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + when(server.getLevel(Level.OVERWORLD)).thenReturn(level); + when(level.dimension()).thenReturn(Level.OVERWORLD); + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getGenerator()).thenReturn(generator); + + assertSame(level, generator.requirePublishedLevel(server, Level.OVERWORLD)); + + ChunkGenerator otherGenerator = mock(ChunkGenerator.class); + when(chunkSource.getGenerator()).thenReturn(otherGenerator); + IllegalStateException mismatch = assertThrows(IllegalStateException.class, + () -> generator.requirePublishedLevel(server, Level.OVERWORLD)); + assertTrue(mismatch.getMessage().contains("does not use Iris generator")); + } + + @Test + public void publishedLevelRejectsMissingServerAndWorld() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + IllegalStateException missingServer = assertThrows(IllegalStateException.class, + () -> generator.requirePublishedLevel(null, Level.OVERWORLD)); + assertTrue(missingServer.getMessage().contains("server is unavailable")); + + MinecraftServer server = mock(MinecraftServer.class); + IllegalStateException missingWorld = assertThrows(IllegalStateException.class, + () -> generator.requirePublishedLevel(server, Level.OVERWORLD)); + assertTrue(missingWorld.getMessage().contains("no published ServerLevel")); + } + + @Test + public void staleSnapshotResolvesOnlyCanonicalOverworldOwnedByGenerator() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + MinecraftServer server = mock(MinecraftServer.class); + ServerLevel overworld = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + when(server.getLevel(Level.OVERWORLD)).thenReturn(overworld); + when(overworld.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getGenerator()).thenReturn(generator); + + assertSame(overworld, generator.resolveBoundLevel(server, List.of())); + + ChunkGenerator otherGenerator = mock(ChunkGenerator.class); + when(chunkSource.getGenerator()).thenReturn(otherGenerator); + assertNull(generator.resolveBoundLevel(server, List.of())); + verify(server, never()).getLevel(Level.NETHER); + } + + @Test + public void dynamicLevelStillResolvesFromSnapshotWithoutCanonicalLookup() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + MinecraftServer server = mock(MinecraftServer.class); + ServerLevel dynamicLevel = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + when(dynamicLevel.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getGenerator()).thenReturn(generator); + + assertSame(dynamicLevel, generator.resolveBoundLevel(server, List.of(dynamicLevel))); + verify(server, never()).getLevel(Level.OVERWORLD); + } + + @Test + public void firstGenerationStagesProvideAuthoritativeLevelContext() throws IOException { + String source = source("IrisModdedChunkGenerator.java"); + String structures = method(source, "public void createStructures("); + String references = method(source, "public void createReferences("); + assertTrue(structures.contains("Engine current = engine(levelKey);")); + assertTrue(references.contains("Engine current = engine(level.getLevel());")); + + String publishedBinding = method(source, "private Engine engine(ResourceKey levelKey)"); + assertTrue(publishedBinding.contains("requirePublishedLevel(ModdedEngineBootstrap.currentServer(), levelKey)")); + assertTrue(publishedBinding.contains("return bindGenerationLevel(level);")); + assertFalse(publishedBinding.contains("ModdedServerLevels.level(")); + + String fullBinding = method(source, "private Engine bindGenerationLevel(ServerLevel level)"); + assertTrue(fullBinding.contains("bindLevel(level);")); + assertTrue(fullBinding.contains("Engine bound = readyEngine();")); + + String genericBinding = method(source, "Engine engine()"); + assertTrue(genericBinding.contains("return bindGenerationLevel(level);")); + } + + @Test + public void constructorMixinIsNotPartOfGenerationBootstrap() throws IOException { + String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY); + assertTrue(sourceRoot != null && !sourceRoot.isBlank()); + Path root = Path.of(sourceRoot); + assertFalse(Files.exists(root.resolve(Path.of("art", "arcane", "iris", "modded", "mixin", + "ServerLevelBindingMixin.java")))); + String mixinConfig = Files.readString(root.getParent().resolve("resources/irisworldgen.entity.mixins.json")); + assertFalse(mixinConfig.contains("ServerLevelBindingMixin")); + } + + private String source(String fileName) throws IOException { + String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY); + assertTrue(sourceRoot != null && !sourceRoot.isBlank()); + return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName)); + } + + private String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue(start >= 0); + int body = source.indexOf('{', start); + assertTrue(body >= 0); + int depth = 0; + for (int index = body; index < source.length(); index++) { + char token = source.charAt(index); + if (token == '{') { + depth++; + } else if (token == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 3c3da576b..8f256eda9 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -105,6 +105,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, dependencies { testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2') testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2') compileOnly('org.slf4j:slf4j-api:2.0.17')