From 25c04c01574f09777e42f5e109fe8537651f773a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 2 Oct 2026 05:39:20 -0400 Subject: [PATCH] Integrate optional terrain transforms - Add versioned terrain providers that run before content placement. - Resolve transformed terrain heights, occupancy, and cave metadata through bounded caches. - Initialize native structures after binding the modded generation runtime. - Cover transform behavior, provider resolution, and structure binding with regression tests. --- .../iris/modded/IrisModdedChunkGenerator.java | 9 + .../iris/modded/ModdedMixinAuditTest.java | 1 + .../ModdedStructureBindingOrderTest.java | 145 +++++ .../mixin/NativeStructureBootstrapTest.java | 70 +++ .../mantle/CaveTerrainSnapshot.java | 31 ++ .../iris/generation/mantle/EngineMantle.java | 17 + .../iris/generation/mantle/MantleWriter.java | 27 + .../iris/generation/mode/ModeOverworld.java | 9 +- .../runtime/DimensionTerrainContext.java | 6 +- .../iris/generation/runtime/Engine.java | 16 + .../iris/generation/runtime/EngineMode.java | 6 +- .../iris/generation/runtime/IrisComplex.java | 113 +++- .../runtime/UpperDimensionContext.java | 2 +- .../generation/stage/IrisCarveModifier.java | 16 +- .../generation/terrain/IrisDimension.java | 3 + .../generation/terrain/Terrain3DColumn.java | 24 + .../transform/IrisTerrainTransform.java | 25 + .../transform/TerrainColumnTransformer.java | 6 + .../transform/TerrainTransformContext.java | 17 + .../transform/TerrainTransformProvider.java | 11 + .../transform/TerrainTransformRegistry.java | 62 +++ .../transform/TerrainTransformRuntime.java | 506 ++++++++++++++++++ .../terrain/transform/TerrainTransformer.java | 11 + .../world/history/FloatingBiomeOverlay.java | 7 + .../mantle/CaveTerrainSnapshotTest.java | 17 + .../ModeOverworldTerrainTransformTest.java | 67 +++ .../DimensionTerrainContextFallbackTest.java | 44 +- .../IrisComplexTerrainTransformTest.java | 341 ++++++++++++ .../runtime/UpperDimensionTransitionTest.java | 25 +- .../stage/IrisNaturalCaveScanTest.java | 77 ++- .../terrain/Terrain3DColumnTest.java | 21 + .../TerrainTransformRegistryTest.java | 107 ++++ .../TerrainTransformRuntimeTest.java | 338 ++++++++++++ .../schema/SchemaBuilderIdentityTest.java | 2 +- .../IrisStructureLocatorContractTest.java | 2 +- 35 files changed, 2160 insertions(+), 21 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureBindingOrderTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/NativeStructureBootstrapTest.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/IrisTerrainTransform.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainColumnTransformer.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformContext.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformProvider.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistry.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntime.java create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformer.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mode/ModeOverworldTerrainTransformTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTerrainTransformTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistryTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntimeTest.java 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 647aafd92..0bb4e4fc7 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 @@ -242,6 +242,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat this.announced.set(false); resetRuntimeCaches(); this.engineBinding.complete(replacement); + this.nativeGenerator.initializeStructureState(level); // Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off. // Never waits on a build owned by another thread: this method owns the generator monitor and the build // path can need it. @@ -442,12 +443,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } Engine current = engineIfBound(); if (NativeDimensionRuntime.sameWorld(boundLevel, level) && current != null && current.getComplex() != null) { + nativeGenerator.initializeStructureState(level); return; } requireCompletedShutdown(engine); unloading = false; Engine bound = bindEngine(level); nativeGenerator.installVolumeIndex(level, bound); + nativeGenerator.initializeStructureState(level); // Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for // the same reason as repointAndBind: this method owns the generator monitor. importedFeatures.prepareWithoutWaiting(bound); @@ -1097,6 +1100,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat return current.getDimensionStackContext() != null; } + @Override + public boolean terrainTransformed() { + Engine current = engine; + return current != null && current.getComplex().getTerrainTransform() != null; + } + @Override public int runtimeId(Engine current) { return current.getCacheID(); 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 index 97ef1c08c..2c09b79b2 100644 --- 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 @@ -42,6 +42,7 @@ public class ModdedMixinAuditTest { "\\[\\[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/ServerLevelStructureBootstrapMixin", "volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin", "volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess", "volmlib.entity.mixins.json/ChunkTerrainReceiptMixin", diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureBindingOrderTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureBindingOrderTest.java new file mode 100644 index 000000000..f9d473c89 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureBindingOrderTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.pack.loading.IrisData; +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 org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedStructureBindingOrderTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void initialBindingPublishesReadyEngineBeforeStartingStructureSearches() throws Exception { + NativeModdedChunkGenerator nativeGenerator = mock(NativeModdedChunkGenerator.class); + ModdedEngineBinding binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS); + IrisModdedChunkGenerator generator = generator(nativeGenerator, binding); + NativeWorld world = mock(NativeWorld.class); + NativeModdedServer server = mock(NativeModdedServer.class); + Engine engine = readyEngine(); + when(nativeGenerator.represents(world)).thenReturn(true); + when(server.generateStructures()).thenReturn(true); + doAnswer(invocation -> { + assertSame(engine, generator.awaitStructureEngine()); + assertSame(engine, binding.await("overworld")); + return null; + }).when(nativeGenerator).initializeStructureState(world); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(world)).thenReturn(server); + engines.when(() -> ModdedWorldEngines.get(eq(world), any(), any(), anyLong(), any())).thenReturn(engine); + + generator.bindLevel(world); + + verify(nativeGenerator).initializeStructureState(world); + } + } + + @Test + public void levelBindingStartsDeferredStructuresAfterAnEarlierDataQueryBoundTheEngine() throws Exception { + NativeModdedChunkGenerator nativeGenerator = mock(NativeModdedChunkGenerator.class); + ModdedEngineBinding binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS); + IrisModdedChunkGenerator generator = generator(nativeGenerator, binding); + NativeWorld world = mock(NativeWorld.class); + Engine engine = readyEngine(); + when(nativeGenerator.represents(world)).thenReturn(true); + when(world.nativeHandle()).thenReturn(new Object()); + field("engine").set(generator, engine); + field("boundLevel").set(generator, world); + binding.complete(engine); + doAnswer(invocation -> { + assertSame(engine, generator.awaitStructureEngine()); + assertSame(engine, binding.await("overworld")); + return null; + }).when(nativeGenerator).initializeStructureState(world); + + generator.bindLevel(world); + + verify(nativeGenerator).initializeStructureState(world); + } + + @Test + public void repointPublishesReplacementBindingBeforeStartingStructureSearches() throws Exception { + NativeModdedChunkGenerator nativeGenerator = mock(NativeModdedChunkGenerator.class); + ModdedEngineBinding binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS); + IrisModdedChunkGenerator generator = generator(nativeGenerator, binding); + NativeWorld world = mock(NativeWorld.class); + NativeModdedServer server = mock(NativeModdedServer.class); + Engine replacement = readyEngine(); + when(nativeGenerator.represents(world)).thenReturn(true); + when(server.generateStructures()).thenReturn(true); + doAnswer(invocation -> { + assertSame(replacement, generator.awaitStructureEngine()); + assertSame(replacement, binding.await("overworld")); + return null; + }).when(nativeGenerator).initializeStructureState(world); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class)) { + servers.when(() -> NativeModdedServer.forWorld(world)).thenReturn(server); + engines.when(() -> ModdedWorldEngines.prepareReplacement(eq(world), eq("overworld"), + eq("overworld"), eq(42L), any())).thenReturn(replacement); + + generator.repointAndBind(world, "overworld", "overworld", 42L); + + verify(nativeGenerator).initializeStructureState(world); + } + } + + private static IrisModdedChunkGenerator generator(NativeModdedChunkGenerator nativeGenerator, + ModdedEngineBinding binding) throws Exception { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + field("nativeGenerator").set(generator, nativeGenerator); + field("engineBinding").set(generator, binding); + field("dimensionKey").set(generator, "overworld"); + field("generationMode").set(generator, ModdedGenerationMode.PERSISTENT_RESTORE); + field("announced").set(generator, new AtomicBoolean()); + field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class)); + return generator; + } + + private static Engine readyEngine() { + Engine engine = mock(Engine.class); + IrisData data = mock(IrisData.class); + when(engine.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engine.getData()).thenReturn(data); + when(data.getDataFolder()).thenReturn(new File("build/structure-binding-test/pack")); + when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class)); + return engine; + } + + 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/volmlib/nativelib/minecraft26_2/modded/mixin/NativeStructureBootstrapTest.java b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/NativeStructureBootstrapTest.java new file mode 100644 index 000000000..b5f718ae6 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/mixin/NativeStructureBootstrapTest.java @@ -0,0 +1,70 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.modded.mixin; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; +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 net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import org.junit.BeforeClass; +import org.junit.Test; + +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 NativeStructureBootstrapTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void levelConstructionDefersNativeStructureInitialization() { + ServerLevelStructureBootstrapMixin mixin = mock(ServerLevelStructureBootstrapMixin.class, CALLS_REAL_METHODS); + ServerChunkCache chunks = mock(ServerChunkCache.class); + NativeModdedChunkGenerator generator = mock(NativeModdedChunkGenerator.class); + ChunkGeneratorStructureState state = mock(ChunkGeneratorStructureState.class); + when(mixin.getChunkSource()).thenReturn(chunks); + when(chunks.getGenerator()).thenReturn(generator); + + mixin.iris$initializeStructureState(state); + + verify(state, never()).ensureStructuresGenerated(); + } + + @Test + public void levelConstructionStillInitializesVanillaStructures() { + ServerLevelStructureBootstrapMixin mixin = mock(ServerLevelStructureBootstrapMixin.class, CALLS_REAL_METHODS); + ServerChunkCache chunks = mock(ServerChunkCache.class); + ChunkGenerator generator = mock(ChunkGenerator.class); + ChunkGeneratorStructureState state = mock(ChunkGeneratorStructureState.class); + when(mixin.getChunkSource()).thenReturn(chunks); + when(chunks.getGenerator()).thenReturn(generator); + + mixin.iris$initializeStructureState(state); + + verify(state).ensureStructuresGenerated(); + } + + @Test + public void boundNativeWorldInitializesItsDeferredStructures() { + NativeModdedChunkGenerator generator = mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS); + NativeWorld world = mock(NativeWorld.class); + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + ChunkGeneratorStructureState state = mock(ChunkGeneratorStructureState.class); + when(world.nativeHandle()).thenReturn(level); + when(level.getChunkSource()).thenReturn(chunks); + when(chunks.getGeneratorState()).thenReturn(state); + + generator.initializeStructureState(world); + + verify(state).ensureStructuresGenerated(); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshot.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshot.java index a447c328c..d7b4a4bfc 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshot.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshot.java @@ -29,6 +29,7 @@ public final class CaveTerrainSnapshot { private final MatterCavern[][] caverns; private final HydrologyCaveCell[][] hydrology; private boolean hydrologyPresent; + private volatile long retainedBytes; private CaveTerrainSnapshot(int chunkX, int chunkZ, int sections) { this.chunkX = chunkX; @@ -63,6 +64,36 @@ public final class CaveTerrainSnapshot { return hydrologyPresent; } + public long estimatedRetainedBytes() { + long cached = retainedBytes; + if (cached != 0L) { + return cached; + } + long bytes = 80L + ((long) caverns.length + hydrology.length) * 8L; + for (MatterCavern[] section : caverns) { + if (section != null) { + bytes += 16L + (long) section.length * 8L; + for (MatterCavern cell : section) { + if (cell != null) { + bytes += 96L + (long) cell.getCustomBiome().length() * 2L; + } + } + } + } + for (HydrologyCaveCell[] section : hydrology) { + if (section != null) { + bytes += 16L + (long) section.length * 8L; + for (HydrologyCaveCell cell : section) { + if (cell != null) { + bytes += 208L + ((long) cell.fluidProfileKey().length() + cell.floodedBiomeKey().length()) * 2L; + } + } + } + } + retainedBytes = bytes; + return bytes; + } + public MatterCavern cavern(int x, int y, int z) { if (y < 0) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java index 96112c72f..e91bca1ef 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/EngineMantle.java @@ -28,6 +28,7 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveStorage; import art.arcane.iris.pack.value.IrisPosition; @@ -108,6 +109,12 @@ public interface EngineMantle extends MatterGenerator { } default int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + IrisComplex complex = getComplex(); + int transformed = complex == null || complex.getTerrainTransform() == null ? Integer.MIN_VALUE + : complex.transformedHeight(x, z, ignoreFluid); + if (transformed != Integer.MIN_VALUE) { + return transformed; + } return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getFluidHeight(x, z)); } @@ -116,6 +123,10 @@ public interface EngineMantle extends MatterGenerator { } default boolean isCarved(int x, int h, int z) { + Terrain3DColumn transformed = getComplex().transformedColumn(x, z); + if (transformed != null) { + return h >= 0 && h < transformed.topY() && !transformed.isSolid(h); + } HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(getMantle(), x, h, z); if (hydrology != null) { return hydrology.carves(); @@ -142,6 +153,12 @@ public interface EngineMantle extends MatterGenerator { } default int getFluidHeight(int x, int z) { + IrisComplex complex = getComplex(); + int transformed = complex == null || complex.getTerrainTransform() == null ? Integer.MIN_VALUE + : complex.transformedFluidHeight(x, z); + if (transformed != Integer.MIN_VALUE) { + return transformed; + } return (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); } 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 90b3ac9fb..7afe46959 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 @@ -582,6 +582,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public boolean isPrerequisiteCarved(int x, int y, int z) { + Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); + if (transformed != null) { + return y >= 0 && y < transformed.topY() && !transformed.isSolid(y); + } HydrologyCaveCell hydrology = getPrerequisiteDataIfPresent(x, y, z, HydrologyCaveCell.class); if (hydrology != null) { return hydrology.carves(); @@ -592,6 +596,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { public byte[] getPrerequisiteCarvedColumn(int x, int z, int height) { int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); + Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); + if (transformed != null) { + return carvedColumn(transformed, cappedHeight); + } byte[] carvedColumn = new byte[cappedHeight]; if (cappedHeight <= 0) { return carvedColumn; @@ -961,6 +969,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { + Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); + if (transformed != null) { + return y >= 0 && y < transformed.topY() && !transformed.isSolid(y); + } HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); if (hydrology != null) { return hydrology.carves(); @@ -972,6 +984,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { public byte[] getCarvedColumn(int x, int z, int height) { int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); + Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); + if (transformed != null) { + return carvedColumn(transformed, cappedHeight); + } byte[] carvedColumn = new byte[cappedHeight]; if (cappedHeight <= 0) { return carvedColumn; @@ -1589,6 +1605,17 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } } + private static byte[] carvedColumn(Terrain3DColumn terrain, int height) { + byte[] result = new byte[height]; + int maximumY = Math.min(height, terrain.topY()); + for (int y = 0; y < maximumY; y++) { + if (!terrain.isSolid(y)) { + result[y] = 1; + } + } + return result; + } + public final class ObjectPlacementCapture implements AutoCloseable { private final ObjectContinuationBundle.PlacementKey key; private final Long2ObjectOpenHashMap fragments = new Long2ObjectOpenHashMap<>(); 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 61216e507..743f897d4 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 @@ -31,6 +31,7 @@ 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.generation.context.ChunkContext; public class ModeOverworld extends IrisEngineMode implements EngineMode { public ModeOverworld(Engine engine) { @@ -45,7 +46,7 @@ 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) -> generateTerrainMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c); + EngineStage sGenMatter = (x, z, k, p, m, c) -> generateTerrainMatterForChunk(x >> 4, z >> 4, m, 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) -> cave.modify(x >> 4, z >> 4, k, m, c); @@ -82,4 +83,10 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { registerStage(sCustom); } } + + void generateTerrainMatterForChunk(int x, int z, boolean multicore, ChunkContext context) { + boolean studioParallel = getEngine().isStudio() + && !(context.isNaturalTerrain() && context.getComplex().getTerrainTransform() != null); + generateTerrainMatter(x, z, multicore || studioParallel, context); + } } 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 a5449c555..53b114a3c 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 @@ -125,8 +125,8 @@ public final class DimensionTerrainContext implements DataProvider { engine.getDimension(), engine.getData(), engine.getHeight(), - natural ? complex.getNaturalHeightStream() : complex.getHeightStream(), - natural ? complex.getNaturalSlopeStream() : complex.getSlopeStream(), + natural ? complex.getNaturalHeightStream() : complex.getRawHeightStream(), + natural ? complex.getNaturalSlopeStream() : complex.getRawSlopeStream(), fluidHeightStream, natural ? complex.getNaturalTrueBiomeStream() : complex.getTrueBiomeStream(), complex.getRegionStream(), @@ -653,7 +653,7 @@ public final class DimensionTerrainContext implements DataProvider { if (selfReferencing) { return naturalSelf || naturalFallback ? engine.getComplex().naturalTerrainColumn(x, z) - : engine.getComplex().terrainColumn(x, z); + : engine.getComplex().originalTerrainColumn(x, z); } if (terrain3D == null) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java index f017370d7..5c0c3728c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java @@ -567,6 +567,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, /** The terrain slope at a column, answered from natural terrain when the server thread may not wait. */ @BlockCoordinates default double getSlope(int x, int z) { + IrisComplex complex = getComplex(); + if (complex != null && complex.getTerrainTransform() != null) { + return complex.getSlopeStream().getDouble(x, z); + } if (answersFromNaturalTerrain(x, z)) { return getComplex().naturalSlope(x, z); } @@ -580,6 +584,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default int getHeight(int x, int z, boolean ignoreFluid) { + IrisComplex complex = getComplex(); + int transformed = complex == null || complex.getTerrainTransform() == null ? Integer.MIN_VALUE + : complex.transformedHeight(x, z, ignoreFluid); + if (transformed != Integer.MIN_VALUE) { + return transformed; + } DimensionStackContext dimensionStackContext = getDimensionStackContext(); if (dimensionStackContext != null) { return ignoreFluid @@ -595,6 +605,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates static int hostHeight(Engine engine, int x, int z, boolean ignoreFluid) { + IrisComplex complex = engine.getComplex(); + int transformed = complex == null || complex.getTerrainTransform() == null ? Integer.MIN_VALUE + : complex.transformedHeight(x, z, ignoreFluid); + if (transformed != Integer.MIN_VALUE) { + return transformed; + } EngineMantle mantle = engine.getMantle(); if (mantle == null) { return engine.getHeight(x, z, ignoreFluid); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java index df3f7a5cf..37f709620 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineMode.java @@ -213,7 +213,11 @@ public interface EngineMode extends Staged { EngineStage[] stages = getStages().toArray(new EngineStage[0]); try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), generationSessionId, ctx)) { - generateTerrain(x, z, blocks, biomes, multicore, ctx); + if (getComplex().getTerrainTransform() == null) { + generateTerrain(x, z, blocks, biomes, multicore, ctx); + } else { + getComplex().getTerrainTransform().generate(x, z, blocks, biomes, multicore, ctx); + } recordNaturalTerrain(x, z, blocks, biomes, ctx); IrisDimensionStackActuator.clearHostNaturalMetadata(getEngine(), x, z, biomes.getHeight(), ctx); ctx.beginContent(); 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 ffd5b3872..73b69f467 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 @@ -54,6 +54,10 @@ import art.arcane.iris.generation.noise.IrisGenerator; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRegistry; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRuntime; +import art.arcane.iris.generation.terrain.transform.TerrainTransformer; import art.arcane.iris.generation.noise.IrisInterpolator; import art.arcane.iris.generation.terrain.IrisMaterialPalette; import art.arcane.iris.generation.terrain.IrisRegion; @@ -185,6 +189,20 @@ public class IrisComplex implements DataProvider { private final ProceduralTerrainHeightSampler proceduralTerrainHeight; @Getter(AccessLevel.NONE) private final transient Engine terrainEngine; + @Setter(AccessLevel.NONE) + private final transient TerrainTransformRuntime terrainTransform; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ProceduralStream transformedHeightStream; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ProceduralStream transformedRoundedHeightStream; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ProceduralStream transformedTopStream; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ProceduralStream transformedSlopeStream; @Getter(AccessLevel.NONE) private transient Terrain3DRuntime terrain3D; private transient HydrologyBankTerrainRuntime hydrologyBanks3D; @@ -494,6 +512,25 @@ public class IrisComplex implements DataProvider { d.hashCode()); }), "", engine, cacheSize); //@done + TerrainTransformer transformer = engine.getDimension().getTerrainTransform() == null ? null + : TerrainTransformRegistry.discover().resolve(engine.getDimension().getTerrainTransform()); + terrainTransform = transformer == null ? null + : new TerrainTransformRuntime(new TerrainTransformRuntime.Options(engine, this), transformer); + transformedHeightStream = transformer == null ? null : ProceduralStream.ofDouble((x, z) -> { + int transformed = transformedHeight((int) Math.floor(x), (int) Math.floor(z), true); + return transformed == Integer.MIN_VALUE ? heightStream.getDouble(x, z) : transformed; + }); + transformedRoundedHeightStream = transformer == null ? null : ProceduralStream.ofDouble((x, z) -> { + int transformed = transformedHeight((int) Math.floor(x), (int) Math.floor(z), true); + return transformed == Integer.MIN_VALUE ? roundedHeighteightStream.get(x, z) : transformed; + }).round(); + transformedTopStream = transformer == null ? null : ProceduralStream.ofDouble((x, z) -> { + int transformed = transformedHeight((int) Math.floor(x), (int) Math.floor(z), false); + return transformed == Integer.MIN_VALUE ? heightFluidStream.getDouble(x, z) : transformed; + }); + ProceduralStream effectiveSlope = transformer == null ? null : transformedHeightStream.slope(3); + transformedSlopeStream = transformer == null ? null : ProceduralStream.ofDouble((x, z) -> + isNaturalTerrainContext() ? slopeStream.getDouble(x, z) : effectiveSlope.getDouble(x, z)); } /** @@ -689,6 +726,14 @@ public class IrisComplex implements DataProvider { } public Terrain3DColumn terrainColumn(int x, int z) { + Terrain3DColumn transformed = transformedColumn(x, z); + if (transformed != null) { + return transformed; + } + return originalTerrainColumn(x, z); + } + + Terrain3DColumn originalTerrainColumn(int x, int z) { if (!hasTerrain3D()) { return null; } @@ -1204,7 +1249,70 @@ public class IrisComplex implements DataProvider { } public ProceduralStream getHeightStream() { - return heightStream; + return terrainTransform == null ? heightStream : transformedHeightStream; + } + + ProceduralStream getRawSlopeStream() { + return terrainEngine.getDimension().getTerrainTransform() == null ? slopeStream : heightStream.slope(3); + } + + public ProceduralStream getRoundedHeighteightStream() { + return terrainTransform == null ? roundedHeighteightStream : transformedRoundedHeightStream; + } + + public ProceduralStream getHeightFluidStream() { + return terrainTransform == null ? heightFluidStream : transformedTopStream; + } + + public ProceduralStream getSlopeStream() { + return terrainTransform == null ? slopeStream : transformedSlopeStream; + } + + public int transformedHeight(int x, int z, boolean ignoreFluid) { + if (terrainTransform == null || isNaturalTerrainContext()) { + return Integer.MIN_VALUE; + } + return readsReadyTerrainOnly() + ? terrainTransform.readyHeight(x, z, ignoreFluid) + : terrainTransform.height(x, z, ignoreFluid); + } + + public Terrain3DColumn transformedColumn(int x, int z) { + if (terrainTransform == null || isNaturalTerrainContext()) { + return null; + } + return readsReadyTerrainOnly() + ? terrainTransform.readyColumn(x, z) : terrainTransform.column(x, z); + } + + public int transformedFluidHeight(int x, int z) { + if (terrainTransform == null || isNaturalTerrainContext()) { + return Integer.MIN_VALUE; + } + return readsReadyTerrainOnly() + ? terrainTransform.readyFluidHeight(x, z) : terrainTransform.fluidHeight(x, z); + } + + private boolean readsReadyTerrainOnly() { + if (!terrainEngine.getPlatformHooks().isMainThread()) { + return false; + } + IrisContext context = IrisContext.get(); + if (context == null || context.getEngine() != terrainEngine) { + return true; + } + ChunkContext chunk = context.getChunkContext(); + if (chunk != null) { + return chunk.getComplex() != this || chunk.isNaturalTerrain(); + } + if (context.getGenerationSessionId() == 0L || terrainEngine.getComplex() != this) { + return true; + } + if (terrainEngine instanceof IrisEngine irisEngine && irisEngine.threadState.assembly() != null + && irisEngine.threadState.assembly().mode == null) { + return true; + } + return terrainEngine.getMode() == null; } boolean isNaturalTerrainContext() { @@ -2144,6 +2252,9 @@ public class IrisComplex implements DataProvider { } public void close() { + if (terrainTransform != null) { + terrainTransform.close(); + } if (hydrologyBanks3D != null) { hydrologyBanks3D.clear(); } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/generation/runtime/UpperDimensionContext.java index b89604aee..6b54e6ae5 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/UpperDimensionContext.java @@ -36,7 +36,7 @@ public class UpperDimensionContext implements DataProvider { private UpperDimensionContext(DimensionTerrainContext terrainContext, Engine engine) { this.terrainContext = terrainContext; IrisComplex complex = engine.getComplex(); - lowerHeightStream = complex.getHeightStream(); + lowerHeightStream = complex.getRawHeightStream(); ceilingLayout = new CeilingLayout(engine.getHeight(), engine.getDimension().getUpperDimensionGap()); } diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index 287563f20..fcfa0e216 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -904,7 +904,7 @@ public class IrisCarveModifier extends EngineAssignedModifier zone.setFloor(floor); zone.setCeiling(y - 1); if (zone.isValid(resolver.height) - && !getComplex().isTerrain3DOpening(worldX, floor, worldZ)) { + && !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ)) { if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) { mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING); } @@ -992,7 +992,7 @@ public class IrisCarveModifier extends EngineAssignedModifier } IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { - if (resolverState.complex.isTerrain3DOpening(worldX, y, worldZ)) { + if (isNaturalTerrainOpening(resolverState.complex, worldX, y, worldZ)) { return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ); } if (cavern != null && !cavern.getCustomBiome().isEmpty()) { @@ -1001,6 +1001,18 @@ public class IrisCarveModifier extends EngineAssignedModifier return resolveCaveBiome(caveBiomeCache, worldX, y, worldZ, resolverState); } + private boolean isNaturalTerrainOpening(IrisComplex complex, int x, int y, int z) { + if (complex.getTerrainTransform() == null) { + return complex.isTerrain3DOpening(x, y, z); + } + if (!complex.hasTerrain3D()) { + return false; + } + Terrain3DColumn column = complex.terrainColumn(x, z, complex.sampleHydrologyColumn(x, z)); + return column != null && y >= column.minY() && y < column.topY() && !column.isSolid(y) + && !getEngine().isAdditionalTerrainOwned(x, y, z); + } + /** The dimension's underground river bed palette, or null when nothing paints. */ private IrisRiverMaterialConfig undergroundBedMaterial() { IrisHydrology hydrology = getDimension().getHydrology(); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index ec55287a0..56db9deec 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -39,6 +39,7 @@ import art.arcane.iris.generation.image.IrisImageMapBinding; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.generation.noise.IrisShapedGeneratorStyle; import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.terrain.transform.IrisTerrainTransform; import art.arcane.iris.pack.validation.CompatPools; import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl; @@ -218,6 +219,8 @@ public class IrisDimension extends IrisRegistrant { private IrisHydrology hydrology = new IrisHydrology(); @Description("Dimension-level river placement, routing, profile, biome, and geometry policy.") private IrisRiverPolicy riverPolicy = new IrisRiverPolicy(); + @Description("Optional external terrain transformation with an exact provider version and pack-owned settings.") + private IrisTerrainTransform terrainTransform; @Description("Refuse to place surface objects and trees over carved surface openings.") private boolean requireObjectSurfaceSupport = true; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DColumn.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DColumn.java index 30249d645..c65ab0e34 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DColumn.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DColumn.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.terrain; import java.util.Arrays; +import java.util.function.IntPredicate; public final class Terrain3DColumn { private final double baseHeight; @@ -20,6 +21,29 @@ public final class Terrain3DColumn { return new Terrain3DColumn(baseHeight, top + 1, false, new int[]{0, top}); } + public static Terrain3DColumn fromOccupancy(double baseHeight, int height, IntPredicate solid) { + if (height < 1) { + throw new IllegalArgumentException("Terrain column height must be positive"); + } + int[] spans = new int[height + 1]; + int count = 0; + boolean occupied = false; + for (int y = 0; y < height; y++) { + boolean next = solid.test(y); + if (next && !occupied) { + spans[count++] = y; + } else if (!next && occupied) { + spans[count++] = y - 1; + } + occupied = next; + } + if (occupied) { + spans[count++] = height - 1; + } + return new Terrain3DColumn(baseHeight, 0, true, + count == 0 ? new int[]{0, -1} : Arrays.copyOf(spans, count)); + } + public double baseHeight() { return baseHeight; } diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/IrisTerrainTransform.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/IrisTerrainTransform.java new file mode 100644 index 000000000..850bf02de --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/IrisTerrainTransform.java @@ -0,0 +1,25 @@ +package art.arcane.iris.generation.terrain.transform; + +import art.arcane.iris.pack.schema.annotation.Required; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.documentation.Description; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Data +@Description("An external terrain transformer with its exact implementation version and generation settings.") +public class IrisTerrainTransform { + @Required + @Description("The installed terrain transform provider identifier.") + private String id; + + @Required + @Description("The exact provider implementation version required to generate this dimension.") + private String version; + + @Description("Provider settings saved with the generation pack. Values are strings interpreted by the provider.") + private KMap settings = new KMap<>(); +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainColumnTransformer.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainColumnTransformer.java new file mode 100644 index 000000000..dba08e91c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainColumnTransformer.java @@ -0,0 +1,6 @@ +package art.arcane.iris.generation.terrain.transform; + +@FunctionalInterface +public interface TerrainColumnTransformer { + void transform(TerrainTransformContext context, int localX, int localZ); +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformContext.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformContext.java new file mode 100644 index 000000000..9be8cf8be --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformContext.java @@ -0,0 +1,17 @@ +package art.arcane.iris.generation.terrain.transform; + +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; + +public interface TerrainTransformContext { + int blockX(); + + int blockZ(); + + int minY(); + + int height(); + + NativeBlockState original(int worldX, int worldY, int worldZ); + + void set(int localX, int localY, int localZ, NativeBlockState state); +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformProvider.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformProvider.java new file mode 100644 index 000000000..206f0b5b7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformProvider.java @@ -0,0 +1,11 @@ +package art.arcane.iris.generation.terrain.transform; + +import java.util.Map; + +public interface TerrainTransformProvider { + String id(); + + String version(); + + TerrainTransformer create(Map settings); +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistry.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistry.java new file mode 100644 index 000000000..37a48fce8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistry.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.terrain.transform; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.ServiceLoader; + +public final class TerrainTransformRegistry { + private final Map providers; + + public TerrainTransformRegistry(Iterable providers) { + Map indexed = new HashMap<>(); + for (TerrainTransformProvider provider : Objects.requireNonNull(providers, "terrain transform providers")) { + Objects.requireNonNull(provider, "terrain transform provider"); + ProviderIdentity identity = new ProviderIdentity(provider.id(), provider.version()); + if (indexed.putIfAbsent(identity, provider) != null) { + throw new IllegalArgumentException("Duplicate terrain transform provider " + identity + "."); + } + } + this.providers = Map.copyOf(indexed); + } + + public static TerrainTransformRegistry discover() { + return new TerrainTransformRegistry(ServiceLoader.load( + TerrainTransformProvider.class, TerrainTransformProvider.class.getClassLoader())); + } + + public TerrainTransformer resolve(IrisTerrainTransform descriptor) { + if (descriptor == null) { + return null; + } + ProviderIdentity identity = new ProviderIdentity(descriptor.getId(), descriptor.getVersion()); + TerrainTransformProvider provider = providers.get(identity); + if (provider == null) { + throw new IllegalStateException("Required terrain transform provider " + identity + " is unavailable."); + } + Map settings = Map.copyOf(Objects.requireNonNull( + descriptor.getSettings(), "terrain transform settings")); + TerrainTransformer transformer = Objects.requireNonNull( + provider.create(settings), "terrain transformer returned by " + identity); + int radius = transformer.radius(); + if (radius < 0 || radius > 16) { + throw new IllegalArgumentException("Terrain transform provider " + identity + + " requires radius " + radius + "; the supported range is 0 through 16 blocks."); + } + return transformer; + } + + private static String requireIdentity(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " must not be blank."); + } + return value; + } + + private record ProviderIdentity(String id, String version) { + private ProviderIdentity { + id = requireIdentity(id, "Terrain transform provider ID"); + version = requireIdentity(version, "Terrain transform provider version"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntime.java new file mode 100644 index 000000000..103f1eaeb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntime.java @@ -0,0 +1,506 @@ +package art.arcane.iris.generation.terrain.transform; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.mantle.CaveTerrainSnapshot; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.Terrain3DColumn; +import art.arcane.iris.world.history.FloatingBiomeOverlay; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.hunk.storage.StorageHunk; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.Arrays; +import java.util.Objects; + +public final class TerrainTransformRuntime implements AutoCloseable { + private static final long CACHE_BYTES = 64L * 1024 * 1024; + + private final Engine engine; + private final IrisComplex complex; + private final TerrainTransformer transformer; + private final TerrainColumnTransformer columnTransformer; + private final int radius; + private final int minY; + private final int height; + private final NativeBlockState air; + private final Cache originals; + private final Cache transformed; + private final Cache transformedColumns; + private volatile boolean closed; + + public TerrainTransformRuntime(Options options, TerrainTransformer transformer) { + engine = Objects.requireNonNull(options, "terrain runtime options").engine(); + complex = options.complex(); + this.transformer = Objects.requireNonNull(transformer, "terrain transformer"); + columnTransformer = transformer.columnTransformer(); + radius = transformer.radius(); + if (radius < 0 || radius > 16) { + throw new IllegalArgumentException("Terrain transform radius must be between 0 and 16 blocks"); + } + minY = engine.getMinHeight(); + height = engine.getHeight(); + if (height <= 0) { + throw new IllegalArgumentException("Terrain height must be positive"); + } + air = Objects.requireNonNull(B.getState("AIR"), "air block state"); + originals = newCache(); + transformed = newCache(); + transformedColumns = columnTransformer == null ? null : Caffeine.newBuilder().maximumWeight(CACHE_BYTES) + .weigher((Long key, ColumnSnapshot snapshot) -> snapshot.weight()).build(); + } + + public void generate(int x, int z, Hunk blocks, Hunk biomes, + boolean multicore, ChunkContext target) { + requireOpen(); + if ((x & 15) != 0 || (z & 15) != 0 || blocks.getWidth() != 16 || blocks.getDepth() != 16 + || blocks.getHeight() != height || biomes.getWidth() != 16 || biomes.getDepth() != 16 + || biomes.getHeight() != height || target.getComplex() != complex) { + throw new IllegalArgumentException("Terrain transform output must match its runtime and chunk"); + } + Snapshot snapshot = transformed(x >> 4, z >> 4); + target.setTerrainBiomeOutput(biomes); + for (int y = 0; y < height; y++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int index = index(localX, y, localZ); + blocks.setRaw(localX, y, localZ, snapshot.blocks[index]); + NativeBiome biome = snapshot.biomes[index]; + if (biome != null) { + biomes.setRaw(localX, y, localZ, biome); + } + } + } + } + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + target.setTerrainHeight(localX, localZ, snapshot.solidHeights[(localZ << 4) | localX]); + } + } + target.setCaveTerrain(snapshot.caves); + target.setFloatingBiomes(snapshot.floating == null ? null : snapshot.floating.copy()); + } + + public int height(int x, int z, boolean ignoreFluid) { + requireOpen(); + Snapshot snapshot = fullForQuery(x, z); + if (snapshot != null) { + return snapshot.height(x & 15, z & 15, ignoreFluid); + } + ColumnSnapshot column = transformedColumn(x, z); + return ignoreFluid ? column.ground : column.top; + } + + public int readyHeight(int x, int z, boolean ignoreFluid) { + requireOpen(); + Snapshot snapshot = transformed.getIfPresent(key(x >> 4, z >> 4)); + if (snapshot != null) { + return snapshot.height(x & 15, z & 15, ignoreFluid); + } + ColumnSnapshot column = readyTransformedColumn(x, z); + return column == null ? Integer.MIN_VALUE : ignoreFluid ? column.ground : column.top; + } + + public NativeBlockState block(int worldX, int localY, int worldZ) { + requireOpen(); + if (localY < 0 || localY >= height) { + return air; + } + return transformed(worldX >> 4, worldZ >> 4).blocks[index(worldX & 15, localY, worldZ & 15)]; + } + + public NativeBlockState readyBlock(int worldX, int localY, int worldZ) { + requireOpen(); + if (localY < 0 || localY >= height) { + return air; + } + Snapshot snapshot = transformed.getIfPresent(key(worldX >> 4, worldZ >> 4)); + return snapshot == null ? null : snapshot.blocks[index(worldX & 15, localY, worldZ & 15)]; + } + + public Terrain3DColumn column(int x, int z) { + requireOpen(); + Snapshot snapshot = fullForQuery(x, z); + return snapshot == null ? transformedColumn(x, z).terrain : snapshot.column(x & 15, z & 15); + } + + public Terrain3DColumn readyColumn(int x, int z) { + requireOpen(); + Snapshot snapshot = transformed.getIfPresent(key(x >> 4, z >> 4)); + if (snapshot != null) { + return snapshot.column(x & 15, z & 15); + } + ColumnSnapshot column = readyTransformedColumn(x, z); + return column == null ? null : column.terrain; + } + + public int fluidHeight(int x, int z) { + requireOpen(); + Snapshot snapshot = fullForQuery(x, z); + return snapshot == null ? transformedColumn(x, z).fluid : snapshot.fluidHeights[((z & 15) << 4) | (x & 15)]; + } + + public int readyFluidHeight(int x, int z) { + requireOpen(); + Snapshot snapshot = transformed.getIfPresent(key(x >> 4, z >> 4)); + if (snapshot != null) { + return snapshot.fluidHeights[((z & 15) << 4) | (x & 15)]; + } + ColumnSnapshot column = readyTransformedColumn(x, z); + return column == null ? Integer.MIN_VALUE : column.fluid; + } + + @Override + public void close() { + closed = true; + originals.invalidateAll(); + transformed.invalidateAll(); + originals.cleanUp(); + transformed.cleanUp(); + if (transformedColumns != null) { + transformedColumns.invalidateAll(); + transformedColumns.cleanUp(); + } + } + + private Cache newCache() { + return Caffeine.newBuilder().maximumWeight(CACHE_BYTES) + .weigher((Long key, Snapshot snapshot) -> snapshot.weight()).build(); + } + + private Snapshot transformed(int chunkX, int chunkZ) { + Snapshot snapshot = transformed.get(key(chunkX, chunkZ), ignored -> transform(chunkX, chunkZ)); + if (closed) { + transformed.invalidateAll(); + requireOpen(); + } + return snapshot; + } + + private Snapshot original(int chunkX, int chunkZ) { + Snapshot snapshot = originals.get(key(chunkX, chunkZ), ignored -> materialize(chunkX, chunkZ)); + if (closed) { + originals.invalidateAll(); + requireOpen(); + } + return snapshot; + } + + private Snapshot fullForQuery(int x, int z) { + return columnTransformer == null ? transformed(x >> 4, z >> 4) + : transformed.getIfPresent(key(x >> 4, z >> 4)); + } + + private ColumnSnapshot readyTransformedColumn(int x, int z) { + return transformedColumns == null ? null : transformedColumns.getIfPresent(key(x, z)); + } + + private ColumnSnapshot transformedColumn(int x, int z) { + ColumnSnapshot snapshot = transformedColumns.get(key(x, z), ignored -> transformColumn(x, z)); + if (closed) { + transformedColumns.invalidateAll(); + requireOpen(); + } + return snapshot; + } + + private Snapshot materialize(int chunkX, int chunkZ) { + requireOpen(); + int x = chunkX << 4; + int z = chunkZ << 4; + NativeBlockState[] states = new NativeBlockState[Math.multiplyExact(256, height)]; + Arrays.fill(states, air); + NativeBiome[] biomeStates = new NativeBiome[states.length]; + Hunk blocks = new SnapshotHunk<>(states, air); + Hunk biomes = new SnapshotHunk<>(biomeStates, null); + long session = engine.getGenerationSessionId(); + ChunkContext context = new ChunkContext(x, z, complex, session, true, + ChunkContext.PrefillPlan.NATURAL_TERRAIN, engine.getMetrics(), engine.getDimensionStackContext()); + try (IrisContext.Scope scope = IrisContext.open(engine, session, context)) { + engine.getMode().generateTerrain(x, z, blocks, biomes, false, context); + } + FloatingBiomeOverlay floating = context.getFloatingBiomes(); + return new Snapshot(new SnapshotData(states, biomeStates, context.getCaveTerrain(), + floating == null ? null : floating.copy()), false); + } + + private Snapshot transform(int chunkX, int chunkZ) { + requireOpen(); + Snapshot source = original(chunkX, chunkZ); + TransformContext context = new TransformContext(new TransformTarget(chunkX, chunkZ, false, 0, 0), source); + try { + transformer.transform(context); + } finally { + context.active = false; + } + return new Snapshot(new SnapshotData(context.blocks, source.biomes, source.caves, source.floating), true); + } + + private ColumnSnapshot transformColumn(int x, int z) { + requireOpen(); + Snapshot source = original(x >> 4, z >> 4); + TransformContext context = new TransformContext( + new TransformTarget(x >> 4, z >> 4, true, x & 15, z & 15), source); + try { + columnTransformer.transform(context, x & 15, z & 15); + } finally { + context.active = false; + } + return new ColumnSnapshot(context.blocks); + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Terrain transform runtime is closed"); + } + } + + private static int index(int x, int y, int z) { + return (y << 8) | (z << 4) | x; + } + + private static long key(int chunkX, int chunkZ) { + return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); + } + + public record Options(Engine engine, IrisComplex complex) { + public Options { + Objects.requireNonNull(engine, "terrain engine"); + Objects.requireNonNull(complex, "terrain complex"); + } + } + + private record TransformTarget(int chunkX, int chunkZ, boolean columnOnly, int localX, int localZ) { + } + + private record SnapshotData(NativeBlockState[] blocks, NativeBiome[] biomes, + CaveTerrainSnapshot caves, FloatingBiomeOverlay floating) { + } + + private static final class SnapshotHunk extends StorageHunk { + private final T[] values; + private final T fallback; + + private SnapshotHunk(T[] values, T fallback) { + super(16, values.length / 256, 16); + this.values = values; + this.fallback = fallback; + } + + @Override + public T getRaw(int x, int y, int z) { + return y < 0 || y >= getHeight() ? fallback : values[index(x, y, z)]; + } + + @Override + public void setRaw(int x, int y, int z, T value) { + if (value != null) { + values[index(x, y, z)] = value; + } + } + } + + private final class TransformContext implements TerrainTransformContext { + private final int x; + private final int z; + private final NativeBlockState[] blocks; + private final TransformTarget target; + private final Snapshot[] sources = new Snapshot[9]; + private volatile boolean active = true; + + private TransformContext(TransformTarget target, Snapshot source) { + this.target = target; + x = target.chunkX() << 4; + z = target.chunkZ() << 4; + if (target.columnOnly()) { + blocks = new NativeBlockState[height]; + for (int y = 0; y < height; y++) { + blocks[y] = source.blocks[index(target.localX(), y, target.localZ())]; + } + } else { + blocks = source.blocks.clone(); + } + sources[4] = source; + } + + @Override + public int blockX() { + return x; + } + + @Override + public int blockZ() { + return z; + } + + @Override + public int minY() { + return minY; + } + + @Override + public int height() { + return height; + } + + @Override + public NativeBlockState original(int worldX, int worldY, int worldZ) { + requireActive(); + long localX = (long) worldX - x; + long localZ = (long) worldZ - z; + if (localX < -radius || localX >= 16L + radius || localZ < -radius || localZ >= 16L + radius) { + throw new IllegalArgumentException("Terrain transform read exceeds its declared radius"); + } + long localY = (long) worldY - minY; + if (localY < 0 || localY >= height) { + return air; + } + int sourceX = worldX >> 4; + int sourceZ = worldZ >> 4; + int slot = (sourceZ - (z >> 4) + 1) * 3 + sourceX - (x >> 4) + 1; + Snapshot snapshot = sources[slot]; + if (snapshot == null) { + snapshot = TerrainTransformRuntime.this.original(sourceX, sourceZ); + sources[slot] = snapshot; + } + return snapshot.blocks[index(worldX & 15, (int) localY, worldZ & 15)]; + } + + @Override + public void set(int localX, int localY, int localZ, NativeBlockState state) { + requireActive(); + Objects.requireNonNull(state, "terrain transform block state"); + if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16 || localY < 0 || localY >= height) { + throw new IllegalArgumentException("Terrain transform write exceeds its output chunk"); + } + if (target.columnOnly() && (localX != target.localX() || localZ != target.localZ())) { + throw new IllegalArgumentException("Terrain column transform write exceeds its output column"); + } + if (complex.allowsMantleWrite(x + localX, z + localZ)) { + blocks[target.columnOnly() ? localY : index(localX, localY, localZ)] = state; + } + } + + private void requireActive() { + requireOpen(); + if (!active) { + throw new IllegalStateException("Terrain transform context is no longer active"); + } + } + } + + private static final class ColumnSnapshot { + private final Terrain3DColumn terrain; + private final int ground; + private final int top; + private final int fluid; + + private ColumnSnapshot(NativeBlockState[] blocks) { + int solidHeight = -1; + int topHeight = -1; + int fluidHeight = -1; + for (int y = blocks.length - 1; y >= 0; y--) { + NativeBlockState state = blocks[y]; + if (state.isAir()) { + continue; + } + if (topHeight == -1) { + topHeight = y; + } + if ((state.isFluid() || state.isWaterLogged()) && fluidHeight == -1) { + fluidHeight = y; + } + if (!state.isFluid() && solidHeight == -1) { + solidHeight = y; + } + } + ground = solidHeight; + top = topHeight; + fluid = fluidHeight; + terrain = Terrain3DColumn.fromOccupancy(ground, blocks.length, + y -> !blocks[y].isAir() && !blocks[y].isFluid()); + } + + private int weight() { + return 192 + terrain.spanCount() * 8; + } + } + + private static final class Snapshot { + private final NativeBlockState[] blocks; + private final NativeBiome[] biomes; + private final CaveTerrainSnapshot caves; + private final FloatingBiomeOverlay floating; + private final int[] solidHeights; + private final int[] topHeights; + private final int[] fluidHeights; + private final Terrain3DColumn[] columns; + + private Snapshot(SnapshotData data, boolean effective) { + blocks = data.blocks(); + biomes = data.biomes(); + caves = data.caves(); + floating = data.floating(); + solidHeights = effective ? new int[256] : null; + topHeights = effective ? new int[256] : null; + fluidHeights = effective ? new int[256] : null; + columns = effective ? new Terrain3DColumn[256] : null; + if (!effective) { + return; + } + Arrays.fill(solidHeights, -1); + Arrays.fill(topHeights, -1); + Arrays.fill(fluidHeights, -1); + int height = blocks.length / 256; + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int column = (z << 4) | x; + for (int y = height - 1; y >= 0; y--) { + NativeBlockState state = blocks[index(x, y, z)]; + if (state.isAir()) { + continue; + } + if (topHeights[column] == -1) { + topHeights[column] = y; + } + if ((state.isFluid() || state.isWaterLogged()) && fluidHeights[column] == -1) { + fluidHeights[column] = y; + } + if (!state.isFluid() && solidHeights[column] == -1) { + solidHeights[column] = y; + } + } + } + } + } + + private int height(int x, int z, boolean ignoreFluid) { + return (ignoreFluid ? solidHeights : topHeights)[(z << 4) | x]; + } + + private Terrain3DColumn column(int x, int z) { + int index = (z << 4) | x; + Terrain3DColumn column = columns[index]; + if (column == null) { + column = Terrain3DColumn.fromOccupancy(solidHeights[index], blocks.length / 256, y -> { + NativeBlockState state = blocks[index(x, y, z)]; + return !state.isAir() && !state.isFluid(); + }); + columns[index] = column; + } + return column; + } + + private int weight() { + long blockReferences = (long) blocks.length * 20L; + long caveReferences = caves == null ? 0L : caves.estimatedRetainedBytes(); + long floatingReferences = floating == null ? 0L : (long) blocks.length * 4L; + return (int) Math.min(Integer.MAX_VALUE, 16384L + blockReferences + caveReferences + floatingReferences); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformer.java b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformer.java new file mode 100644 index 000000000..7b26dfd7d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/transform/TerrainTransformer.java @@ -0,0 +1,11 @@ +package art.arcane.iris.generation.terrain.transform; + +public interface TerrainTransformer { + int radius(); + + void transform(TerrainTransformContext context); + + default TerrainColumnTransformer columnTransformer() { + return null; + } +} diff --git a/core/src/main/java/art/arcane/iris/world/history/FloatingBiomeOverlay.java b/core/src/main/java/art/arcane/iris/world/history/FloatingBiomeOverlay.java index cef54cb4f..4169a2852 100644 --- a/core/src/main/java/art/arcane/iris/world/history/FloatingBiomeOverlay.java +++ b/core/src/main/java/art/arcane/iris/world/history/FloatingBiomeOverlay.java @@ -64,6 +64,13 @@ public final class FloatingBiomeOverlay { return height; } + public FloatingBiomeOverlay copy() { + FloatingBiomeOverlay copy = new FloatingBiomeOverlay(height); + System.arraycopy(volume, 0, copy.volume, 0, volume.length); + System.arraycopy(surfaces, 0, copy.surfaces, 0, surfaces.length); + return copy; + } + private int index(int localX, int y, int localZ) { return ((localX >> 2) * 4 + (localZ >> 2)) * quartHeight + (y >> 2); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshotTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshotTest.java index 95c64d0c2..a9150b137 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshotTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/CaveTerrainSnapshotTest.java @@ -81,6 +81,23 @@ public class CaveTerrainSnapshotTest { assertNull(snapshot.cavern(4, 22, 6)); } + @Test + public void retainedEstimateCountsOnlyAllocatedSectionsAndTheirCells() { + Matter[] sections = new Matter[SECTIONS]; + long emptyBytes = CaveTerrainSnapshot.capture(chunk(sections), 0, 0).estimatedRetainedBytes(); + assertTrue(emptyBytes < 1024); + sections[1] = new IrisMatter(16, 16, 16); + sections[1].slice(MatterCavern.class).set(0, 0, 0, new MatterCavern(true, "cave", (byte) 0)); + CaveTerrainSnapshot sparse = CaveTerrainSnapshot.capture(chunk(sections), 0, 0); + long sparseBytes = sparse.estimatedRetainedBytes(); + assertTrue(sparseBytes >= emptyBytes + 4096L * 8L); + assertTrue(sparseBytes < emptyBytes + 4096L * 16L); + sections[1].slice(MatterCavern.class).set(1, 0, 0, new MatterCavern(true, "other", (byte) 0)); + long moreCells = CaveTerrainSnapshot.capture(chunk(sections), 0, 0).estimatedRetainedBytes(); + assertTrue(moreCells > sparseBytes); + assertEquals(sparseBytes, sparse.estimatedRetainedBytes()); + } + private static Map legacyResolverInputs(MantleChunk chunk) { Map inputs = new HashMap<>(); for (int x = 0; x < 16; x++) { diff --git a/core/src/test/java/art/arcane/iris/generation/mode/ModeOverworldTerrainTransformTest.java b/core/src/test/java/art/arcane/iris/generation/mode/ModeOverworldTerrainTransformTest.java new file mode 100644 index 000000000..e7a5d14bb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mode/ModeOverworldTerrainTransformTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.generation.mode; + +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRuntime; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class ModeOverworldTerrainTransformTest { + @Test + public void studioRawSnapshotsKeepTerrainMatterOnTheBoundCallingThread() { + Fixture fixture = new Fixture(); + doReturn(mock(TerrainTransformRuntime.class)).when(fixture.complex).getTerrainTransform(); + Thread owner = Thread.currentThread(); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + assertSame(fixture.context, IrisContext.require().getChunkContext()); + return null; + }).when(fixture.mantle).generateTerrainMatter(eq(-2), eq(3), anyBoolean(), eq(fixture.context)); + try (IrisContext.Scope scope = IrisContext.open(fixture.engine, 41L, fixture.context)) { + fixture.mode.generateTerrainMatterForChunk(-2, 3, false, fixture.context); + } + verify(fixture.mantle).generateTerrainMatter(-2, 3, false, fixture.context); + } + + @Test + public void ordinaryStudioTerrainStillUsesParallelMatter() { + Fixture fixture = new Fixture(); + fixture.mode.generateTerrainMatterForChunk(-2, 3, false, fixture.context); + verify(fixture.mantle).generateTerrainMatter(-2, 3, true, fixture.context); + } + + @Test + public void explicitParallelGenerationRemainsParallelWithATransformer() { + Fixture fixture = new Fixture(); + doReturn(mock(TerrainTransformRuntime.class)).when(fixture.complex).getTerrainTransform(); + fixture.mode.generateTerrainMatterForChunk(-2, 3, true, fixture.context); + verify(fixture.mantle).generateTerrainMatter(-2, 3, true, fixture.context); + } + + private static final class Fixture { + private final ModeOverworld mode = mock(ModeOverworld.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final EngineMantle mantle = mock(EngineMantle.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final ChunkContext context = mock(ChunkContext.class); + + private Fixture() { + doReturn(engine).when(mode).getEngine(); + doReturn(mantle).when(engine).getMantle(); + doReturn(true).when(engine).isStudio(); + doReturn(complex).when(context).getComplex(); + doReturn(true).when(context).isNaturalTerrain(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrainContextFallbackTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrainContextFallbackTest.java index 80dbb9abd..a871631fe 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrainContextFallbackTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrainContextFallbackTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.generation.image.IrisImageMapRuntime; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisRegion; @@ -54,10 +56,10 @@ public class DimensionTerrainContextFallbackTest { when(engine.getHeight()).thenReturn(128); when(dimension.getLoadKey()).thenReturn("root"); when(complex.hasTerrain3D()).thenReturn(true); - when(complex.getSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 40D)); - when(complex.terrainColumn(-17, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 5, 20, 40)); - when(complex.terrainColumn(-14, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 8, 20, 60)); - when(complex.terrainColumn(-17, 11)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 9, 20, 80)); + when(complex.getRawSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 40D)); + when(complex.originalTerrainColumn(-17, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 5, 20, 40)); + when(complex.originalTerrainColumn(-14, 8)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 8, 20, 60)); + when(complex.originalTerrainColumn(-17, 11)).thenReturn(Terrain3DColumnFixtures.spans(20, 0, 9, 20, 80)); DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension); assertEquals(5D, context.getSurfaceSlopeStream(5).getDouble(-17, 8), 0D); @@ -90,7 +92,7 @@ public class DimensionTerrainContextFallbackTest { when(dimension.getLoadKey()).thenReturn("root"); when(dimension.getFluidHeight()).thenReturn(12); when(complex.getNaturalHeightStream()).thenReturn(naturalHeight); - when(complex.getHeightStream()).thenReturn(resolvedHeight); + when(complex.getRawHeightStream()).thenReturn(resolvedHeight); when(complex.getRiverWaterSurfaceStream()).thenReturn(resolvedFluidHeight); when(complex.getNaturalTrueBiomeStream()).thenReturn(naturalBiome); when(complex.getTrueBiomeStream()).thenReturn(resolvedBiome); @@ -132,7 +134,7 @@ public class DimensionTerrainContextFallbackTest { Terrain3DColumn natural = Terrain3DColumnFixtures.spans(30, 0, 15, 25, 40); Terrain3DColumn resolved = Terrain3DColumnFixtures.spans(30, 0, 12, 28, 35); when(complex.naturalTerrainColumn(-17, 8)).thenReturn(natural); - when(complex.terrainColumn(-17, 8)).thenReturn(resolved); + when(complex.originalTerrainColumn(-17, 8)).thenReturn(resolved); DimensionTerrainContext context = DimensionTerrainContext.forStack(engine, dimension); assertSame(resolved, context.terrainColumn(-17, 8)); @@ -141,4 +143,34 @@ public class DimensionTerrainContextFallbackTest { when(engine.answersFromNaturalTerrain(-17, 8)).thenReturn(false); assertSame(resolved, context.terrainColumn(-17, 8)); } + + @Test + public void stackInputsRemainOriginalWhenTheCallingChunkBeginsContent() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + Terrain3DColumn original = Terrain3DColumnFixtures.spans(30, 0, 12, 28, 35); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(128); + when(dimension.getLoadKey()).thenReturn("root"); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 35D)); + when(complex.getRawSlopeStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 4D)); + when(complex.originalTerrainColumn(0, 0)).thenReturn(original); + when(complex.getHeightStream()).thenThrow(new AssertionError("Layout read final terrain height")); + when(complex.getSlopeStream()).thenThrow(new AssertionError("Layout read final terrain slope")); + when(complex.terrainColumn(0, 0)).thenThrow(new AssertionError("Layout read final terrain occupancy")); + DimensionTerrainContext terrain = DimensionTerrainContext.forStack(engine, dimension); + ChunkContext chunk = new ChunkContext(0, 0, complex, 7L, false, ChunkContext.PrefillPlan.NONE, null); + + try (IrisContext.Scope ignored = IrisContext.open(engine, 7L, chunk)) { + assertEquals(35D, terrain.getNormalTerrainHeight(0, 0), 0D); + assertEquals(4D, terrain.getSlopeStream().getDouble(0, 0), 0D); + assertSame(original, terrain.terrainColumn(0, 0)); + chunk.beginContent(); + assertEquals(35D, terrain.getNormalTerrainHeight(0, 0), 0D); + assertEquals(4D, terrain.getSlopeStream().getDouble(0, 0), 0D); + assertSame(original, terrain.terrainColumn(0, 0)); + } + } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTerrainTransformTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTerrainTransformTest.java new file mode 100644 index 000000000..005005224 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTerrainTransformTest.java @@ -0,0 +1,341 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.transform.IrisTerrainTransform; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRegistry; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRuntime; +import art.arcane.iris.generation.terrain.transform.TerrainTransformer; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisComplexTerrainTransformTest { + @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisSettings previousSettings; + + @Before + public void prepareRuntime() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + } + + @After + public void releaseRuntime() { + IrisPlatforms.unbind(); + IrisServices.remove(PreservationRegistry.class); + IrisSettings.settings = previousSettings; + } + + @Test + public void absentProviderRetainsTheOriginalStreamsAndStackHeights() throws Exception { + try (Fixture fixture = new Fixture(pack(), false)) { + ProceduralStream heights = ProceduralStream.ofDouble((x, z) -> 80D); + ProceduralStream rounded = heights.round(); + ProceduralStream tops = ProceduralStream.ofDouble((x, z) -> 90D); + ProceduralStream slopes = ProceduralStream.ofDouble((x, z) -> 4D); + fixture.complex.setHeightStream(heights); + fixture.complex.setRoundedHeighteightStream(rounded); + fixture.complex.setHeightFluidStream(tops); + fixture.complex.setSlopeStream(slopes); + + assertNull(fixture.complex.getTerrainTransform()); + assertSame(heights, fixture.complex.getHeightStream()); + assertSame(rounded, fixture.complex.getRoundedHeighteightStream()); + assertSame(tops, fixture.complex.getHeightFluidStream()); + assertSame(slopes, fixture.complex.getSlopeStream()); + assertSame(slopes, fixture.complex.getRawSlopeStream()); + fixture.installStack(); + assertEquals(40, fixture.engine.getHeight(0, 0, true)); + assertEquals(50, fixture.engine.getHeight(0, 0, false)); + } + } + + @Test + public void capturedStreamsSwitchAtSamplingTimeWhenContentBegins() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 80D + 2D * x + 3D * z)); + fixture.complex.setRoundedHeighteightStream(ProceduralStream.ofDouble((x, z) -> 81D).round()); + fixture.complex.setHeightFluidStream(ProceduralStream.ofDouble((x, z) -> 90D)); + fixture.complex.setSlopeStream(ProceduralStream.ofDouble((x, z) -> 9D)); + ProceduralStream heights = fixture.complex.getHeightStream(); + ProceduralStream rounded = fixture.complex.getRoundedHeighteightStream(); + ProceduralStream tops = fixture.complex.getHeightFluidStream(); + ProceduralStream slopes = fixture.complex.getSlopeStream(); + ChunkContext context = fixture.context(); + + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 7L, context)) { + assertEquals(80D, heights.getDouble(0, 0), 0D); + assertEquals(Integer.valueOf(81), rounded.get(0, 0)); + assertEquals(90D, tops.getDouble(0, 0), 0D); + assertEquals(9D, slopes.getDouble(0, 0), 0D); + assertEquals(0, fixture.materializations.get()); + + context.beginContent(); + + assertEquals(120D, heights.getDouble(0, 0), 0D); + assertEquals(Integer.valueOf(120), rounded.get(0, 0)); + assertEquals(125D, tops.getDouble(0, 0), 0D); + assertEquals(0D, slopes.getDouble(0, 0), 0D); + } + } + } + + @Test + public void absentProviderRunsTerrainTransitionAndContentInTheirOriginalOrder() throws Exception { + try (Fixture fixture = new Fixture(pack(), false)) { + EngineMode mode = mock(EngineMode.class, CALLS_REAL_METHODS); + doReturn(fixture.engine).when(mode).getEngine(); + doReturn(fixture.complex).when(mode).getComplex(); + when(fixture.engine.getPlatformHooks().shouldDisableChunkContextCache(fixture.engine)).thenReturn(true); + List stages = new ArrayList<>(); + NativeBlockState terrain = mock(NativeBlockState.class); + NativeBlockState transitioned = mock(NativeBlockState.class); + EngineStage natural = (x, z, blocks, biomes, multicore, context) -> { + assertTrue(context.isNaturalTerrain()); + blocks.set(0, 1, 0, terrain); + stages.add("terrain"); + }; + EngineStage transition = (x, z, blocks, biomes, multicore, context) -> { + assertTrue(context.isNaturalTerrain()); + assertSame(terrain, blocks.get(0, 1, 0)); + blocks.set(0, 1, 0, transitioned); + stages.add("transition"); + }; + EngineStage content = (x, z, blocks, biomes, multicore, context) -> { + assertFalse(context.isNaturalTerrain()); + assertSame(transitioned, blocks.get(0, 1, 0)); + stages.add("content"); + }; + doReturn(new KList<>(natural)).when(mode).getTerrainStages(); + doReturn(transition).when(mode).getTransitionStage(); + doReturn(new KList<>(content)).when(mode).getStages(); + Hunk blocks = Hunk.newArrayHunk(16, 8, 16); + Hunk biomes = Hunk.newArrayHunk(16, 8, 16); + + mode.generate(0, 0, blocks, biomes, false, 7L); + + assertEquals(List.of("terrain", "transition", "content"), stages); + assertSame(transitioned, blocks.get(0, 1, 0)); + } + } + + @Test + public void transformedHeightsOverrideTheStackOnlyOutsideNaturalGeneration() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.installStack(); + assertEquals(120, fixture.engine.getHeight(0, 0, true)); + assertEquals(125, fixture.engine.getHeight(0, 0, false)); + assertEquals(120, Engine.hostHeight(fixture.engine, 0, 0, true)); + assertEquals(125, Engine.hostHeight(fixture.engine, 0, 0, false)); + + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 7L, fixture.context())) { + assertEquals(40, fixture.engine.getHeight(0, 0, true)); + assertEquals(50, fixture.engine.getHeight(0, 0, false)); + } + } + } + + @Test + public void mainThreadColdQueriesUseRawHeightsWithoutMaterializingTerrain() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.mainThread.set(true); + doReturn(7L).when(fixture.engine).getGenerationSessionId(); + fixture.complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 80D)); + fixture.complex.setHeightFluidStream(ProceduralStream.ofDouble((x, z) -> 90D)); + assertEquals(Integer.MIN_VALUE, fixture.complex.transformedHeight(0, 0, true)); + assertEquals(80D, fixture.complex.getHeightStream().getDouble(0, 0), 0D); + assertEquals(90D, fixture.complex.getHeightFluidStream().getDouble(0, 0), 0D); + fixture.ready.set(true); + assertEquals(120D, fixture.complex.getHeightStream().getDouble(0, 0), 0D); + assertEquals(125D, fixture.complex.getHeightFluidStream().getDouble(0, 0), 0D); + assertEquals(0, fixture.materializations.get()); + } + } + + @Test + public void mainThreadContentQueriesMaterializeUncachedNeighborTerrain() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.mainThread.set(true); + ChunkContext context = fixture.context(); + context.beginContent(); + + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 0L, context)) { + assertEquals(120, fixture.complex.transformedHeight(16, 0, true)); + assertEquals(125, fixture.complex.transformedHeight(16, 0, false)); + assertEquals(2, fixture.materializations.get()); + } + } + } + + @Test + public void originalSlopeIgnoresContentHeightOverrides() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 80D + x + z)); + ProceduralStream originalSlope = fixture.complex.getRawSlopeStream(); + ChunkContext context = fixture.context(); + context.setTerrainHeight(0, 0, 200); + context.setTerrainHeight(3, 0, 250); + context.setTerrainHeight(0, 3, 300); + context.beginContent(); + + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 7L, context)) { + assertEquals(Math.sqrt(18D), originalSlope.getDouble(0, 0), 0.000001D); + assertEquals(0, fixture.materializations.get()); + } + } + } + + @Test + public void admittedMainThreadQueriesWaitUntilTheirComplexAndModeArePublished() throws Exception { + try (Fixture fixture = new Fixture(pack(), true)) { + fixture.mainThread.set(true); + doReturn(null).when(fixture.engine).getComplex(); + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 7L, null)) { + assertEquals(Integer.MIN_VALUE, fixture.complex.transformedHeight(16, 0, true)); + doReturn(fixture.complex).when(fixture.engine).getComplex(); + assertEquals(Integer.MIN_VALUE, fixture.complex.transformedHeight(16, 0, true)); + assertEquals(0, fixture.materializations.get()); + + doReturn(mock(EngineMode.class)).when(fixture.engine).getMode(); + assertEquals(120, fixture.complex.transformedHeight(16, 0, true)); + assertEquals(1, fixture.materializations.get()); + } + } + } + + private Path pack() throws Exception { + Path pack = temporaryFolder.newFolder().toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("regions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("generators")); + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"focus":"plain","regions":["plain"],"landChance":1, + "carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/plain.json"), "{\"landBiomes\":[\"plain\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/plain.json"), + "{\"generators\":[{\"generator\":\"flat\",\"min\":10,\"max\":10}]}"); + return pack; + } + + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + private final AtomicBoolean mainThread = new AtomicBoolean(); + private final AtomicBoolean ready = new AtomicBoolean(); + private final AtomicInteger materializations = new AtomicInteger(); + private final MockedStatic blocks = mockStatic(B.class); + private final MockedStatic registries = mockStatic(TerrainTransformRegistry.class); + private final MockedConstruction runtimes; + private final IrisData data; + private final IrisComplex complex; + + private Fixture(Path pack, boolean transformed) { + runtimes = mockConstruction(TerrainTransformRuntime.class, (runtime, context) -> { + when(runtime.height(anyInt(), anyInt(), anyBoolean())).thenAnswer(invocation -> { + materializations.incrementAndGet(); + return invocation.getArgument(2) ? 120 : 125; + }); + when(runtime.readyHeight(anyInt(), anyInt(), anyBoolean())).thenAnswer(invocation -> + ready.get() ? invocation.getArgument(2) ? 120 : 125 : Integer.MIN_VALUE); + }); + NativeBlockState block = mock(NativeBlockState.class); + when(block.isFluid()).thenReturn(true); + blocks.when(() -> B.getState(anyString())).thenReturn(block); + blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block); + data = IrisData.openRuntime(pack.toFile()); + IrisDimension dimension = data.getDimensionLoader().load("main"); + if (transformed) { + IrisTerrainTransform descriptor = new IrisTerrainTransform().setId("test").setVersion("1"); + dimension.setTerrainTransform(descriptor); + TerrainTransformRegistry registry = mock(TerrainTransformRegistry.class); + when(registry.resolve(descriptor)).thenReturn(mock(TerrainTransformer.class)); + registries.when(TerrainTransformRegistry::discover).thenReturn(registry); + } + doReturn(data).when(engine).getData(); + doReturn(dimension).when(engine).getDimension(); + doReturn(data.getBiomeLoader().load("plain")).when(engine).getFocus(); + doReturn(384).when(engine).getHeight(); + doReturn(-64).when(engine).getMinHeight(); + doReturn(320).when(engine).getMaxHeight(); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(hooks.isMainThread()).thenAnswer(ignored -> mainThread.get()); + doReturn(hooks).when(engine).getPlatformHooks(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + complex = new IrisComplex(engine); + doReturn(complex).when(engine).getComplex(); + } + + private ChunkContext context() { + return new ChunkContext(0, 0, complex, 7L, false, ChunkContext.PrefillPlan.NONE, null); + } + + private void installStack() { + DimensionStackContext stack = mock(DimensionStackContext.class); + when(stack.getStackTerrainHeight(0, 0)).thenReturn(40); + when(stack.getStackTopHeight(0, 0)).thenReturn(50); + doReturn(stack).when(engine).getDimensionStackContext(); + } + + @Override + public void close() { + try { + complex.close(); + data.close(); + } finally { + runtimes.close(); + registries.close(); + blocks.close(); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/UpperDimensionTransitionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/UpperDimensionTransitionTest.java index 4c49a09c5..b8ca1efa2 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/UpperDimensionTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/UpperDimensionTransitionTest.java @@ -42,7 +42,7 @@ public class UpperDimensionTransitionTest { Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110); when(complex.naturalTerrainColumn(-17, 8)).thenReturn(terrain); when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 110D)); - when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); Engine engine = mock(Engine.class); when(engine.getDimension()).thenReturn(dimension); when(engine.getComplex()).thenReturn(complex); @@ -63,7 +63,7 @@ public class UpperDimensionTransitionTest { when(dimension.getUpperDimensionGap()).thenReturn(32); IrisComplex complex = mock(IrisComplex.class); when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 139D)); - when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 75D + 4D * x)); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 75D + 4D * x)); TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); when(complex.getTransitionGenerationPlan()).thenReturn(plan); Engine engine = mock(Engine.class); @@ -86,7 +86,7 @@ public class UpperDimensionTransitionTest { when(dimension.getUpperDimensionGap()).thenReturn(32); IrisComplex complex = mock(IrisComplex.class); when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); - when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); when(complex.getTransitionGenerationPlan()).thenReturn(plan); Engine engine = mock(Engine.class); @@ -126,4 +126,23 @@ public class UpperDimensionTransitionTest { assertThrows(IllegalArgumentException.class, () -> new UpperDimensionContext.CeilingLayout(256, -1)); } + @Test + public void upperGapIgnoresTheFinalTransformedHostHeight() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("main"); + when(dimension.getUpperDimensionGap()).thenReturn(32); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); + when(complex.getHeightStream()).thenThrow(new AssertionError("Upper gap read final terrain height")); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(256); + + UpperDimensionContext context = UpperDimensionContext.create(engine, dimension); + + assertEquals(175, context.getEffectiveSurfaceY(0, 0)); + } + } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java index fac251a2c..2af50e4ac 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java @@ -5,6 +5,8 @@ import art.arcane.iris.generation.decoration.IrisCeilingDecorator; import art.arcane.iris.generation.decoration.IrisSurfaceDecorator; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.terrain.transform.TerrainTransformRuntime; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.iris.generation.mantle.CaveTerrainSnapshot; import art.arcane.iris.generation.mantle.EngineMantle; @@ -162,6 +164,64 @@ public class IrisNaturalCaveScanTest { } } + @Test + public void transformedCavesDecorateOnlyTheirRemainingFloorAndCeiling() throws Exception { + Fixture fixture = new Fixture(); + fixture.transform(); + Hunk output = fixture.cavity(); + output.setRaw(0, 4, 0, fixture.stone); + output.setRaw(0, 5, 0, fixture.stone); + fixture.modifier.decorateNaturalCaves(-32, 48, output, null); + assertEquals(List.of("floor:0:0:5:1", "ceiling:0:0:8:1"), fixture.calls); + assertSame(fixture.stone, output.getRaw(0, 4, 0)); + assertSame(fixture.stone, output.getRaw(0, 5, 0)); + assertSame(fixture.floor, output.getRaw(0, 6, 0)); + assertSame(fixture.ceiling, output.getRaw(0, 8, 0)); + } + + @Test + public void transformedFilledCavesAndRemovedBoundariesRemainUndecorated() throws Exception { + Fixture filled = new Fixture(); + filled.transform(); + Hunk filledOutput = filled.cavity(); + for (int y = 4; y <= 8; y++) { + filledOutput.setRaw(0, y, 0, filled.stone); + } + filled.modifier.decorateNaturalCaves(-32, 48, filledOutput, null); + assertTrue(filled.calls.isEmpty()); + assertTrue(filled.markers.isEmpty()); + + Fixture openRoof = new Fixture(); + openRoof.transform(); + Hunk openRoofOutput = openRoof.cavity(); + for (int y = 9; y < 32; y++) { + openRoofOutput.setRaw(0, y, 0, openRoof.air); + } + openRoof.modifier.decorateNaturalCaves(-32, 48, openRoofOutput, null); + assertTrue(openRoof.calls.isEmpty()); + + Fixture openFloor = new Fixture(); + openFloor.transform(); + Hunk openFloorOutput = openFloor.cavity(); + for (int y = 0; y < 4; y++) { + openFloorOutput.setRaw(0, y, 0, openFloor.air); + } + openFloor.modifier.decorateNaturalCaves(-32, 48, openFloorOutput, null); + assertTrue(openFloor.calls.isEmpty()); + } + + @Test + public void transformKeepsNaturalTerrainOpeningsOutOfCaveDecoration() throws Exception { + Fixture fixture = new Fixture(); + fixture.transform(); + doReturn(true).when(fixture.complex).hasTerrain3D(); + Terrain3DColumn natural = Terrain3DColumn.fromOccupancy(31, 32, y -> y < 4 || y > 8); + doReturn(natural).when(fixture.complex).terrainColumn(anyInt(), anyInt(), any()); + fixture.modifier.decorateNaturalCaves(-32, 48, fixture.cavity(), null); + assertTrue(fixture.calls.isEmpty()); + assertTrue(fixture.markers.isEmpty()); + } + private static void verifyScan(int width, int depth) throws Exception { Fixture fixture = new Fixture(); Hunk output = fixture.output(width, depth); @@ -236,6 +296,7 @@ public class IrisNaturalCaveScanTest { private final NativeBlockState floor = block("minecraft:moss_block", true); private final NativeBlockState ceiling = block("minecraft:calcite", true); private final IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + private final IrisComplex complex = mock(IrisComplex.class); private final Mantle mantle; private final MantleChunk chunk; private final List calls = new ArrayList<>(); @@ -252,7 +313,7 @@ public class IrisNaturalCaveScanTest { mantle = mock(Mantle.class); chunk = mock(MantleChunk.class); doReturn(engine).when(modifier).getEngine(); - doReturn(mock(IrisComplex.class)).when(modifier).getComplex(); + doReturn(complex).when(modifier).getComplex(); doReturn(32).when(engine).getHeight(); doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); doReturn(engineMantle).when(engine).getMantle(); @@ -317,6 +378,20 @@ public class IrisNaturalCaveScanTest { return output; } + private void transform() { + doReturn(mock(TerrainTransformRuntime.class)).when(complex).getTerrainTransform(); + doReturn(true).when(complex).isTerrain3DOpening(anyInt(), anyInt(), anyInt()); + } + + private Hunk cavity() { + Hunk output = Hunk.newArrayHunk(1, 32, 1); + output.fill(stone); + for (int y = 4; y <= 8; y++) { + output.setRaw(0, y, 0, air); + } + return output; + } + private Hunk output(int width, int depth) { Hunk output = Hunk.newArrayHunk(width, 32, depth); for (int x = 0; x < width; x++) { diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java index 3e92cca66..be0d3de21 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java @@ -8,6 +8,27 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class Terrain3DColumnTest { + @Test + public void occupancyPreservesCavitiesAndIsolatedBlocks() { + Terrain3DColumn column = Terrain3DColumn.fromOccupancy(7, 9, + y -> y < 3 || y == 5 || y >= 7); + assertEquals(3, column.spanCount()); + assertEquals(8, column.topY()); + assertEquals(2, column.surfaceY(1)); + assertEquals(5, column.surfaceY(5)); + assertFalse(column.isSolid(3)); + assertFalse(column.isSolid(6)); + assertTrue(column.isSolid(8)); + } + + @Test + public void emptyOccupancyHasNoSurface() { + Terrain3DColumn column = Terrain3DColumn.fromOccupancy(3, 9, y -> false); + assertEquals(-1, column.topY()); + assertEquals(-1, column.highestSolidY(8)); + assertFalse(column.isSolid(0)); + } + @Test public void displacementPreservesCavitiesAndUpperShelvesWhileKeepingBedrockGrounded() { Terrain3DColumn original = new Terrain3DColumn(40D, 25, true, new int[]{0, 30, 36, 42, 50, 54}); diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistryTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistryTest.java new file mode 100644 index 000000000..d228222e1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRegistryTest.java @@ -0,0 +1,107 @@ +package art.arcane.iris.generation.terrain.transform; + +import art.arcane.volmlib.util.collection.KMap; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class TerrainTransformRegistryTest { + @Test + public void absentConfigurationDisablesTransformation() { + assertNull(new TerrainTransformRegistry(List.of()).resolve(null)); + } + + @Test + public void selectsThePinnedProviderVersion() { + TestProvider first = new TestProvider("1", 0); + TestProvider second = new TestProvider("2", 16); + TerrainTransformRegistry registry = new TerrainTransformRegistry(List.of(first, second)); + + assertSame(first.transformer, registry.resolve(descriptor("1"))); + assertSame(second.transformer, registry.resolve(descriptor("2"))); + } + + @Test + public void rejectsUnavailableProvidersAndVersions() { + TerrainTransformRegistry registry = new TerrainTransformRegistry(List.of(new TestProvider("1", 1))); + + assertThrows(IllegalStateException.class, () -> registry.resolve(descriptor("2"))); + assertThrows(IllegalStateException.class, () -> registry.resolve(descriptor("1").setId("missing"))); + } + + @Test + public void freezesSettingsBeforeCreatingTheTransformer() { + TestProvider provider = new TestProvider("1", 1); + KMap settings = new KMap<>(); + settings.put("strength", "2"); + IrisTerrainTransform descriptor = descriptor("1").setSettings(settings); + + new TerrainTransformRegistry(List.of(provider)).resolve(descriptor); + settings.put("strength", "9"); + + assertEquals("2", provider.settings.get("strength")); + assertThrows(UnsupportedOperationException.class, () -> provider.settings.put("strength", "3")); + } + + @Test + public void rejectsDuplicateProviderIdentities() { + assertThrows(IllegalArgumentException.class, () -> new TerrainTransformRegistry( + List.of(new TestProvider("1", 1), new TestProvider("1", 2)))); + } + + @Test + public void rejectsMissingIdentityAndInvalidNeighborhoodSizes() { + assertThrows(IllegalArgumentException.class, + () -> new TerrainTransformRegistry(List.of(new TestProvider(" ", 1)))); + TerrainTransformRegistry registry = new TerrainTransformRegistry(List.of(new TestProvider("1", 1))); + assertThrows(IllegalArgumentException.class, () -> registry.resolve(descriptor(" "))); + assertThrows(IllegalArgumentException.class, () -> registry.resolve(descriptor("1").setId(""))); + assertThrows(IllegalArgumentException.class, () -> new TerrainTransformRegistry( + List.of(new TestProvider("1", -1))).resolve(descriptor("1"))); + assertThrows(IllegalArgumentException.class, () -> new TerrainTransformRegistry( + List.of(new TestProvider("1", 17))).resolve(descriptor("1"))); + } + + private static IrisTerrainTransform descriptor(String version) { + return new IrisTerrainTransform().setId("test").setVersion(version); + } + + private static final class TestProvider implements TerrainTransformProvider { + private final String version; + private final TerrainTransformer transformer; + private Map settings; + + private TestProvider(String version, int radius) { + this.version = version; + this.transformer = new TestTransformer(radius); + } + + @Override + public String id() { + return "test"; + } + + @Override + public String version() { + return version; + } + + @Override + public TerrainTransformer create(Map settings) { + this.settings = settings; + return transformer; + } + } + + private record TestTransformer(int radius) implements TerrainTransformer { + @Override + public void transform(TerrainTransformContext context) { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntimeTest.java new file mode 100644 index 000000000..be226e204 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/transform/TerrainTransformRuntimeTest.java @@ -0,0 +1,338 @@ +package art.arcane.iris.generation.terrain.transform; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineMode; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.Terrain3DColumn; +import art.arcane.iris.world.history.FloatingBiomeOverlay; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class TerrainTransformRuntimeTest { + @Test + public void reusesTerrainAndCopiesOutputMetadata() { + try (Fixture fixture = new Fixture()) { + AtomicInteger transforms = new AtomicInteger(); + TerrainTransformRuntime runtime = fixture.runtime(0, context -> { + transforms.incrementAndGet(); + context.set(0, 2, 0, fixture.air); + context.set(0, 5, 0, fixture.stone); + context.set(0, 6, 0, fixture.water); + }); + assertEquals(Integer.MIN_VALUE, runtime.readyHeight(0, 0, true)); + assertEquals(Integer.MIN_VALUE, runtime.readyFluidHeight(0, 0)); + assertNull(runtime.readyBlock(0, 0, 0)); + assertNull(runtime.readyColumn(0, 0)); + assertEquals(5, runtime.height(0, 0, true)); + assertEquals(6, runtime.height(0, 0, false)); + assertEquals(6, runtime.fluidHeight(0, 0)); + assertEquals(6, runtime.readyFluidHeight(0, 0)); + assertSame(fixture.air, runtime.block(0, 2, 0)); + assertSame(fixture.stone, runtime.readyBlock(0, 5, 0)); + assertEquals(5, runtime.column(0, 0).topY()); + assertSame(runtime.column(0, 0), runtime.readyColumn(0, 0)); + Hunk blocks = Hunk.newArrayHunk(16, 8, 16); + Hunk biomes = Hunk.newArrayHunk(16, 8, 16); + ChunkContext first = fixture.context(0); + runtime.generate(0, 0, blocks, biomes, false, first); + assertEquals(5, first.getRoundedHeight(0, 0)); + assertSame(fixture.stone, blocks.getRaw(0, 5, 0)); + assertSame(fixture.biome, biomes.getRaw(0, 2, 0)); + blocks.setRaw(0, 5, 0, fixture.air); + first.getFloatingBiomes().retainHighestSurfaces((x, z) -> -1); + ChunkContext second = fixture.context(0); + runtime.generate(0, 0, blocks, biomes, false, second); + assertSame(fixture.stone, blocks.getRaw(0, 5, 0)); + assertNotSame(first.getFloatingBiomes(), second.getFloatingBiomes()); + assertEquals(2, second.getFloatingBiomes().surfaceYAt(1, 1)); + assertEquals(1, fixture.generations.get()); + assertEquals(1, transforms.get()); + assertEquals(5, runtime.readyHeight(0, 0, true)); + runtime.close(); + assertThrows(IllegalStateException.class, () -> runtime.height(0, 0, true)); + } + } + + @Test + public void neighboringReadsAlwaysUseOriginalTerrainInEitherOrder() { + try (Fixture fixture = new Fixture()) { + TerrainTransformer transformer = fixture.transformer(16, context -> { + int neighbor = context.blockX() == 0 ? 16 : 0; + assertSame(fixture.stone, context.original(neighbor, -2, 0)); + assertSame(fixture.air, context.original(neighbor, -5, 0)); + context.set(0, 2, 0, fixture.air); + }); + TerrainTransformRuntime forward = new TerrainTransformRuntime(new TerrainTransformRuntime.Options(fixture.engine, fixture.complex), transformer); + TerrainTransformRuntime backward = new TerrainTransformRuntime(new TerrainTransformRuntime.Options(fixture.engine, fixture.complex), transformer); + assertEquals(1, forward.height(0, 0, true)); + assertEquals(1, forward.height(16, 0, true)); + assertEquals(1, backward.height(16, 0, true)); + assertEquals(1, backward.height(0, 0, true)); + forward.close(); + backward.close(); + } + } + + @Test + public void rejectsReadsBeyondDeclaredRadiusAndWritesBeyondChunk() { + try (Fixture fixture = new Fixture()) { + TerrainTransformRuntime runtime = fixture.runtime(1, context -> { + assertThrows(IllegalArgumentException.class, () -> context.original(17, -2, 0)); + assertThrows(IllegalArgumentException.class, () -> context.original(-2, -2, 0)); + assertThrows(IllegalArgumentException.class, () -> context.set(16, 0, 0, fixture.stone)); + assertThrows(IllegalArgumentException.class, () -> context.set(0, 8, 0, fixture.stone)); + assertSame(fixture.stone, context.original(-1, -2, 0)); + assertEquals(-4, context.minY()); + assertEquals(8, context.height()); + }); + assertEquals(2, runtime.height(0, 0, true)); + runtime.close(); + } + } + + @Test + public void preservesHistoricalColumnsAndCountsWaterloggedFluid() { + try (Fixture fixture = new Fixture()) { + when(fixture.complex.allowsMantleWrite(0, 0)).thenReturn(false); + NativeBlockState wetSlab = mock(NativeBlockState.class); + when(wetSlab.isWaterLogged()).thenReturn(true); + TerrainTransformRuntime runtime = fixture.runtime(0, context -> { + context.set(0, 5, 0, fixture.stone); + context.set(1, 5, 0, wetSlab); + }); + assertEquals(2, runtime.height(0, 0, true)); + assertEquals(5, runtime.height(1, 0, true)); + assertEquals(5, runtime.fluidHeight(1, 0)); + assertEquals(-1, runtime.fluidHeight(0, 0)); + runtime.close(); + } + } + + @Test + public void concurrentNeighborTransformsDoNotDependOnEachOther() throws Exception { + try (Fixture fixture = new Fixture()) { + CountDownLatch entered = new CountDownLatch(2); + TerrainTransformRuntime runtime = fixture.runtime(16, context -> { + entered.countDown(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + int neighbor = context.blockX() == 0 ? 16 : 0; + context.set(0, 4, 0, context.original(neighbor, -2, 0)); + }); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future first = workers.submit(() -> runtime.height(0, 0, true)); + Future second = workers.submit(() -> runtime.height(16, 0, true)); + assertEquals(Integer.valueOf(4), first.get(10, TimeUnit.SECONDS)); + assertEquals(Integer.valueOf(4), second.get(10, TimeUnit.SECONDS)); + assertEquals(2, fixture.generations.get()); + } finally { + workers.shutdownNow(); + runtime.close(); + } + } + } + + @Test + public void sparseColumnQueriesAvoidFullTransformationAndReuseOccupancy() { + try (Fixture fixture = new Fixture()) { + AtomicInteger fullCalls = new AtomicInteger(); + AtomicInteger columnCalls = new AtomicInteger(); + TerrainTransformRuntime runtime = fixture.columnRuntime(0, (context, x, z) -> { + columnCalls.incrementAndGet(); + context.set(x, 2, z, fixture.air); + context.set(x, 5, z, fixture.stone); + context.set(x, 6, z, fixture.water); + assertThrows(IllegalArgumentException.class, () -> context.set((x + 1) & 15, 0, z, fixture.stone)); + assertThrows(IllegalArgumentException.class, () -> context.set(x, 0, (z + 1) & 15, fixture.stone)); + }, context -> fullCalls.incrementAndGet()); + assertEquals(Integer.MIN_VALUE, runtime.readyHeight(3, 5, true)); + assertEquals(5, runtime.height(3, 5, true)); + assertEquals(6, runtime.height(3, 5, false)); + assertEquals(6, runtime.fluidHeight(3, 5)); + assertEquals(5, runtime.readyHeight(3, 5, true)); + assertEquals(6, runtime.readyHeight(3, 5, false)); + assertEquals(6, runtime.readyFluidHeight(3, 5)); + assertEquals(5, runtime.column(3, 5).topY()); + assertSame(runtime.column(3, 5), runtime.readyColumn(3, 5)); + assertNull(runtime.readyBlock(3, 5, 5)); + assertEquals(Integer.MIN_VALUE, runtime.readyHeight(4, 5, true)); + assertEquals(1, columnCalls.get()); + assertEquals(0, fullCalls.get()); + assertEquals(1, fixture.generations.get()); + runtime.close(); + } + } + + @Test + public void columnPredictionMatchesBoundaryGenerationAndFullOutputTakesPrecedence() { + try (Fixture fixture = new Fixture()) { + AtomicInteger fullCalls = new AtomicInteger(); + AtomicInteger columnCalls = new AtomicInteger(); + TerrainColumnTransformer operation = (context, x, z) -> { + NativeBlockState neighbor = context.original(context.blockX() + x + 1, + context.minY() + 2, context.blockZ() + z + 1); + context.set(x, 2, z, fixture.air); + context.set(x, 4, z, neighbor); + context.set(x, 5, z, fixture.water); + }; + TerrainTransformRuntime runtime = fixture.columnRuntime(1, (context, x, z) -> { + columnCalls.incrementAndGet(); + operation.transform(context, x, z); + }, context -> { + fullCalls.incrementAndGet(); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + operation.transform(context, x, z); + } + } + }); + assertEquals(4, runtime.height(-1, 15, true)); + assertEquals(5, runtime.height(-1, 15, false)); + Terrain3DColumn predicted = runtime.column(-1, 15); + assertEquals(2, fixture.generations.get()); + assertEquals(0, fullCalls.get()); + Hunk blocks = Hunk.newArrayHunk(16, 8, 16); + runtime.generate(-16, 0, blocks, Hunk.newArrayHunk(16, 8, 16), false, fixture.context(-16)); + assertEquals(1, fullCalls.get()); + assertEquals(1, columnCalls.get()); + assertEquals(4, runtime.height(-1, 15, true)); + assertEquals(5, runtime.fluidHeight(-1, 15)); + assertEquals(predicted, runtime.column(-1, 15)); + assertNotSame(predicted, runtime.column(-1, 15)); + assertSame(runtime.column(-1, 15), runtime.readyColumn(-1, 15)); + assertSame(fixture.stone, runtime.readyBlock(-1, 4, 15)); + assertSame(fixture.water, blocks.getRaw(15, 5, 15)); + runtime.close(); + } + } + + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final EngineMode mode = mock(EngineMode.class); + private final NativeBlockState air = mock(NativeBlockState.class); + private final NativeBlockState stone = mock(NativeBlockState.class); + private final NativeBlockState water = mock(NativeBlockState.class); + private final NativeBiome biome = mock(NativeBiome.class); + private final AtomicInteger generations = new AtomicInteger(); + private final MockedStatic states = mockStatic(B.class); + + private Fixture() { + states.when(() -> B.getState("AIR")).thenReturn(air); + when(air.isAir()).thenReturn(true); + when(water.isFluid()).thenReturn(true); + when(engine.getComplex()).thenReturn(complex); + when(engine.getMode()).thenReturn(mode); + when(engine.getHeight()).thenReturn(8); + when(engine.getMinHeight()).thenReturn(-4); + when(engine.getGenerationSessionId()).thenReturn(7L); + when(complex.getRawHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 2)); + when(complex.getTrueBiomeStream()).thenReturn(mock(ProceduralStream.class)); + when(complex.getRockStream()).thenReturn(mock(ProceduralStream.class)); + when(complex.getFluidStream()).thenReturn(mock(ProceduralStream.class)); + when(complex.getRegionStream()).thenReturn(mock(ProceduralStream.class)); + when(complex.allowsMantleWrite(anyInt(), anyInt())).thenReturn(true); + doAnswer(invocation -> { + generations.incrementAndGet(); + Hunk blocks = invocation.getArgument(2); + Hunk biomes = invocation.getArgument(3); + ChunkContext context = invocation.getArgument(5); + assertTrue(context.isNaturalTerrain()); + assertSame(context, IrisContext.require().getChunkContext()); + assertEquals(7L, IrisContext.require().getGenerationSessionId()); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y <= 2; y++) { + blocks.setRaw(x, y, z, stone); + biomes.setRaw(x, y, z, biome); + } + } + } + context.floatingBiomes(8).record(1, 2, 1, new FloatingBiomeOverlay.Identity("island", "region")); + return null; + }).when(mode).generateTerrain(anyInt(), anyInt(), any(), any(), anyBoolean(), any()); + } + + private TerrainTransformRuntime runtime(int radius, Consumer action) { + return new TerrainTransformRuntime(new TerrainTransformRuntime.Options(engine, complex), transformer(radius, action)); + } + + private TerrainTransformer transformer(int radius, Consumer action) { + return new TerrainTransformer() { + @Override + public int radius() { + return radius; + } + + @Override + public void transform(TerrainTransformContext context) { + action.accept(context); + } + }; + } + + private TerrainTransformRuntime columnRuntime(int radius, TerrainColumnTransformer column, + Consumer full) { + TerrainTransformer transformer = new TerrainTransformer() { + @Override + public int radius() { + return radius; + } + + @Override + public void transform(TerrainTransformContext context) { + full.accept(context); + } + + @Override + public TerrainColumnTransformer columnTransformer() { + return column; + } + }; + return new TerrainTransformRuntime(new TerrainTransformRuntime.Options(engine, complex), transformer); + } + + private ChunkContext context(int x) { + return new ChunkContext(x, 0, complex, 7L, true, ChunkContext.PrefillPlan.NONE, null); + } + + @Override + public void close() { + states.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 0caed6db5..e06d2568a 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "9c118c18c2f757f08ffb71d52d8549c53e4dae4939e0e030d36e4e9115794497"; + private static final String EXPECTED_SCHEMA_DIGEST = "4d37a4cf8bb02c6e334545b549433a1b75ff5b8aae0bc91cd8b21cd8f62307c0"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/structure/placement/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/structure/placement/IrisStructureLocatorContractTest.java index 63bcec1e0..edef80dbc 100644 --- a/core/src/test/java/art/arcane/iris/structure/placement/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/structure/placement/IrisStructureLocatorContractTest.java @@ -420,7 +420,7 @@ public class IrisStructureLocatorContractTest { assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); assertFalse(result.found()); - verify(engine, times(8_192)).getComplex(); + verify(engine, times(16_384)).getComplex(); } @Test