From 8c3e5d547bcc727f212e91c809429eeb7d4fd73b Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 27 Sep 2026 15:31:31 -0400 Subject: [PATCH] Reworked terrain transitions - New terrain heights meet saved boundaries while retaining local relief - Surface water levels approach saved fluid heights across the band - Cave and physical biome matching use a four-block seam patch - Terrain generates once without speculative neighbor containment - Bounded caches serve transition membership and boundary lookups - Saved biome writes honor the selected durability policy - Release labels and presentation code leave generation identity unchanged - Unfinished objects retain blocks and metadata across pack changes - Whole structure assemblies preserve pending excavation and foundations - New placements keep pending historical pieces clear - Transition probes cover cutovers and retained object fragments --- .../IrisChunkGeneratorHeightCacheTest.java | 67 +-- .../IrisChunkGeneratorHeightCacheTest.java | 67 +-- .../iris/modded/IrisModdedChunkGenerator.java | 3 +- .../modded/ModdedNativeStructurePolicy.java | 3 +- .../modded/ModdedTerrainHeightQueryTest.java | 40 ++ .../buildtools/GenerationRevisionScope.java | 4 + .../java/GenerationBuildRevisionTest.java | 25 + .../java/GenerationRevisionScopeTest.java | 23 + .../iris/generation/context/ChunkContext.java | 9 - .../generation/decoration/IrisSugarCane.java | 8 - .../iris/generation/mantle/EngineMantle.java | 39 -- .../FloatingObjectPlacementTransaction.java | 45 +- .../mantle/IrisStructureComponent.java | 116 ++-- .../mantle/MantleFloatingObjectComponent.java | 51 +- .../mantle/MantleObjectComponent.java | 36 +- .../iris/generation/mantle/MantleWriter.java | 196 +++---- .../mantle/ObjectContinuationBundle.java | 178 ++++++ .../mantle/ObjectContinuationPersistence.java | 77 +++ .../mantle/ObjectDestinationTransaction.java | 24 +- .../generation/mantle/ObjectPassPlacer.java | 7 + .../generation/mantle/ObjectSourcePlan.java | 115 ++++ .../runtime/DimensionTerrainContext.java | 4 +- .../iris/generation/runtime/Engine.java | 10 - .../iris/generation/runtime/EngineMode.java | 133 ++++- .../iris/generation/runtime/IrisComplex.java | 196 ++++--- .../iris/generation/runtime/IrisEngine.java | 16 +- .../runtime/ResolvedTerrainProvider.java | 261 --------- .../generation/stage/IrisBiomeActuator.java | 6 +- .../stage/IrisDimensionStackActuator.java | 3 +- .../stage/IrisFloatingChildBiomeModifier.java | 4 +- .../stage/IrisTerrainNormalActuator.java | 2 +- .../stage/IrisTransitionGeometryActuator.java | 5 +- .../generation/terrain/Terrain3DColumn.java | 24 + .../nms/BukkitStructureStagePolicy.java | 3 +- .../bukkit/nms/BukkitTerrainColumnPolicy.java | 4 +- .../nativegen/ImportedFeaturePolicy.java | 3 +- .../iris/structure/object/IObjectPlacer.java | 7 - .../object/IrisStaticObjectLayer.java | 142 ++++- .../placement/IrisStructureLocator.java | 10 +- .../iris/world/history/GenerationBlend.java | 17 - .../iris/world/history/GenerationHistory.java | 11 +- .../GenerationHistoryRuntimeRouter.java | 90 ++- .../world/history/GenerationKernelV1.java | 2 +- .../history/GenerationSemanticCapture.java | 4 +- .../history/GenerationSemanticQueries.java | 4 +- .../HistoricalObjectContinuations.java | 120 ++++ .../iris/world/history/SavedBiomeStore.java | 16 +- .../TerrainBoundarySignatureStore.java | 73 +-- .../world/history/TransitionBandIndex.java | 93 ++++ .../history/TransitionBoundarySampler.java | 12 +- .../history/TransitionDisplacementField.java | 252 +++++++++ .../history/TransitionFluidContainment.java | 149 ----- .../history/TransitionGenerationPlan.java | 24 +- .../history/TransitionGeometryBlender.java | 80 +-- .../storage/matter/IrisMatterSupport.java | 1 + .../matter/ObjectContinuationMatter.java | 36 ++ ...loatingObjectPlacementTransactionTest.java | 63 ++- .../MantleObjectComponentCaveAnchorTest.java | 16 - .../mantle/MantleWriterOverlayTest.java | 11 + .../mantle/ObjectContinuationTest.java | 271 +++++++++ .../ObjectDestinationTransactionTest.java | 28 - ...st.java => TerrainPlacementQueryTest.java} | 54 +- .../DimensionTerrain3DCompositionTest.java | 13 +- .../EngineModeTerrainGenerationTest.java | 268 +++++++++ .../IrisComplexHydrologyNoiseCacheTest.java | 1 - .../IrisComplexNaturalBiomeReuseTest.java | 15 + .../runtime/IrisComplexTransitionTest.java | 156 +++++- .../runtime/ResolvedTerrainProviderTest.java | 517 ------------------ .../runtime/UpperDimensionTransitionTest.java | 11 +- .../IrisBiomeActuatorCoordinateTest.java | 8 +- .../IrisTransitionGeometryActuatorTest.java | 10 +- .../terrain/Terrain3DColumnTest.java | 55 ++ .../terrain/Terrain3DSurfaceSlopeTest.java | 2 +- .../object/IrisStaticObjectLayerTest.java | 212 +++++++ .../IrisStructureLocatorContractTest.java | 35 ++ .../DenseTransitionGeometryReference.java | 107 +--- .../GenerationHistoryRecoveryTest.java | 55 ++ .../world/history/GenerationHistoryTest.java | 38 ++ .../GenerationSemanticQueriesTest.java | 6 +- .../GenerationUnpublishedHistoryTest.java | 4 +- .../HistoricalObjectContinuationsTest.java | 114 ++++ .../history/SavedBiomeStoreWriteTest.java | 30 + .../TerrainBoundarySignatureStoreTest.java | 44 ++ .../history/TransitionBandIndexTest.java | 199 +++++++ .../TransitionDisplacementFieldTest.java | 134 +++++ .../TransitionFluidContainmentTest.java | 166 ------ .../history/TransitionGenerationPlanTest.java | 8 +- .../TransitionGeometryBlenderTest.java | 34 +- .../history/TransitionGeometryCursorTest.java | 2 +- probe/build.gradle | 27 +- .../art/arcane/iris/probe/StubPlatform.java | 5 +- .../arcane/iris/probe/TransitionProbe.java | 405 ++++++++++++++ .../probe/ObjectContinuationProbeTest.java | 414 ++++++++++++++ .../probe/TransitionFixtureProbeTest.java | 347 ++++++++++++ .../iris/probe/TransitionProbeTest.java | 76 +++ .../boundary-structure/biomes/terrain.json | 21 + .../dimensions/transition.json | 49 ++ .../boundary-structure/generators/flat.json | 15 + .../jigsaw-pieces/boundary.json | 4 + .../jigsaw-pools/boundary.json | 8 + .../objects/buildings/boundary.iob | Bin 0 -> 885 bytes .../boundary-structure/regions/terrain.json | 12 + .../structures/boundary.json | 5 + .../boundary-tree/biomes/terrain.json | 38 ++ .../boundary-tree/dimensions/transition.json | 20 + .../boundary-tree/generators/flat.json | 15 + .../boundary-tree/objects/trees/boundary.iob | Bin 0 -> 382 bytes .../boundary-tree/regions/terrain.json | 12 + .../calcite-mountain/biomes/terrain.json | 21 + .../dimensions/transition.json | 28 + .../calcite-mountain/generators/flat.json | 15 + .../calcite-mountain/regions/terrain.json | 12 + .../cave-lowland/biomes/terrain.json | 21 + .../cave-lowland/dimensions/transition.json | 46 ++ .../cave-lowland/generators/flat.json | 15 + .../cave-lowland/regions/terrain.json | 12 + .../granite-lowland/biomes/terrain.json | 21 + .../dimensions/transition.json | 28 + .../granite-lowland/generators/flat.json | 15 + .../granite-lowland/regions/terrain.json | 12 + .../lowland/biomes/terrain.json | 21 + .../lowland/dimensions/transition.json | 20 + .../lowland/generators/flat.json | 15 + .../lowland/regions/terrain.json | 12 + .../mountain/biomes/terrain.json | 21 + .../mountain/dimensions/transition.json | 20 + .../mountain/generators/flat.json | 15 + .../mountain/regions/terrain.json | 12 + .../ocean-highwater/biomes/terrain.json | 21 + .../dimensions/transition.json | 20 + .../ocean-highwater/generators/flat.json | 15 + .../ocean-highwater/regions/terrain.json | 12 + .../ocean/biomes/terrain.json | 21 + .../ocean/dimensions/transition.json | 20 + .../ocean/generators/flat.json | 15 + .../ocean/regions/terrain.json | 12 + .../ripple-mountain/biomes/terrain.json | 21 + .../dimensions/transition.json | 20 + .../ripple-mountain/expressions/ripple.json | 3 + .../ripple-mountain/generators/flat.json | 15 + .../ripple-mountain/regions/terrain.json | 12 + .../slope-lowland/biomes/terrain.json | 21 + .../slope-lowland/dimensions/transition.json | 20 + .../slope-lowland/expressions/slope.json | 3 + .../slope-lowland/generators/flat.json | 15 + .../slope-lowland/regions/terrain.json | 12 + .../slope-mountain/biomes/terrain.json | 21 + .../slope-mountain/dimensions/transition.json | 20 + .../slope-mountain/expressions/slope.json | 3 + .../slope-mountain/generators/flat.json | 15 + .../slope-mountain/regions/terrain.json | 12 + .../volumetric-lowland/biomes/terrain.json | 31 ++ .../dimensions/transition.json | 20 + .../volumetric-lowland/generators/flat.json | 15 + .../volumetric-lowland/regions/terrain.json | 12 + .../volumetric-mountain/biomes/terrain.json | 31 ++ .../dimensions/transition.json | 20 + .../volumetric-mountain/generators/flat.json | 15 + .../volumetric-mountain/regions/terrain.json | 12 + 159 files changed, 6092 insertions(+), 1905 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTerrainHeightQueryTest.java create mode 100644 core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationBundle.java create mode 100644 core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/ResolvedTerrainProvider.java create mode 100644 core/src/main/java/art/arcane/iris/world/history/HistoricalObjectContinuations.java create mode 100644 core/src/main/java/art/arcane/iris/world/history/TransitionBandIndex.java create mode 100644 core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java delete mode 100644 core/src/main/java/art/arcane/iris/world/history/TransitionFluidContainment.java create mode 100644 core/src/main/java/art/arcane/iris/world/storage/matter/ObjectContinuationMatter.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java rename core/src/test/java/art/arcane/iris/generation/mantle/{ResolvedTerrainPlacementTest.java => TerrainPlacementQueryTest.java} (72%) create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/ResolvedTerrainProviderTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/HistoricalObjectContinuationsTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/TransitionBandIndexTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java delete mode 100644 core/src/test/java/art/arcane/iris/world/history/TransitionFluidContainmentTest.java create mode 100644 probe/src/main/java/art/arcane/iris/probe/TransitionProbe.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/ObjectContinuationProbeTest.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/TransitionFixtureProbeTest.java create mode 100644 probe/src/test/java/art/arcane/iris/probe/TransitionProbeTest.java create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pieces/boundary.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pools/boundary.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/objects/buildings/boundary.iob create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/boundary-structure/structures/boundary.json create mode 100644 probe/src/test/resources/transition-packs/boundary-tree/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/boundary-tree/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/boundary-tree/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/boundary-tree/objects/trees/boundary.iob create mode 100644 probe/src/test/resources/transition-packs/boundary-tree/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/calcite-mountain/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/calcite-mountain/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/calcite-mountain/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/calcite-mountain/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/cave-lowland/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/cave-lowland/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/cave-lowland/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/cave-lowland/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/granite-lowland/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/granite-lowland/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/granite-lowland/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/granite-lowland/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/lowland/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/lowland/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/lowland/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/lowland/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/mountain/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/mountain/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/mountain/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/mountain/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ocean-highwater/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ocean-highwater/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/ocean-highwater/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/ocean-highwater/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ocean/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ocean/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/ocean/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/ocean/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ripple-mountain/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/ripple-mountain/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/ripple-mountain/expressions/ripple.json create mode 100644 probe/src/test/resources/transition-packs/ripple-mountain/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/ripple-mountain/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/slope-lowland/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/slope-lowland/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/slope-lowland/expressions/slope.json create mode 100644 probe/src/test/resources/transition-packs/slope-lowland/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/slope-lowland/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/slope-mountain/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/slope-mountain/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/slope-mountain/expressions/slope.json create mode 100644 probe/src/test/resources/transition-packs/slope-mountain/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/slope-mountain/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-lowland/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-lowland/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-lowland/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-lowland/regions/terrain.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-mountain/biomes/terrain.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-mountain/dimensions/transition.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-mountain/generators/flat.json create mode 100644 probe/src/test/resources/transition-packs/volumetric-mountain/regions/terrain.json diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java index bb54918a3..e16203fea 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java @@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_2_R1.terrain.NativeChunkGenerator; import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeTerrainColumns; import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy; -import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy; import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.LevelHeightAccessor; @@ -24,7 +24,7 @@ import java.util.Optional; import java.util.OptionalInt; import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; public final class IrisChunkGeneratorHeightCacheTest { @Rule @@ -44,68 +45,72 @@ public final class IrisChunkGeneratorHeightCacheTest { } @Test - public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception { + public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception { Fixture fixture = fixture(); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); - assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); - when(fixture.complex().resolvedTerrainColumn(-1, -33)) - .thenThrow(new IllegalStateException("Full terrain was evicted")); - + when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4); for (int index = 0; index < 100; index++) { assertEquals(-11, fixture.generator().getBaseHeight( -1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); } - verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33); + verify(fixture.engine(), times(100)).getHeight(-1, -33, false); when(fixture.engine().getCacheID()).thenReturn(2); - doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33); - assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); - verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33); + when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7); + assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); + verify(fixture.engine(), never()).getComplex(); + verify(fixture.engine(), never()).getMode(); } @Test - public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception { + public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception { Fixture fixture = fixture(); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); - when(fixture.complex().resolvedTerrainColumn(0, 0)) - .thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature())); when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25); + when(fixture.engine().getHeight(0, 0, true)).thenReturn(8); assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); verify(fixture.engine(), times(2)).getHeight(0, 0, false); - verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0); + verify(fixture.engine(), times(1)).getHeight(0, 0, true); + verify(fixture.engine(), never()).getComplex(); + verify(fixture.engine(), never()).getMode(); } @Test public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception { - Fixture fixture = fixture(); + NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class); + NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class); + when(policy.openQuery(any())).thenReturn(session); + when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn(); + NativeChunkGenerator generator = generator(policy); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); for (int repeat = 0; repeat < 2; repeat++) { - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); - assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, + assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); + assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null)); + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null)); + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, LevelHeightAccessor.create(-16, 2), null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, LevelHeightAccessor.create(-14, 3), null)); } - verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0); + verify(session, times(6)).resolvedColumn(); } private static Fixture fixture() throws Exception { Engine engine = mock(Engine.class); - IrisComplex complex = mock(IrisComplex.class); - when(engine.getComplex()).thenReturn(complex); when(engine.getCacheID()).thenReturn(1); when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop()); - when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature())); + return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine); + } + + private static NativeChunkGenerator generator(NativeTerrainColumnPolicy policy) throws Exception { NativeChunkGenerator generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS); - set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null))); - return new Fixture(generator, engine, complex); + set(generator, "terrainColumns", new NativeTerrainColumns(policy)); + return generator; } private static void set(NativeChunkGenerator generator, String name, Object value) throws Exception { @@ -127,6 +132,6 @@ public final class IrisChunkGeneratorHeightCacheTest { new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry); } - private record Fixture(NativeChunkGenerator generator, Engine engine, IrisComplex complex) { + private record Fixture(NativeChunkGenerator generator, Engine engine) { } } diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java index 09a9198e3..48243703a 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java @@ -4,12 +4,12 @@ import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeChunkGenerator; import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeTerrainColumns; import art.arcane.iris.platform.bukkit.nms.BukkitTerrainColumnPolicy; -import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy; import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import net.minecraft.world.level.LevelHeightAccessor; @@ -24,7 +24,7 @@ import java.util.Optional; import java.util.OptionalInt; import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @@ -32,6 +32,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; public final class IrisChunkGeneratorHeightCacheTest { @Rule @@ -45,68 +46,72 @@ public final class IrisChunkGeneratorHeightCacheTest { } @Test - public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception { + public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception { Fixture fixture = fixture(); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); - assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); - when(fixture.complex().resolvedTerrainColumn(-1, -33)) - .thenThrow(new IllegalStateException("Full terrain was evicted")); - + when(fixture.engine().getHeight(-1, -33, false)).thenReturn(4); for (int index = 0; index < 100; index++) { assertEquals(-11, fixture.generator().getBaseHeight( -1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); } - verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33); + verify(fixture.engine(), times(100)).getHeight(-1, -33, false); when(fixture.engine().getCacheID()).thenReturn(2); - doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33); - assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); - verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33); + when(fixture.engine().getHeight(-1, -33, false)).thenReturn(7); + assertEquals(-8, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); + verify(fixture.engine(), never()).getComplex(); + verify(fixture.engine(), never()).getMode(); } @Test - public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception { + public void nativeHeightPredicatesSelectFluidOrSolidHeightWithoutGeneratingTerrain() throws Exception { Fixture fixture = fixture(); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); - when(fixture.complex().resolvedTerrainColumn(0, 0)) - .thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature())); when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25); + when(fixture.engine().getHeight(0, 0, true)).thenReturn(8); assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-7, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); verify(fixture.engine(), times(2)).getHeight(0, 0, false); - verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0); + verify(fixture.engine(), times(1)).getHeight(0, 0, true); + verify(fixture.engine(), never()).getComplex(); + verify(fixture.engine(), never()).getMode(); } @Test public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception { - Fixture fixture = fixture(); + NativeTerrainColumnPolicy policy = mock(NativeTerrainColumnPolicy.class); + NativeTerrainColumnPolicy.ColumnSession session = mock(NativeTerrainColumnPolicy.ColumnSession.class); + when(policy.openQuery(any())).thenReturn(session); + when(policy.placementKey(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + doReturn(Optional.of(signature().geometry())).when(session).resolvedColumn(); + NativeChunkGenerator generator = generator(policy); LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); for (int repeat = 0; repeat < 2; repeat++) { - assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); - assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); - assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, + assertEquals(-11, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); + assertEquals(-13, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null)); + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null)); + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, LevelHeightAccessor.create(-16, 2), null)); - assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + assertEquals(-14, generator.getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, LevelHeightAccessor.create(-14, 3), null)); } - verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0); + verify(session, times(6)).resolvedColumn(); } private static Fixture fixture() throws Exception { Engine engine = mock(Engine.class); - IrisComplex complex = mock(IrisComplex.class); - when(engine.getComplex()).thenReturn(complex); when(engine.getCacheID()).thenReturn(1); when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop()); - when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature())); + return new Fixture(generator(new BukkitTerrainColumnPolicy(engine, null)), engine); + } + + private static NativeChunkGenerator generator(NativeTerrainColumnPolicy policy) throws Exception { NativeChunkGenerator generator = mock(NativeChunkGenerator.class, CALLS_REAL_METHODS); - set(generator, "terrainColumns", new NativeTerrainColumns(new BukkitTerrainColumnPolicy(engine, null))); - return new Fixture(generator, engine, complex); + set(generator, "terrainColumns", new NativeTerrainColumns(policy)); + return generator; } private static void set(NativeChunkGenerator generator, String name, Object value) throws Exception { @@ -128,6 +133,6 @@ public final class IrisChunkGeneratorHeightCacheTest { new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry); } - private record Fixture(NativeChunkGenerator generator, Engine engine, IrisComplex complex) { + private record Fixture(NativeChunkGenerator generator, Engine engine) { } } 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 0c9248e89..1bc6e2250 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 @@ -66,7 +66,6 @@ import art.arcane.iris.world.history.TerrainNativeBlockKeys; import art.arcane.volmlib.nativelib.terrain.NativeChunkWritePolicy; -import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.world.history.NativeBiomeSpawnSelection; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.PackValidationRegistry; @@ -1086,7 +1085,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat @Override public NativeBlockColumn resolvedColumn(Engine current, int x, int z) { - return current.getComplex().resolvedTerrainColumn(x, z).map(TerrainBoundarySignature::geometry).orElse(null); + return null; } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructurePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructurePolicy.java index 39b1342bf..080577629 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructurePolicy.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructurePolicy.java @@ -102,7 +102,8 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po @Override public NativeBlockPositionPredicate protectedPositions(Engine context) { - return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()), + return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()) + .forTransition(context), context.getDimensionStackContext(), context.getMinHeight()); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTerrainHeightQueryTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTerrainHeightQueryTest.java new file mode 100644 index 000000000..cc28c2c2f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTerrainHeightQueryTest.java @@ -0,0 +1,40 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.generation.runtime.Engine; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public final class ModdedTerrainHeightQueryTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void nativeQueriesUseLiveHeightsWithoutGeneratingTerrain() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + assertNull(generator.resolvedColumn(engine, -1, -33)); + verifyNoInteractions(engine); + + when(engine.getHeight(-1, -33, true)).thenReturn(12, 18); + when(engine.getHeight(-1, -33, false)).thenReturn(24); + assertEquals(12, generator.terrainHeight(engine, -1, -33, true, true)); + assertEquals(18, generator.terrainHeight(engine, -1, -33, true, true)); + assertEquals(24, generator.terrainHeight(engine, -1, -33, false, true)); + verify(engine, never()).getComplex(); + verify(engine, never()).getMode(); + } +} diff --git a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java index de4dcc7bf..8c5ecdd3c 100644 --- a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java +++ b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java @@ -29,6 +29,10 @@ public final class GenerationRevisionScope { "core/src/main/java/art/arcane/iris/world/safeguard/Diagnostic.java", "core/src/main/java/art/arcane/iris/world/safeguard/ValueWithDiagnostics.java", "core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashRenderer.java", + "core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java", + "core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java", + "adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDebug.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandDeveloper.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPregen.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java", diff --git a/buildSrc/src/test/java/GenerationBuildRevisionTest.java b/buildSrc/src/test/java/GenerationBuildRevisionTest.java index 17ed01d8b..65aeafb08 100644 --- a/buildSrc/src/test/java/GenerationBuildRevisionTest.java +++ b/buildSrc/src/test/java/GenerationBuildRevisionTest.java @@ -33,6 +33,31 @@ public class GenerationBuildRevisionTest { GenerationBuildRevision.fingerprint(GenerationBuildRevision.capture(fixture.options(), fixture.dependencies()))); } + @Test + public void dependencyReleaseNamesAndMavenMetadataDoNotChangeGenerationIdentity() throws Exception { + Fixture fixture = fixture(); + String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision())); + Path released = fixture.root().resolve("math-2.0.jar"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(released))) { + for (Map.Entry entry : Map.of( + "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\r\nImplementation-Version: 2.0\r\nBuild-Time: 12345\r\n\r\n", + "META-INF/maven/example/math/pom.properties", "groupId=example\nartifactId=math\nversion=2.0\n", + "META-INF/maven/example/math/pom.xml", "2.0", + "example/Math.class", "same code").entrySet()) { + ZipEntry archiveEntry = new ZipEntry(entry.getKey()); + archiveEntry.setTime(1_700_000_000_000L); + output.putNextEntry(archiveEntry); + output.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + Map dependencies = Map.of("math", released); + + GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), dependencies); + assertEquals(original, GenerationBuildRevision.fingerprint( + GenerationBuildRevision.capture(fixture.options(), dependencies))); + } + @Test public void rejectsChangedAddedAndDeletedGenerationSources() throws Exception { Fixture fixture = fixture(); diff --git a/buildSrc/src/test/java/GenerationRevisionScopeTest.java b/buildSrc/src/test/java/GenerationRevisionScopeTest.java index 28603201a..2e9f45798 100644 --- a/buildSrc/src/test/java/GenerationRevisionScopeTest.java +++ b/buildSrc/src/test/java/GenerationRevisionScopeTest.java @@ -44,6 +44,25 @@ public class GenerationRevisionScopeTest { } } + @Test + public void releaseVersionAndExpandedPluginMetadataPreserveTheGenerationFingerprint() throws Exception { + Fixture fixture = fixture(); + String original = GenerationBuildRevision.fingerprint(GenerationBuildRevision.read(fixture.revision())); + for (String version : List.of("4.1.2-26.2", "4.1.3-26.2", "4.1.3-26.2-build-123")) { + write(fixture.root().resolve("gradle.properties"), "irisVersion=" + version + "\n"); + write(fixture.root().resolve("adapters/bukkit/plugin/src/main/resources/paper-plugin.yml"), + "name: Iris\nversion: " + version + "\n"); + write(fixture.root().resolve("adapters/bukkit/plugin/build/resources/main/paper-plugin.yml"), + "name: Iris\nversion: " + version + "\n"); + write(fixture.root().resolve("adapters/fabric/build/resources/main/fabric.mod.json"), + "{\"id\":\"iris\",\"version\":\"" + version + "\"}\n"); + + GenerationBuildRevision.verifySnapshot(fixture.root(), fixture.revision(), fixture.dependencies()); + assertEquals(original, GenerationBuildRevision.fingerprint( + GenerationBuildRevision.capture(fixture.options(), fixture.dependencies()))); + } + } + @Test public void generationChangesProduceANewBuildRevision() throws Exception { Fixture fixture = fixture(); @@ -88,6 +107,10 @@ public class GenerationRevisionScopeTest { } assertFalse(manifest.sources().containsKey(ENGINE_ROOT + "EngineDiagnostics.java")); assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandIris.java")); + assertFalse(manifest.sources().containsKey(COMMAND_ROOT + "CommandDebug.java")); + assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashComposer.java")); + assertFalse(manifest.sources().containsKey("core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java")); + assertFalse(manifest.sources().containsKey("adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java")); } @Test diff --git a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java index b092ffbdd..b3e7509f4 100644 --- a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java @@ -21,7 +21,6 @@ import java.util.concurrent.CompletableFuture; public class ChunkContext { private boolean naturalTerrain = true; - private boolean speculativeTerrain; private Hunk terrainBiomes; private FloatingBiomeOverlay floatingBiomes; private final int x; @@ -164,14 +163,6 @@ public class ChunkContext { } } - public boolean isSpeculativeTerrain() { - return speculativeTerrain; - } - - public void beginSpeculativeTerrain() { - speculativeTerrain = true; - } - public boolean isNaturalTerrain() { return naturalTerrain; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSugarCane.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSugarCane.java index f3206802a..2962be290 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSugarCane.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSugarCane.java @@ -2,17 +2,14 @@ package art.arcane.iris.generation.decoration; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; -import java.util.Optional; final class IrisSugarCane { private static final int[][] NEIGHBORS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; @@ -74,11 +71,6 @@ final class IrisSugarCane { private static boolean plannedWater(Engine engine, int x, int y, int z) { IrisComplex complex = engine.getComplex(); - Optional resolved = complex.resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - String key = resolved.get().geometry().voxelAt(y + engine.getMinHeight()).stateKey(); - return supportsAdjacent(IrisPlatforms.get().registries().blockOrNull(key)); - } HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z); HydrologyColumnLayer fluid = sample == null ? null : sample.primarySurfaceFluidLayerOrNull(); if (fluid != 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 b6241b4c0..8300c5afb 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 @@ -21,8 +21,6 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.IrisComplex; -import art.arcane.iris.world.history.TerrainBoundarySignature; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.generation.runtime.DimensionStackContext; import art.arcane.iris.generation.runtime.DimensionStackLayout; import art.arcane.iris.generation.runtime.UpperDimensionContext; @@ -55,8 +53,6 @@ import org.jetbrains.annotations.UnmodifiableView; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.OptionalInt; import java.util.concurrent.TimeUnit; public interface EngineMantle extends MatterGenerator { @@ -112,10 +108,6 @@ public interface EngineMantle extends MatterGenerator { } default int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { - OptionalInt resolved = getComplex().resolvedTerrainHeight(x, z, ignoreFluid); - if (resolved.isPresent()) { - return resolved.getAsInt(); - } return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getFluidHeight(x, z)); } @@ -124,21 +116,6 @@ public interface EngineMantle extends MatterGenerator { } default boolean isCarved(int x, int h, int z) { - Optional resolved = getComplex().resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - PreObjectMatterCell cell = getMantle().get(x, h, z, PreObjectMatterCell.class); - if (cell != null && cell.blockCaptured()) { - NativeBlockState block = getMantle().get(x, h, z, NativeBlockState.class); - if (block != null) { - return (block.isAir() || block.isFluid()) - && resolved.get().geometry().hasSolidAbove(h + getEngine().getMinHeight()); - } - } - if (cell != null && cell.cavernCaptured()) { - return getMantle().get(x, h, z, MatterCavern.class) != null; - } - return resolved.get().geometry().isEnclosedOpenAt(h + getEngine().getMinHeight()); - } HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(getMantle(), x, h, z); if (hydrology != null) { return hydrology.carves(); @@ -149,18 +126,6 @@ public interface EngineMantle extends MatterGenerator { default NativeBlockState get(int x, int y, int z) { NativeBlockState block = getMantle().get(x, y, z, NativeBlockState.class); - Optional resolved = getComplex().resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - PreObjectMatterCell cell = getMantle().get(x, y, z, PreObjectMatterCell.class); - if (cell == null || !cell.blockCaptured()) { - String stateKey = resolved.get().geometry().voxelAt(y + getEngine().getMinHeight()).stateKey(); - NativeBlockState natural = IrisPlatforms.get().registries().blockOrNull(stateKey); - if (natural == null) { - throw new IllegalStateException("Saved terrain state is unavailable: " + stateKey); - } - return natural; - } - } return block == null ? AIR.get() : block; } @@ -177,10 +142,6 @@ public interface EngineMantle extends MatterGenerator { } default int getFluidHeight(int x, int z) { - Optional resolved = getComplex().resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - return resolved.get().fluidHeight().orElse(-1); - } return (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java index 4a419fad5..8551d0fb5 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransaction.java @@ -29,16 +29,24 @@ import art.arcane.volmlib.util.collection.KList; import org.jetbrains.annotations.Nullable; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.function.Supplier; +import java.util.Objects; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; import java.util.Map; final class FloatingObjectPlacementTransaction implements IObjectPlacer { - private final IslandObjectPlacer delegate; + private final IObjectPlacer delegate; + private final ObjectContinuationBundle.PlacementKey placementKey; private final KList mutations; private final Map bufferedBlocks; private int blockWrites; - FloatingObjectPlacementTransaction(IslandObjectPlacer delegate) { - this.delegate = delegate; + FloatingObjectPlacementTransaction(IObjectPlacer delegate, ObjectContinuationBundle.PlacementKey placementKey) { + this.delegate = Objects.requireNonNull(delegate); + this.placementKey = Objects.requireNonNull(placementKey); this.mutations = new KList<>(); this.bufferedBlocks = new HashMap<>(); } @@ -56,7 +64,8 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.REJECTED_TRANSITION; } - if (!delegate.canWriteObjectBlock(mutation.x(), mutation.y(), mutation.z())) { + if (delegate instanceof IslandObjectPlacer island + && !island.canWriteObjectBlock(mutation.x(), mutation.y(), mutation.z())) { discard(); return CommitResult.REJECTED_SUPPORT; } @@ -64,10 +73,38 @@ final class FloatingObjectPlacementTransaction implements IObjectPlacer { for (BufferedMutation mutation : mutations) { mutation.apply(delegate); } + persistContinuation(engine); discard(); return CommitResult.COMMITTED; } + private void persistContinuation(Engine engine) { + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (BufferedMutation mutation : mutations) { + minimumX = Math.min(minimumX, mutation.x()); + minimumZ = Math.min(minimumZ, mutation.z()); + maximumX = Math.max(maximumX, mutation.x()); + maximumZ = Math.max(maximumZ, mutation.z()); + } + if ((minimumX >> 4) == (maximumX >> 4) && (minimumZ >> 4) == (maximumZ >> 4)) { + return; + } + IrisMatterSupport.ensureRegistered(); + LinkedHashMap payloads = new LinkedHashMap<>(); + for (BufferedMutation mutation : mutations) { + ObjectContinuationBundle.ChunkPosition chunk = new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4); + Matter payload = payloads.computeIfAbsent(chunk, ignored -> new IrisMatter(16, engine.getHeight(), 16)); + ObjectContinuationPersistence.put(payload, mutation.x() & 15, mutation.y(), mutation.z() & 15, mutation.value()); + } + LinkedHashMap> destinations = new LinkedHashMap<>(); + payloads.forEach((chunk, payload) -> destinations.put(chunk, () -> payload)); + ObjectContinuationPersistence.persist(engine.getMantle().getMantle(), placementKey, + new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ), destinations); + } + void discard() { mutations.clear(); bufferedBlocks.clear(); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/IrisStructureComponent.java index be1cbae21..bb781f5f1 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/IrisStructureComponent.java @@ -81,12 +81,14 @@ public class IrisStructureComponent extends IrisMantleComponent { if (!complex.allowsNewGenerationChunk(x, z)) { return; } + int ordinal = 0; for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt( getEngineMantle().getEngine(), x, z)) { + int placementOrdinal = ordinal++; if (!placement.hasIrisStructures()) { continue; } - placeFromPlacement(writer, placement, x, z, complex); + placeFromPlacement(writer, placement, x, z, placementOrdinal); } } @@ -96,7 +98,7 @@ public class IrisStructureComponent extends IrisMantleComponent { IrisStructurePlacement placement, int cx, int cz, - IrisComplex complex + int ordinal ) { IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement( getEngineMantle().getEngine(), placement, cx, cz); @@ -117,16 +119,7 @@ public class IrisStructureComponent extends IrisMantleComponent { return; } IrisStructureTerrain terrain = placement.resolvedTerrain(); - int[] bounds = computePieceBounds(pieces); - int horizontalPadding = terrain.resolvedMode() == StructureTerrainMode.FORCE_CARVE - || terrain.resolvedMode() == StructureTerrainMode.BORE - ? Math.max(0, terrain.getHorizontalPadding()) - : 0; - if (bounds == null || !complex.allowsNewGenerationFootprint( - saturatedOffset(bounds[0], -horizontalPadding), - saturatedOffset(bounds[2], -horizontalPadding), - saturatedOffset(bounds[3], horizontalPadding), - saturatedOffset(bounds[5], horizontalPadding))) { + if (!IrisStructureLocator.allowsResolvedFootprint(getEngineMantle().getEngine(), resolved)) { return; } RNG rng = resolved.rng(); @@ -135,60 +128,66 @@ public class IrisStructureComponent extends IrisMantleComponent { IrisLogging.debug("[StructTrace] ASSEMBLED chunk=" + cx + "," + cz + " key=" + key + " baseY=" + baseY + " pieces=" + pieces.size()); } - if (!placement.isAnchoredUnderground()) { - clearIntersectingObjectTrees(writer, resolved); - } + try (MantleWriter.ObjectPlacementCapture capture = writer.captureObjectPlacement( + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.STRUCTURE, cx, cz, ordinal))) { + if (!placement.isAnchoredUnderground()) { + clearIntersectingObjectTrees(writer, resolved); + } - StructureTerrainMode terrainMode = terrain.resolvedMode(); - if (terrainMode == StructureTerrainMode.FORCE_CARVE) { - forceCarveStructure(writer, pieces, terrain); - } else if (terrainMode == StructureTerrainMode.BORE) { - boreStructure(writer, pieces, terrain); - } else if (terrainMode != StructureTerrainMode.PRESERVE - && terrainMode != StructureTerrainMode.SOURCE) { - throw new IllegalStateException("Iris assembly terrain mode " + terrainMode - + " is not implemented for structure '" + key + "'"); - } + StructureTerrainMode terrainMode = terrain.resolvedMode(); + if (terrainMode == StructureTerrainMode.FORCE_CARVE) { + forceCarveStructure(writer, pieces, terrain); + } else if (terrainMode == StructureTerrainMode.BORE) { + boreStructure(writer, pieces, terrain); + } else if (terrainMode != StructureTerrainMode.PRESERVE + && terrainMode != StructureTerrainMode.SOURCE) { + throw new IllegalStateException("Iris assembly terrain mode " + terrainMode + + " is not implemented for structure '" + key + "'"); + } - ObjectPlaceMode mode = structure.getPlaceMode(); - int failedPieces = 0; - IrisStructureStiltSettings stilt = placement.getStilt(); - Long2IntOpenHashMap foundationColumns = stilt == null - ? null : new Long2IntOpenHashMap(); - boolean supportNonOccluding = stilt != null && stilt.isSupportNonOccluding(); - if (placement.isAnchoredUnderground()) { - ObjectPlaceMode undergroundMode = (mode == ObjectPlaceMode.ORGANIC_STILT || mode == ObjectPlaceMode.CEILING_HANG) - ? mode : ObjectPlaceMode.STRUCTURE_PIECE; - for (PlacedStructurePiece p : pieces) { - if (placeObject(writer, structure, p, undergroundMode, p.getY(), rng, + ObjectPlaceMode mode = structure.getPlaceMode(); + int failedPieces = 0; + IrisStructureStiltSettings stilt = placement.getStilt(); + Long2IntOpenHashMap foundationColumns = stilt == null + ? null : new Long2IntOpenHashMap(); + boolean supportNonOccluding = stilt != null && stilt.isSupportNonOccluding(); + if (placement.isAnchoredUnderground()) { + ObjectPlaceMode undergroundMode = (mode == ObjectPlaceMode.ORGANIC_STILT || mode == ObjectPlaceMode.CEILING_HANG) + ? mode : ObjectPlaceMode.STRUCTURE_PIECE; + for (PlacedStructurePiece p : pieces) { + if (placeObject(writer, structure, p, undergroundMode, p.getY(), rng, + foundationColumns, supportNonOccluding) == -1) { + failedPieces++; + } + } + } else if (mode == ObjectPlaceMode.STRUCTURE_PIECE || mode == ObjectPlaceMode.FLOATING) { + for (PlacedStructurePiece p : pieces) { + if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, + foundationColumns, supportNonOccluding) == -1) { + failedPieces++; + } + } + } else if (pieces.size() == 1) { + if (placeObject(writer, structure, pieces.getFirst(), mode, -1, rng, foundationColumns, supportNonOccluding) == -1) { failedPieces++; } - } - } else if (mode == ObjectPlaceMode.STRUCTURE_PIECE || mode == ObjectPlaceMode.FLOATING) { - for (PlacedStructurePiece p : pieces) { - if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, - foundationColumns, supportNonOccluding) == -1) { - failedPieces++; + } else { + for (PlacedStructurePiece p : pieces) { + if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, + foundationColumns, supportNonOccluding) == -1) { + failedPieces++; + } } } - } else if (pieces.size() == 1) { - if (placeObject(writer, structure, pieces.getFirst(), mode, -1, rng, - foundationColumns, supportNonOccluding) == -1) { - failedPieces++; + requireAppliedPieces(resolved, cx, cz, failedPieces); + if (failedPieces == 0 && stilt != null) { + placeFoundation(writer, foundationColumns, stilt, rng, !placement.isAnchoredUnderground()); } - } else { - for (PlacedStructurePiece p : pieces) { - if (placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng, - foundationColumns, supportNonOccluding) == -1) { - failedPieces++; - } + if (failedPieces == 0) { + capture.commit(); } } - requireAppliedPieces(resolved, cx, cz, failedPieces); - if (failedPieces == 0 && stilt != null) { - placeFoundation(writer, foundationColumns, stilt, rng, !placement.isAnchoredUnderground()); - } } private void placeFoundation(MantleWriter writer, Long2IntOpenHashMap columns, @@ -558,11 +557,6 @@ public class IrisStructureComponent extends IrisMantleComponent { return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; } - private static int saturatedOffset(int coordinate, int offset) { - long result = (long) coordinate + offset; - return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, result)); - } - private int placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng, Long2IntOpenHashMap foundationColumns, boolean supportNonOccluding) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java index ebee9a7e7..bd7929e59 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java @@ -18,6 +18,7 @@ package art.arcane.iris.generation.mantle; +import java.io.UncheckedIOException; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.cache.Cache; @@ -65,6 +66,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { return; } IrisData data = getData(); + PlacementSequence placements = new PlacementSequence(); int minX = x << 4; int minZ = z << 4; RNG chunkRng = new RNG(Cache.key(x, z) + seed() + 0x0FA710BEL); @@ -122,7 +124,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { KList floating = entry.getFloatingObjects(); if (floating != null && !floating.isEmpty()) { for (IrisObjectPlacement placement : floating) { - tryPlaceFloatingChunk(writer, complex, chunkRng, data, placement, columns, minX, minZ, entry); + tryPlaceFloatingChunk(writer, complex, chunkRng, data, placement, columns, minX, minZ, entry, placements); } } @@ -135,12 +137,12 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { interior = interiorColumns(sampleResolver, columns, minX, minZ, entry, IslandObjectPlacer.AnchorFace.TOP); if (hasSurface) { for (IrisObjectPlacement placement : surface) { - tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); + tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry, placements); } } if (hasExtras) { for (IrisObjectPlacement placement : extras) { - tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); + tryPlaceAnchoredChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry, placements); } } } @@ -159,14 +161,14 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (bottom != null && !bottom.isEmpty()) { KList interior = interiorColumns(sampleResolver, columns, minX, minZ, entry, IslandObjectPlacer.AnchorFace.BOTTOM); for (IrisObjectPlacement placement : bottom) { - tryPlaceInvertedChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry); + tryPlaceInvertedChunk(writer, complex, chunkRng, data, placement, samples, sampleResolver, columns, interior, minX, minZ, entry, placements); } } } } @ChunkCoordinates - private void tryPlaceFloatingChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, KList columns, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceFloatingChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, KList columns, int minX, int minZ, IrisFloatingChildBiomes entry, PlacementSequence placements) { if (placement == null || columns == null || columns.isEmpty()) { return; } @@ -210,21 +212,32 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } int id = rng.i(0, Integer.MAX_VALUE); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction( + writer, placements.next(minX >> 4, minZ >> 4)); try { - obj.place(xx, -1, zz, writer, floatingPlacement, rng, (b, bd) -> { + int resultY = obj.place(xx, -1, zz, transaction, floatingPlacement, rng, (b, bd) -> { String marker = placementMarker(obj, id); - if (marker != null && shouldWritePlacementMarker(writer, bd, b.getX(), b.getY(), b.getZ())) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); + if (marker != null && shouldWritePlacementMarker(transaction, bd, b.getX(), b.getY(), b.getZ())) { + transaction.setData(b.getX(), b.getY(), b.getZ(), marker); } }, null, data); + if (resultY < 0) { + transaction.discard(); + } else { + transaction.commit(); + } + } catch (UncheckedIOException failure) { + transaction.discard(); + throw failure; } catch (Throwable e) { + transaction.discard(); IrisLogging.reportError(e); } } } @ChunkCoordinates - private void tryPlaceAnchoredChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceAnchoredChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry, PlacementSequence placements) { if (placement == null || columns.isEmpty()) { return; } @@ -293,7 +306,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int yv = pickTopY + 1 - fp.getLowestSolidKeyY(); IslandObjectPlacer islandPlacer = IslandObjectPlacer.top(writer, sampleProvider, entry, pickTopY); - FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer, placements.next(minX >> 4, minZ >> 4)); int id = rng.i(0, Integer.MAX_VALUE); try { @@ -308,6 +321,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } else { transaction.commit(); } + } catch (UncheckedIOException failure) { + transaction.discard(); + throw failure; } catch (Throwable e) { transaction.discard(); IrisLogging.reportError(e); @@ -316,7 +332,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private void tryPlaceInvertedChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry) { + private void tryPlaceInvertedChunk(MantleWriter writer, IrisComplex complex, RNG rng, IrisData data, IrisObjectPlacement placement, FloatingIslandSample[] samples, IslandObjectPlacer.SampleProvider sampleProvider, KList columns, KList interior, int minX, int minZ, IrisFloatingChildBiomes entry, PlacementSequence placements) { if (placement == null || columns.isEmpty()) { return; } @@ -402,7 +418,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { int yv = invertedBaseY(pickBottomY, fp, invertedRotation); IslandObjectPlacer islandPlacer = IslandObjectPlacer.bottom(writer, sampleProvider, entry, pickBottomY); - FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(islandPlacer, placements.next(minX >> 4, minZ >> 4)); int id = rng.i(0, Integer.MAX_VALUE); try { @@ -417,6 +433,9 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } else { transaction.commit(); } + } catch (UncheckedIOException failure) { + transaction.discard(); + throw failure; } catch (Throwable e) { transaction.discard(); IrisLogging.reportError(e); @@ -636,4 +655,12 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { } return radius; } + private static final class PlacementSequence { + private int ordinal; + + private ObjectContinuationBundle.PlacementKey next(int sourceX, int sourceZ) { + return new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, sourceX, sourceZ, ordinal++); + } + } + } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index faa931095..c306afb3e 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -23,9 +23,6 @@ import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.generation.block.B; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.IrisComplex; -import art.arcane.iris.world.history.TerrainBoundarySignature; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import java.util.Optional; import art.arcane.iris.generation.runtime.UpperDimensionContext; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; @@ -145,11 +142,12 @@ public class MantleObjectComponent extends IrisMantleComponent { x, z, getRadius(), - (sourceX, sourceZ) -> transaction.apply(sourcePlans.get( - sourceX, - sourceZ, - () -> buildSourcePlan(writer, sourceX, sourceZ, context) - )) + (sourceX, sourceZ) -> { + ObjectSourcePlan plan = sourcePlans.get(sourceX, sourceZ, + () -> buildSourcePlan(writer, sourceX, sourceZ, context)); + transaction.apply(plan); + plan.persistContinuations(writer, sourceX, sourceZ, x, z); + } ); transaction.commit(); } @@ -668,6 +666,7 @@ public class MantleObjectComponent extends IrisMantleComponent { try { int placeResult = -1; CaveObjectPlacementTransaction.CommitResult commitResult = CaveObjectPlacementTransaction.CommitResult.EMPTY; + int continuation = writer.beginObjectPlacement(); if (carving) { int caveFloorY = caveAnchor.y(); if (golden) { @@ -707,6 +706,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } }, null, getData()); } + if (placeResult >= 0 && (!carving || commitResult == CaveObjectPlacementTransaction.CommitResult.COMMITTED)) { + writer.endObjectPlacement(continuation); + } if (golden) { IrisLogging.debug("Goldendebug procedural result: chunk=" + x + "," + z + " placement=" + p.getName() @@ -778,19 +780,10 @@ public class MantleObjectComponent extends IrisMantleComponent { private boolean acceptsCaveAnchorAt(int x, int y, int z, boolean underwater, MatterCavern cavern, HydrologyCaveCell hydrology) { - Optional resolved = getEngineMantle().getComplex().resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - return acceptsResolvedCaveAnchor(underwater, - resolved.get().geometry().voxelAt(y + getEngineMantle().getEngine().getMinHeight())); - } return acceptsCaveAnchorFluid(underwater, hydrology == null ? cavern : hydrology.asCavern(), hydrology, y, getDimension().getCaveLavaHeight()); } - static boolean acceptsResolvedCaveAnchor(boolean underwater, BoundaryColumnGeometry.Voxel voxel) { - return !voxel.protectedContent() && (voxel.phase() == BoundaryColumnGeometry.Phase.AIR - || underwater && voxel.phase() == BoundaryColumnGeometry.Phase.FLUID); - } static boolean acceptsCaveAnchorFluid(boolean underwater, MatterCavern cavern, int y, int lavaHeight) { return acceptsCaveAnchorFluid(underwater, cavern, null, y, lavaHeight); @@ -914,6 +907,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } try { String marker = placementMarker(v, id, "surface"); + int continuation = writer.beginObjectPlacement(); int result = v.place(xx, -1, zz, placePlacer, effectivePlacement, rng, (b, data) -> { if (marker != null) { writer.setData(b.getX(), b.getY(), b.getZ(), marker); @@ -927,6 +921,7 @@ public class MantleObjectComponent extends IrisMantleComponent { }, null, getData()); if (result >= 0) { + writer.endObjectPlacement(continuation); placed++; } else { rejected++; @@ -1096,6 +1091,7 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisObjectPlacement effectivePlacement = resolveCavePlacement(objectPlacement, object, caveProfile); try { + int continuation = writer.beginObjectPlacement(); ContainedPlacementResult contained = placeContainedCaveObject( writer, object, @@ -1112,6 +1108,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int result = contained.resultY(); boolean wroteBlocks = contained.commitResult() == CaveObjectPlacementTransaction.CommitResult.COMMITTED; if (wroteBlocks) { + writer.endObjectPlacement(continuation); placed++; } else { rejected++; @@ -1286,7 +1283,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } boolean treePlacement = isTreePlacement(v, objectPlacement); String marker = placementMarker(v, id, "upper"); - + int continuation = writer.beginObjectPlacement(); int result = v.place(xx, anchorY, zz, writer, placement, rng, (b, data) -> { if (marker != null) { writer.setData(b.getX(), b.getY(), b.getZ(), marker); @@ -1299,6 +1296,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } }, null, getData()); + if (result >= 0) { + writer.endObjectPlacement(continuation); + } if (traceRegen) { IrisLogging.debug("Upper object placement: chunk=" + chunkX + "," + chunkZ + " scope=" + scope 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 64d0cf4eb..3561ccddb 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 @@ -32,9 +32,6 @@ 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.TransitionGenerationPlan; -import art.arcane.iris.world.history.TerrainBoundarySignature; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.generation.noise.IrisGeneratorStyle; import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.generation.block.TileData; @@ -46,6 +43,7 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; import art.arcane.volmlib.util.noise.CNG; @@ -61,9 +59,13 @@ import art.arcane.iris.generation.block.B; import org.bukkit.util.Vector; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.function.Supplier; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.util.Map; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicReferenceArray; @@ -86,6 +88,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @EqualsAndHashCode.Exclude @ToString.Exclude private final ThreadLocal activeComponentPriority = new ThreadLocal<>(); + @Getter(AccessLevel.NONE) + private final ThreadLocal objectPlacementCapture = new ThreadLocal<>(); public MantleWriter(EngineMantle engineMantle, Mantle mantle, int x, int z, int radius, boolean multicore) { this(engineMantle, mantle, x, z, radius, radius * 2, multicore); @@ -292,6 +296,22 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { (int) Math.round(style.warp(rng, data, z, -y, z, x)), t); } + public ObjectPlacementCapture captureObjectPlacement(ObjectContinuationBundle.PlacementKey key) { + if (objectPlacementCapture.get() != null) { + throw new IllegalStateException("Object placement capture is already active"); + } + ObjectPlacementCapture capture = new ObjectPlacementCapture(key); + objectPlacementCapture.set(capture); + return capture; + } + + private void recordObjectValue(int x, int y, int z, Class type, Object value) { + ObjectPlacementCapture capture = objectPlacementCapture.get(); + if (capture != null) { + capture.set(x, y, z, type, value); + } + } + public void setData(int x, int y, int z, T t) { if (t == null || !allowsWrite(x, z)) { return; @@ -323,6 +343,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { Class sliceType = t instanceof NativeBlockState ? NativeBlockState.class : matter.getClass(t); capturePreObjectOriginal(matter, x, y, z, sliceType); matter.slice(sliceType).set(x & 15, y & 15, z & 15, t); + recordObjectValue(x, y, z, sliceType, t); } } @@ -361,6 +382,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { cavernSlice = matter.slice(MatterCavern.class); } cavernSlice.set(x & 15, y & 15, z & 15, value); + recordObjectValue(x, y, z, MatterCavern.class, value); return true; } } @@ -384,6 +406,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (blockSlice != null) { capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); blockSlice.set(x & 15, y & 15, z & 15, null); + recordObjectValue(x, y, z, NativeBlockState.class, AIR.get()); } clearDeferredPlacement(matter, x, y, z); MatterSlice cavernSlice = matter.getSlice(MatterCavern.class); @@ -395,6 +418,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { cavernSlice = matter.slice(MatterCavern.class); } cavernSlice.set(x & 15, y & 15, z & 15, value); + recordObjectValue(x, y, z, MatterCavern.class, value); return true; } } @@ -413,14 +437,13 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (hasProtectedHydrology(matter, x, y, z)) { return; } - MatterSlice blockSlice = matter.getSlice(NativeBlockState.class); - if (blockSlice != null) { - capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); - blockSlice.set(x & 15, y & 15, z & 15, null); - } + capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); + matter.slice(NativeBlockState.class).set(x & 15, y & 15, z & 15, AIR.get()); clearDeferredPlacement(matter, x, y, z); capturePreObjectOriginal(matter, x, y, z, MatterCavern.class); matter.slice(MatterCavern.class).set(x & 15, y & 15, z & 15, value); + recordObjectValue(x, y, z, NativeBlockState.class, AIR.get()); + recordObjectValue(x, y, z, MatterCavern.class, value); } } @@ -448,6 +471,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (blockSlice != null) { capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); blockSlice.set(x & 15, y & 15, z & 15, null); + recordObjectValue(x, y, z, NativeBlockState.class, AIR.get()); } clearDeferredPlacement(matter, x, y, z); } @@ -517,34 +541,14 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } } - private Optional resolvedColumn(int x, int z) { - IrisComplex complex = engineMantle.getComplex(); - return complex == null ? Optional.empty() : complex.resolvedTerrainColumn(x, z); - } - private NativeBlockState resolvedBlock(TerrainBoundarySignature column, int y) { - String stateKey = column.geometry().voxelAt(y + engineMantle.getEngine().getMinHeight()).stateKey(); - NativeBlockState state = IrisPlatforms.get().registries().blockOrNull(stateKey); - if (state == null) { - throw new IllegalStateException("Saved terrain state is unavailable: " + stateKey); - } - return state; - } public NativeBlockState getPrerequisiteBlock(int x, int y, int z) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - return resolvedBlock(resolved.get(), y); - } NativeBlockState block = getPrerequisiteDataIfPresent(x, y, z, NativeBlockState.class); return block == null ? AIR.get() : block; } public boolean isPrerequisiteCarved(int x, int y, int z) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - return resolved.get().geometry().isEnclosedOpenAt(y + engineMantle.getEngine().getMinHeight()); - } HydrologyCaveCell hydrology = getPrerequisiteDataIfPresent(x, y, z, HydrologyCaveCell.class); if (hydrology != null) { return hydrology.carves(); @@ -554,10 +558,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public byte[] getPrerequisiteCarvedColumn(int x, int z, int height) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - return resolvedCarvedColumn(resolved.get(), height); - } int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); byte[] carvedColumn = new byte[cappedHeight]; if (cappedHeight <= 0) { @@ -646,6 +646,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return false; } restoreRaw(matter, x, y, z, type, cell.original(type)); + recordObjectValue(x, y, z, type, cell.original(type)); return true; } } @@ -672,12 +673,15 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } if (cell.blockCaptured()) { restoreRaw(matter, x, y, z, NativeBlockState.class, cell.block()); + recordObjectValue(x, y, z, NativeBlockState.class, cell.block()); } if (cell.stringCaptured()) { restoreRaw(matter, x, y, z, String.class, cell.string()); + recordObjectValue(x, y, z, String.class, cell.string()); } if (cell.cavernCaptured()) { restoreRaw(matter, x, y, z, MatterCavern.class, cell.cavern()); + recordObjectValue(x, y, z, MatterCavern.class, cell.cavern()); } return true; } @@ -705,6 +709,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } capturePreObjectOriginal(matter, x, y, z, type); matter.getSlice(type).set(x & 15, y & 15, z & 15, null); + recordObjectValue(x, y, z, type, null); } } @@ -747,6 +752,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); blockSlice.set(x & 15, y & 15, z & 15, baseState); identifierSlice.set(x & 15, y & 15, z & 15, identifier); + recordObjectValue(x, y, z, NativeBlockState.class, baseState); + recordObjectValue(x, y, z, Identifier.class, identifier); } } @@ -905,13 +912,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { // Read-only probe: getDataIfPresent returns the identical answer without materializing // a 16^3 section + slice on a miss the way getData's getOrCreate path does. NativeBlockState block = getDataIfPresent(x, y, z, NativeBlockState.class); - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - PreObjectMatterCell cell = getDataIfPresent(x, y, z, PreObjectMatterCell.class); - if (cell == null || !cell.blockCaptured()) { - return resolvedBlock(resolved.get(), y); - } - } if (block == null) return AIR.get(); return block; @@ -924,20 +924,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - PreObjectMatterCell cell = getDataIfPresent(x, y, z, PreObjectMatterCell.class); - if (cell != null && cell.blockCaptured()) { - NativeBlockState block = getDataIfPresent(x, y, z, NativeBlockState.class); - if (block != null) { - return (block.isAir() || block.isFluid()) - && resolved.get().geometry().hasSolidAbove(y + engineMantle.getEngine().getMinHeight()); - } - } - return cell != null && cell.cavernCaptured() - ? getDataIfPresent(x, y, z, MatterCavern.class) != null - : resolved.get().geometry().isEnclosedOpenAt(y + engineMantle.getEngine().getMinHeight()); - } HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); if (hydrology != null) { return hydrology.carves(); @@ -946,45 +932,8 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { || engineMantle.getComplex().isTerrain3DOpening(x, y, z); } - private byte[] resolvedCarvedColumn(TerrainBoundarySignature column, int height) { - int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); - byte[] carved = new byte[cappedHeight]; - int minimumY = engineMantle.getEngine().getMinHeight(); - BoundaryColumnGeometry geometry = column.geometry(); - boolean ceiling = false; - for (int worldY = geometry.minimumY() + geometry.height() - 1; worldY >= minimumY; worldY--) { - BoundaryColumnGeometry.Voxel voxel = geometry.voxelAt(worldY); - int internalY = worldY - minimumY; - if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID) { - ceiling = true; - } else if (ceiling && internalY < cappedHeight) { - carved[internalY] = 1; - } - } - return carved; - } public byte[] getCarvedColumn(int x, int z, int height) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - byte[] carved = resolvedCarvedColumn(resolved.get(), height); - for (int y = 0; y < carved.length; y++) { - PreObjectMatterCell cell = getDataIfPresent(x, y, z, PreObjectMatterCell.class); - if (cell != null && cell.blockCaptured()) { - NativeBlockState block = getDataIfPresent(x, y, z, NativeBlockState.class); - if (block != null) { - carved[y] = (block.isAir() || block.isFluid()) - && resolved.get().geometry().hasSolidAbove(y + engineMantle.getEngine().getMinHeight()) - ? (byte) 1 : 0; - continue; - } - } - if (cell != null && cell.cavernCaptured()) { - carved[y] = getDataIfPresent(x, y, z, MatterCavern.class) != null ? (byte) 1 : 0; - } - } - return carved; - } int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); byte[] carvedColumn = new byte[cappedHeight]; if (cappedHeight <= 0) { @@ -1045,11 +994,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @Override public boolean isSurfaceSolid(int x, int y, int z) { - Optional resolved = resolvedColumn(x, z); - if (resolved.isPresent()) { - return resolved.get().geometry() - .voxelAt(y + engineMantle.getEngine().getMinHeight()).phase() == BoundaryColumnGeometry.Phase.SOLID; - } return engineMantle.getEngine().isTerrainSurfaceSolid(x, y, z); } @@ -1564,4 +1508,64 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } } } + + public final class ObjectPlacementCapture implements AutoCloseable { + private final ObjectContinuationBundle.PlacementKey key; + private final Long2ObjectOpenHashMap fragments = new Long2ObjectOpenHashMap<>(); + private int minimumX = Integer.MAX_VALUE; + private int minimumZ = Integer.MAX_VALUE; + private int maximumX = Integer.MIN_VALUE; + private int maximumZ = Integer.MIN_VALUE; + private boolean closed; + + private ObjectPlacementCapture(ObjectContinuationBundle.PlacementKey key) { + this.key = Objects.requireNonNull(key); + } + + public void commit() { + close(); + if (fragments.size() > 1) { + Map> pending = new LinkedHashMap<>(); + for (Long2ObjectMap.Entry entry : fragments.long2ObjectEntrySet()) { + long coordinate = entry.getLongKey(); + Matter matter = entry.getValue(); + pending.put(new ObjectContinuationBundle.ChunkPosition((int) (coordinate >> 32), (int) coordinate), () -> matter); + } + ObjectContinuationPersistence.persist(mantle, key, + new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ), pending); + } + } + + @Override + public void close() { + if (closed) { + return; + } + if (objectPlacementCapture.get() != this) { + throw new IllegalStateException("Object placement capture closed outside its owner"); + } + objectPlacementCapture.remove(); + closed = true; + } + + private void set(int x, int y, int z, Class type, Object value) { + long coordinate = ((long) (x >> 4) << 32) | ((z >> 4) & 0xffffffffL); + Matter fragment = fragments.get(coordinate); + if (fragment == null) { + fragment = new IrisMatter(16, mantle.getWorldHeight(), 16); + fragments.put(coordinate, fragment); + } + minimumX = Math.min(minimumX, x); + minimumZ = Math.min(minimumZ, z); + maximumX = Math.max(maximumX, x); + maximumZ = Math.max(maximumZ, z); + if (type == NativeBlockState.class) { + fragment.slice(Identifier.class).set(x & 15, y, z & 15, null); + fragment.slice(type).set(x & 15, y, z & 15, value == null ? AIR.get() : value); + } else { + fragment.slice(type).set(x & 15, y, z & 15, value); + } + } + } + } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationBundle.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationBundle.java new file mode 100644 index 000000000..2719aaabe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationBundle.java @@ -0,0 +1,178 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.io.CountingDataInputStream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.UncheckedIOException; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; + +public record ObjectContinuationBundle(List fragments) { + private static final int MAX_ENTRIES = 1_048_576; + private static final int MAX_PAYLOAD_BYTES = 64 * 1024 * 1024; + + public ObjectContinuationBundle { + fragments = List.copyOf(fragments); + } + + public ObjectContinuationBundle with(Fragment fragment) { + LinkedHashMap merged = new LinkedHashMap<>(); + for (Fragment existing : fragments) { + merged.put(existing.key(), existing); + } + merged.put(fragment.key(), fragment); + return new ObjectContinuationBundle(new ArrayList<>(merged.values())); + } + + public void write(DataOutputStream output) throws IOException { + output.writeInt(fragments.size()); + for (Fragment fragment : fragments) { + output.writeUTF(fragment.key().kind().name()); + output.writeInt(fragment.key().sourceChunkX()); + output.writeInt(fragment.key().sourceChunkZ()); + output.writeInt(fragment.key().ordinal()); + output.writeInt(fragment.bounds().minimumX()); + output.writeInt(fragment.bounds().minimumZ()); + output.writeInt(fragment.bounds().maximumX()); + output.writeInt(fragment.bounds().maximumZ()); + output.writeInt(fragment.touchedChunks().size()); + for (ChunkPosition chunk : fragment.touchedChunks()) { + output.writeInt(chunk.x()); + output.writeInt(chunk.z()); + } + output.writeInt(fragment.payload.length); + output.write(fragment.payload); + } + } + + public static ObjectContinuationBundle read(DataInputStream input) throws IOException { + int count = checkedCount(input.readInt(), MAX_ENTRIES); + ArrayList fragments = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + PlacementKey key = new PlacementKey(Kind.valueOf(input.readUTF()), input.readInt(), input.readInt(), input.readInt()); + Bounds bounds = new Bounds(input.readInt(), input.readInt(), input.readInt(), input.readInt()); + int touchedCount = checkedCount(input.readInt(), MAX_ENTRIES); + ArrayList touched = new ArrayList<>(touchedCount); + for (int chunk = 0; chunk < touchedCount; chunk++) { + touched.add(new ChunkPosition(input.readInt(), input.readInt())); + } + int length = checkedCount(input.readInt(), MAX_PAYLOAD_BYTES); + byte[] payload = new byte[length]; + input.readFully(payload); + fragments.add(new Fragment(key, bounds, touched, payload)); + } + return new ObjectContinuationBundle(fragments); + } + + public static byte[] encode(Matter payload) { + IrisMatterSupport.ensureRegistered(); + try { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + payload.write(output); + return output.toByteArray(); + } catch (IOException failure) { + throw new UncheckedIOException("Unable to encode object continuation", failure); + } + } + + private static int checkedCount(int count, int maximum) throws IOException { + if (count < 0 || count > maximum) { + throw new IOException("Invalid object continuation size: " + count); + } + return count; + } + + public enum Kind { + BIOME, FLOATING, STRUCTURE, STATIC + } + + public record PlacementKey(Kind kind, int sourceChunkX, int sourceChunkZ, int ordinal) { + public PlacementKey { + Objects.requireNonNull(kind, "placement kind"); + if (ordinal < 0) { + throw new IllegalArgumentException("Placement ordinal must be nonnegative"); + } + } + } + + public record ChunkPosition(int x, int z) { + } + + public record Bounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { + public Bounds { + if (minimumX > maximumX || minimumZ > maximumZ) { + throw new IllegalArgumentException("Object continuation bounds are inverted"); + } + } + } + + public record Fragment(PlacementKey key, Bounds bounds, List touchedChunks, byte[] payload) { + public Fragment { + Objects.requireNonNull(key, "placement key"); + Objects.requireNonNull(bounds, "placement bounds"); + touchedChunks = List.copyOf(touchedChunks); + if (touchedChunks.size() < 2 || payload.length == 0 || payload.length > MAX_PAYLOAD_BYTES) { + throw new IllegalArgumentException("Object continuation must span chunks with a bounded payload"); + } + payload = payload.clone(); + } + + @Override + public byte[] payload() { + return payload.clone(); + } + + public int encodedSize() { + return payload.length; + } + + public Matter decode() throws IOException { + try (CountingDataInputStream input = CountingDataInputStream.wrap(new ByteArrayInputStream(payload))) { + int width = input.readInt(); + int height = input.readInt(); + int depth = input.readInt(); + if (width != 16 || depth != 16 || height < 1 || height > 4096) { + throw new IOException("Invalid object continuation dimensions"); + } + Matter matter = new IrisMatter(width, height, depth); + int sliceCount = input.readUnsignedByte(); + matter.getHeader().read(input); + for (int index = 0; index < sliceCount; index++) { + int size = input.readInt(); + long end = input.count() + size; + if (size <= 0 || end > payload.length) { + throw new IOException("Invalid object continuation slice size"); + } + String identifier = input.readUTF(); + Class type = IrisMatter.getSliceType(identifier); + if (type == null || matter.hasSlice(type)) { + throw new IOException("Unavailable or duplicate object continuation slice: " + identifier); + } + MatterSlice slice = matter.createSlice(type, matter); + if (slice == null) { + throw new IOException("Unavailable object continuation codec: " + identifier); + } + slice.read(input); + if (input.count() != end) { + throw new IOException("Object continuation slice size mismatch: " + identifier); + } + matter.putSlice(type, slice); + } + if (input.count() != payload.length) { + throw new IOException("Trailing object continuation bytes"); + } + return matter; + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java new file mode 100644 index 000000000..9730e924c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java @@ -0,0 +1,77 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +public final class ObjectContinuationPersistence { + private ObjectContinuationPersistence() { + } + + public static void persist(Mantle mantle, ObjectContinuationBundle.PlacementKey key, + ObjectContinuationBundle.Bounds bounds, + Map> destinations) { + if (destinations.size() < 2) { + return; + } + List touched = List.copyOf(destinations.keySet()); + for (Map.Entry> entry : destinations.entrySet()) { + ObjectContinuationBundle.ChunkPosition destination = entry.getKey(); + MantleChunk chunk = mantle.getChunk(destination.x(), destination.z()).use(); + try { + synchronized (chunk) { + if (chunk.isFlagged(MantleFlag.REAL)) { + continue; + } + Matter section = chunk.getOrCreate(0); + ObjectContinuationBundle bundle = section.hasSlice(ObjectContinuationBundle.class) + ? section.getSlice(ObjectContinuationBundle.class).get(0, 0, 0) : null; + if (contains(bundle, key)) { + continue; + } + ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( + key, bounds, touched, ObjectContinuationBundle.encode(entry.getValue().get())); + section.slice(ObjectContinuationBundle.class).set(0, 0, 0, + bundle == null ? new ObjectContinuationBundle(List.of(fragment)) : bundle.with(fragment)); + } + } finally { + chunk.release(); + } + } + } + + public static void put(Matter payload, int x, int y, int z, Object value) { + if (value instanceof NativeBlockState state) { + String customKey = state.deferredPlacementKey(); + NativeBlockState base = state.placementBaseState(); + boolean custom = state.isCustom() && customKey != null && base != null; + payload.slice(NativeBlockState.class).set(x, y, z, custom ? base : state); + payload.slice(Identifier.class).set(x, y, z, custom ? Identifier.fromString(customKey) : null); + } else if (value instanceof TileData tile) { + payload.slice(TileWrapper.class).set(x, y, z, new TileWrapper(tile)); + } else { + payload.slice(payload.getClass(value)).set(x, y, z, value); + } + } + + private static boolean contains(ObjectContinuationBundle bundle, ObjectContinuationBundle.PlacementKey key) { + if (bundle == null) { + return false; + } + for (ObjectContinuationBundle.Fragment fragment : bundle.fragments()) { + if (fragment.key().equals(key)) { + return true; + } + } + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index 7e10c48b4..4971c04cb 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -30,6 +30,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private final int destinationChunkZ; private final int worldHeight; private final List mutations; + private final List placements = new ArrayList<>(); private final Long2ObjectOpenHashMap> overlay; ObjectDestinationTransaction(MantleWriter writer, int destinationChunkX, int destinationChunkZ) { @@ -53,7 +54,25 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { if (checkpoint < 0 || checkpoint > mutations.size()) { throw new IllegalArgumentException("Mutation checkpoint is outside the transaction"); } - return new ObjectSourcePlan(mutations.subList(checkpoint, mutations.size())); + ArrayList> accepted = new ArrayList<>(); + for (PlacementRange placement : placements) { + if (placement.start() >= checkpoint) { + accepted.add(List.copyOf(mutations.subList(placement.start(), placement.end()))); + } + } + return new ObjectSourcePlan(mutations.subList(checkpoint, mutations.size()), accepted); + } + + @Override + public int beginObjectPlacement() { + return mutations.size(); + } + + @Override + public void endObjectPlacement(int checkpoint) { + if (checkpoint >= 0 && checkpoint < mutations.size()) { + placements.add(new PlacementRange(checkpoint, mutations.size())); + } } void apply(ObjectSourcePlan plan) { @@ -413,6 +432,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } } + private record PlacementRange(int start, int end) { + } + record DataKey(int x, int y, int z, Class type) { } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectPassPlacer.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectPassPlacer.java index 1ae2310bf..455d34507 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectPassPlacer.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectPassPlacer.java @@ -8,6 +8,13 @@ public interface ObjectPassPlacer extends IObjectPlacer { byte[] getCarvedColumn(int x, int z, int height); + default int beginObjectPlacement() { + return -1; + } + + default void endObjectPlacement(int checkpoint) { + } + @Override default @Nullable T getData(int x, int y, int z, Class type) { return getDataIfPresent(x, y, z, type); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java index ef409292d..0badf1681 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectSourcePlan.java @@ -3,15 +3,39 @@ package art.arcane.iris.generation.mantle; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; + import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.List; final class ObjectSourcePlan { private static final int DESTINATION_INDEX_WEIGHT = 8; private final Long2ObjectOpenHashMap> destinations; private final int mutationWeight; + private final List placements; ObjectSourcePlan(List mutations) { + this(mutations, List.of()); + } + + ObjectSourcePlan(List mutations, List> placements) { + ArrayList crossing = new ArrayList<>(); + for (int ordinal = 0; ordinal < placements.size(); ordinal++) { + List placement = placements.get(ordinal); + LinkedHashMap> fragments = group(placement); + if (fragments.size() > 1) { + crossing.add(new Placement(ordinal, bounds(placement), List.copyOf(fragments.keySet()), fragments)); + } + } + this.placements = List.copyOf(crossing); Long2ObjectOpenHashMap> grouped = new Long2ObjectOpenHashMap<>(2); long weight = 1L; for (ObjectDestinationTransaction.Mutation mutation : mutations) { @@ -29,6 +53,12 @@ final class ObjectSourcePlan { for (Long2ObjectMap.Entry> entry : grouped.long2ObjectEntrySet()) { destinations.put(entry.getLongKey(), List.copyOf(entry.getValue())); } + for (Placement placement : this.placements) { + weight += 8L + placement.touched().size() * DESTINATION_INDEX_WEIGHT; + for (List fragment : placement.fragments().values()) { + weight += fragment.size(); + } + } this.mutationWeight = weight >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) weight; } @@ -37,10 +67,95 @@ final class ObjectSourcePlan { return mutations == null ? List.of() : mutations; } + void persistContinuations(MantleWriter writer, int sourceX, int sourceZ, int destinationX, int destinationZ) { + ObjectContinuationBundle.ChunkPosition destination = new ObjectContinuationBundle.ChunkPosition(destinationX, destinationZ); + for (Placement placement : placements) { + if (!placement.fragments().containsKey(destination)) { + continue; + } + ObjectContinuationBundle.PlacementKey key = new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, sourceX, sourceZ, placement.ordinal()); + for (Map.Entry> entry : placement.fragments().entrySet()) { + ObjectContinuationBundle.ChunkPosition target = entry.getKey(); + if (target.equals(destination)) { + continue; + } + writer.withChunkFence(target.x(), target.z(), () -> { + MantleChunk chunk = writer.acquireChunk(target.x(), target.z()); + if (chunk.isFlagged(MantleFlag.REAL)) { + return; + } + int x = target.x() << 4; + int z = target.z() << 4; + ObjectContinuationBundle bundle = writer.getDataIfPresent(x, 0, z, ObjectContinuationBundle.class); + if (bundle != null) { + for (ObjectContinuationBundle.Fragment existing : bundle.fragments()) { + if (existing.key().equals(key)) { + return; + } + } + } + ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment(key, placement.bounds(), placement.touched(), + encode(entry.getValue(), writer.getMantle().getWorldHeight())); + writer.setData(x, 0, z, bundle == null ? new ObjectContinuationBundle(List.of(fragment)) : bundle.with(fragment)); + }); + } + } + } + + static byte[] encode(List mutations, int height) { + IrisMatterSupport.ensureRegistered(); + Matter payload = new IrisMatter(16, height, 16); + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + ObjectDestinationTransaction.DataKey key = mutation.key(); + int x = key.x() & 15; + int z = key.z() & 15; + if (mutation instanceof ObjectDestinationTransaction.CustomBlockMutation custom) { + payload.slice(NativeBlockState.class).set(x, key.y(), z, custom.state().placementBaseState()); + payload.slice(Identifier.class).set(x, key.y(), z, Identifier.fromString(custom.state().deferredPlacementKey())); + } else if (mutation instanceof ObjectDestinationTransaction.SetMutation set) { + if (key.type() == NativeBlockState.class) { + payload.slice(Identifier.class).set(x, key.y(), z, null); + } + payload.slice(key.type()).set(x, key.y(), z, set.value()); + } + } + return ObjectContinuationBundle.encode(payload); + } + + private static LinkedHashMap> group( + List mutations + ) { + LinkedHashMap> grouped = new LinkedHashMap<>(); + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + ObjectContinuationBundle.ChunkPosition destination = new ObjectContinuationBundle.ChunkPosition(mutation.x() >> 4, mutation.z() >> 4); + grouped.computeIfAbsent(destination, ignored -> new ArrayList<>()).add(mutation); + } + return grouped; + } + + private static ObjectContinuationBundle.Bounds bounds(List mutations) { + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (ObjectDestinationTransaction.Mutation mutation : mutations) { + minimumX = Math.min(minimumX, mutation.x()); + minimumZ = Math.min(minimumZ, mutation.z()); + maximumX = Math.max(maximumX, mutation.x()); + maximumZ = Math.max(maximumZ, mutation.z()); + } + return new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ); + } + int mutationWeight() { return mutationWeight; } + private record Placement(int ordinal, ObjectContinuationBundle.Bounds bounds, + List touched, + Map> fragments) { + } + private static long destinationKey(int chunkX, int chunkZ) { return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); } 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 9474de2d2..5f563e06c 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,10 +125,10 @@ public final class DimensionTerrainContext implements DataProvider { engine.getDimension(), engine.getData(), engine.getHeight(), - natural ? complex.getUnblendedNaturalHeightStream() : complex.getHeightStream(), + natural ? complex.getNaturalHeightStream() : complex.getHeightStream(), natural ? complex.getNaturalSlopeStream() : complex.getSlopeStream(), fluidHeightStream, - natural ? complex.getUnblendedNaturalTrueBiomeStream() : complex.getTrueBiomeStream(), + natural ? complex.getNaturalTrueBiomeStream() : complex.getTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), fluidBlockSampler, 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 67ec3d41a..02e35e4a8 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 @@ -47,7 +47,6 @@ import art.arcane.iris.structure.placement.IrisStructure; import art.arcane.iris.world.IrisWorld; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveStorage; -import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.collection.KList; @@ -68,20 +67,15 @@ import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.iris.generation.concurrent.BurstExecutor; import art.arcane.iris.generation.concurrent.MultiBurst; -import art.arcane.volmlib.util.scheduling.ChronoLatch; -import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.stream.ProceduralStream; import org.jetbrains.annotations.Nullable; import java.awt.Color; import java.nio.file.Path; -import java.util.Optional; -import java.util.OptionalInt; import java.util.Locale; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -496,10 +490,6 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default int getHeight(int x, int z, boolean ignoreFluid) { - OptionalInt resolved = getComplex().resolvedTerrainHeight(x, z, ignoreFluid); - if (resolved.isPresent()) { - return resolved.getAsInt(); - } DimensionStackContext dimensionStackContext = getDimensionStackContext(); if (dimensionStackContext != null) { return 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 5c3facd60..5e3d27b61 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 @@ -18,17 +18,31 @@ package art.arcane.iris.generation.runtime; -import art.arcane.iris.generation.stage.IrisBiomeActuator; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.iris.generation.mantle.EngineMantle; -import art.arcane.iris.generation.context.ChunkContext; -import art.arcane.iris.generation.context.IrisContext; -import art.arcane.volmlib.util.documentation.BlockCoordinates; -import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.iris.generation.concurrent.BurstExecutor; import art.arcane.iris.generation.concurrent.MultiBurst; +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.stage.IrisBiomeActuator; +import art.arcane.iris.generation.stage.IrisTransitionGeometryActuator; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.iris.world.history.FloatingBiomeOverlay; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.SavedTerrainChunk; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.documentation.BlockCoordinates; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; + +import java.io.IOException; +import java.util.List; +import java.util.Map; public interface EngineMode extends Staged { KList getTerrainStages(); @@ -199,12 +213,115 @@ public interface EngineMode extends Staged { EngineStage[] stages = getStages().toArray(new EngineStage[0]); try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), generationSessionId, ctx)) { - getComplex().getResolvedTerrain().generate(this, x, z, blocks, biomes, multicore, ctx); + generateTerrain(x, z, blocks, biomes, multicore, ctx); + recordNaturalTerrain(x, z, blocks, biomes, ctx); IrisBiomeActuator.publishNaturalMetadata(getEngine(), x, z, biomes, ctx); ctx.beginContent(); + List continuations = restoreObjectContinuations(x, z); for (EngineStage i : stages) { i.generate(x, z, blocks, biomes, multicore, ctx); } + for (Matter continuation : continuations) { + restoreObjectMetadata(x, z, continuation); + MatterSlice states = continuation.getSlice(NativeBlockState.class); + if (states != null) { + states.iterateSync((localX, y, localZ, state) -> blocks.set(localX, y, localZ, state)); + } + } + } + } + + private List restoreObjectContinuations(int x, int z) { + if (!(getEngine() instanceof IrisEngine irisEngine)) { + return List.of(); + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return List.of(); + } + List fragments = router.objectContinuations( + getComplex().getTransitionGenerationPlan(), x >> 4, z >> 4); + if (fragments.isEmpty()) { + return List.of(); + } + try { + List continuations = router.decodeObjectContinuations(fragments); + for (Matter continuation : continuations) { + if (continuation.getHeight() != getEngine().getHeight()) { + throw new IOException("Object continuation height does not match the world"); + } + restoreObjectMetadata(x, z, continuation); + } + return continuations; + } catch (IOException failure) { + throw new IllegalStateException("Unable to restore object continuation at " + (x >> 4) + "," + (z >> 4), failure); + } + } + + private void restoreObjectMetadata(int x, int z, Matter continuation) { + MantleChunk chunk = getMantle().getMantle().getChunk(x >> 4, z >> 4).use(); + try { + synchronized (chunk) { + MatterSlice states = continuation.getSlice(NativeBlockState.class); + if (states != null) { + states.iterateSync((localX, y, localZ, state) -> { + Matter section = chunk.get(y >> 4); + if (section != null) { + clearObjectMetadata(section, localX, y & 15, localZ); + } + }); + } + for (Map.Entry, MatterSlice> entry : continuation.getSliceMap().entrySet()) { + entry.getValue().iterateSync((localX, y, localZ, value) -> + chunk.getOrCreate(y >> 4).slice(entry.getKey()).set(localX, y & 15, localZ, value)); + } + } + } finally { + chunk.release(); + } + } + + private static void clearObjectMetadata(Matter section, int x, int y, int z) { + clearObjectMetadata(section.getSlice(Identifier.class), x, y, z); + clearObjectMetadata(section.getSlice(String.class), x, y, z); + clearObjectMetadata(section.getSlice(TreeBlockMaterial.class), x, y, z); + clearObjectMetadata(section.getSlice(TileWrapper.class), x, y, z); + } + + private static void clearObjectMetadata(MatterSlice slice, int x, int y, int z) { + if (slice != null) { + slice.set(x, y, z, null); + } + } + + private void recordNaturalTerrain(int x, int z, Hunk blocks, + Hunk biomes, ChunkContext context) { + if (!(getEngine() instanceof IrisEngine irisEngine)) { + return; + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return; + } + FloatingBiomeOverlay floating = context.getFloatingBiomes(); + if (floating != null) { + floating.retainHighestSurfaces((localX, localZ) -> { + for (int y = blocks.getHeight() - 1; y >= 0; y--) { + NativeBlockState state = blocks.getRaw(localX, y, localZ); + if (state != null && !state.isAir() && !state.isFluid()) { + return y; + } + } + return -1; + }); + } + try { + SavedTerrainChunk terrain = IrisTransitionGeometryActuator.capture( + x, z, blocks, biomes, getEngine().getMinHeight(), context, true); + router.recordNaturalTerrain(terrain); + router.recordFloatingBiomes(x >> 4, z >> 4, floating); + } catch (IOException failure) { + throw new IllegalStateException("Unable to capture natural terrain at " + (x >> 4) + "," + (z >> 4), failure); } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index 7e0c49ac6..0b501194e 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 @@ -30,7 +30,7 @@ import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.studio.generation.BiomeBuffetLayout; import art.arcane.iris.world.history.GenerationBlend; import art.arcane.iris.world.history.TransitionGenerationPlan; -import art.arcane.iris.world.history.TerrainBoundarySignature; +import art.arcane.iris.world.history.TransitionDisplacementField; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyColumnSnapshot; @@ -41,6 +41,8 @@ import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext; import art.arcane.iris.generation.image.IrisImageMapApplication; import art.arcane.iris.generation.image.IrisImageMapRuntime; import art.arcane.iris.generation.mantle.MantleHydrologyCaveVoxelView; +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink; @@ -99,7 +101,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.OptionalInt; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @@ -175,7 +176,6 @@ public class IrisComplex implements DataProvider { private final transient boolean reuseNaturalBaseBiome; private ProceduralStream baseBiomeIDStream; private ProceduralStream naturalTrueBiomeStream; - private ProceduralStream unblendedNaturalTrueBiomeStream; private ProceduralStream trueBiomeStream; private ProceduralStream trueBiomeDerivativeStream; private ProceduralStream naturalHeightStream; @@ -188,8 +188,7 @@ public class IrisComplex implements DataProvider { private transient Terrain3DRuntime terrain3D; private transient HydrologyBankTerrainRuntime hydrologyBanks3D; private ProceduralStream unblendedNaturalHeightStream; - private final ResolvedTerrainProvider resolvedTerrain; - private ProceduralStream placementHeightStream; + private final TransitionDisplacementField transitionDisplacement; private ProceduralStream heightStream; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -248,7 +247,6 @@ public class IrisComplex implements DataProvider { biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep(); this.detached = detached; this.transitionGenerationPlan = transitionGenerationPlan; - this.resolvedTerrain = new ResolvedTerrainProvider(engine); int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize(), detached); IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); @@ -408,22 +406,21 @@ public class IrisComplex implements DataProvider { unblendedNaturalHeightStream = terrain3DEnabled ? GenerationStreams.cache2DDouble(ProceduralStream.ofDouble(this::sampleTerrain3DHeight), "unblendedNaturalHeightStream", engine, cacheSize) : baseTerrainHeightStream; - naturalHeightStream = unblendedNaturalHeightStream; + transitionDisplacement = transitionGenerationPlan == null ? null : new TransitionDisplacementField( + transitionGenerationPlan, new TransitionDisplacementField.Sources( + (x, z) -> unblendedNaturalHeightStream.getDouble(x, z), engine.getHeight())); + naturalHeightStream = transitionDisplacement == null ? unblendedNaturalHeightStream + : GenerationStreams.cache2DDouble(ProceduralStream.ofDouble((x, z) -> transitionDisplacement.height( + blockCoordinate(x), blockCoordinate(z), unblendedNaturalHeightStream.getDouble(x, z))), + "naturalHeightStream", engine, cacheSize); naturalTrueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "naturalTrueBiomeStream-focus", engine, cacheSize) : GenerationStreams.cache2D(naturalHeightStream .convertAware2D((h, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null - ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight) + ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, + transitionFluidHeight(x, z, fluidHeight)) : mapped; }), "naturalTrueBiomeStream", engine, cacheSize); - unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusedBiomes != null - ? naturalTrueBiomeStream - : GenerationStreams.cache2D(unblendedNaturalHeightStream.convertAware2D((h, x, z) -> { - IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); - return mapped == null - ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight) - : mapped; - }), "unblendedNaturalTrueBiomeStream", engine, cacheSize); IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); if (hydrologyActive(configuredHydrology)) { hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( @@ -456,10 +453,9 @@ public class IrisComplex implements DataProvider { ProceduralStream.ofDouble(this::resolveHydrologyTerrainHeight), cacheSize); heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread() ? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z)); - placementHeightStream = ProceduralStream.ofDouble(this::samplePlacementHeight); - roundedHeighteightStream = GenerationStreams.contextInjecting(placementHeightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) + roundedHeighteightStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); - slopeStream = GenerationStreams.contextInjecting(placementHeightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) + slopeStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .slope(3); naturalSlopeStream = GenerationStreams.cache2DDouble(naturalHeightStream.slope(3), "naturalSlopeStream", engine, cacheSize); // Terrain3D column heights resolve the surface biome, so the shore band reads the pre-terrain3D slope or it recurses. @@ -534,7 +530,7 @@ public class IrisComplex implements DataProvider { List capacities = new ArrayList<>(5); int additionalChunks = HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS; for (ProceduralStream stream : new ProceduralStream[]{ - baseBiomeStream, baseTerrainHeightStream, unblendedNaturalHeightStream, regionStream, bridgeStream}) { + baseBiomeStream, baseTerrainHeightStream, unblendedNaturalHeightStream, naturalHeightStream, regionStream, bridgeStream}) { if (!(stream instanceof CachedStream2D || stream instanceof CachedDoubleStream2D) || selected.put(stream, Boolean.TRUE) != null) { continue; @@ -641,9 +637,7 @@ public class IrisComplex implements DataProvider { } private double sampleNaturalTerrainHeight(Engine engine, double x, double z) { - return terrain3D != null && terrain3D.active() - ? sampleTerrain3DHeight(x, z) - : sampleUnblendedNaturalTerrainHeight(x, z); + return naturalHeightStream.getDouble(x, z); } private double sampleTerrain3DHeight(double x, double z) { @@ -672,6 +666,10 @@ public class IrisComplex implements DataProvider { return null; } Terrain3DColumn column = terrain3D.column(x, z); + if (transitionDisplacement != null && column.shaped()) { + int offset = (int) Math.round(naturalHeightStream.getDouble(x, z)) - column.topY(); + column = column.displaced(offset, terrainEngine.getHeight()); + } return column.shaped() ? column : null; } @@ -740,7 +738,7 @@ public class IrisComplex implements DataProvider { private double nearbyTerrainSurfaceHeight(int x, int surfaceY, int z) { Terrain3DColumn column = terrainColumn(x, z); - return column == null ? getPlacementHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY); + return column == null ? getHeightStream().getDouble(x, z) : column.nearestSurfaceY(surfaceY); } private double sampleUnblendedNaturalTerrainHeight(double x, double z) { @@ -783,17 +781,18 @@ public class IrisComplex implements DataProvider { if (biome == null || region == null) { return biome; } - double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopeStream); - if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) { + double localFluidHeight = transitionFluidHeight(x, z, fluidHeight); + double shoreTop = shoreBandTop(height, region, x, z, localFluidHeight, shoreSlopeStream); + if (height >= localFluidHeight - 1 && height <= shoreTop && !biome.isShore()) { return sampleInferredBiome(region, InferredType.SHORE, x, z); } if (height > shoreTop && !biome.isLand()) { return sampleInferredBiome(region, InferredType.LAND, x, z); } - if (height < fluidHeight && !biome.isAquatic()) { + if (height < localFluidHeight && !biome.isAquatic()) { return sampleInferredBiome(region, InferredType.SEA, x, z); } - if (height == fluidHeight && !biome.isShore()) { + if (height == localFluidHeight && !biome.isShore()) { return sampleInferredBiome(region, InferredType.SHORE, x, z); } return biome; @@ -955,6 +954,16 @@ public class IrisComplex implements DataProvider { } public NativeBlockState resolveSurfaceFluid(double x, double z) { + if (transitionDisplacement != null) { + String historical = transitionDisplacement.fluidStateKey(blockCoordinate(x), blockCoordinate(z)); + if (historical != null && !historical.isEmpty()) { + NativeBlockState fluid = IrisPlatforms.get().registries().blockOrNull(historical); + if (fluid == null || !fluid.isFluid()) { + throw new IllegalStateException("Historical surface fluid is unavailable: " + historical); + } + return fluid; + } + } HydrologyColumnLayer layer = surfaceFluidLayer(x, z); if (layer != null) { return resolveHydrologyFluid(layer.profileKey(), x, z); @@ -1023,7 +1032,15 @@ public class IrisComplex implements DataProvider { private double resolveHydrologyFluidSurface(double x, double z) { HydrologyColumnLayer layer = surfaceFluidLayer(x, z); - return layer == null ? fluidHeight : layer.fluidHeadY(); + if (layer != null) { + return layer.fluidHeadY(); + } + return transitionFluidHeight(x, z, fluidHeight); + } + + private double transitionFluidHeight(double x, double z, double nativeHead) { + return transitionDisplacement == null ? nativeHead + : transitionDisplacement.fluidHeight(blockCoordinate(x), blockCoordinate(z), nativeHead); } public HydrologyColumnSample sampleHydrologyColumn(double x, double z) { @@ -1034,27 +1051,55 @@ public class IrisComplex implements DataProvider { return hydrologyRuntime.sample(x, z).orElse(null); } double hydrologyWeight = transitionHydrologyWeight(x, z); - if (hydrologyWeight == 0D) { - return null; - } HydrologyColumnSample sample = hydrologyRuntime.sample(x, z).orElse(null); - if (sample == null || hydrologyWeight == 1D) { + if (sample == null) { return sample; } - return taperHydrologySample(sample, hydrologyWeight); + return transitionHydrologySample(sample, hydrologyWeight, transitionDisplacement); } private HydrologyColumnSnapshot sampleHydrologySnapshot(int x, int z) { if (hydrologyRuntime == null) { return HydrologyColumnSnapshot.ready(null); } - double weight = transitionHydrologyWeight(x, z); - if (weight == 0D) { - return HydrologyColumnSnapshot.ready(null); - } HydrologyColumnSnapshot snapshot = hydrologyRuntime.sampleSnapshot(x, z); - return !snapshot.available() || snapshot.column() == null || weight == 1D ? snapshot - : HydrologyColumnSnapshot.ready(taperHydrologySample(snapshot.column(), weight)); + return !snapshot.available() || snapshot.column() == null ? snapshot + : HydrologyColumnSnapshot.ready(transitionHydrologySample( + snapshot.column(), transitionHydrologyWeight(x, z), transitionDisplacement)); + } + + static HydrologyColumnSample transitionHydrologySample( + HydrologyColumnSample sample, double weight, TransitionDisplacementField transitionDisplacement + ) { + HydrologyColumnSample tapered = taperHydrologySample(sample, weight); + if (transitionDisplacement == null + || transitionDisplacement.sample(sample.x(), sample.z()).newTerrainWeight() == 1D) { + return tapered; + } + ArrayList layers = new ArrayList<>(tapered.layers().size()); + int seaLevel = (int) Math.round(transitionDisplacement.fluidHeight(sample.x(), sample.z(), tapered.seaLevel())); + for (int index = 0; index < tapered.layers().size(); index++) { + HydrologyColumnLayer layer = tapered.layers().get(index); + if (layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid()) { + layers.add(layer); + continue; + } + HydrologyColumnLayer original = sample.layers().get(index); + int head = (int) Math.round(transitionDisplacement.fluidHeight(sample.x(), sample.z(), layer.fluidHeadY())); + if ((tapered.naturalHeight() < seaLevel || tapered.naturalHeight() == seaLevel && head > seaLevel) + && (original.terrainOwned() || original.fluidOwned() || original.grading() || original.shore())) { + continue; + } + boolean dryChannel = original.channel() && layer.bedY() > head; + layers.add(new HydrologyColumnLayer(layer.feature(), layer.bedY(), + head, head, original.channel() && !dryChannel, layer.shore(), layer.grading(), original.connectedFluid() && !dryChannel, + original.fallingFluid() && !dryChannel, original.receivingPool() && !dryChannel, + layer.terrainOwned(), original.fluidOwned() && !dryChannel, layer.oceanApron(), + layer.profileKey(), layer.surfaceBiomeKey(), layer.mouthBiomeKey(), layer.shoreBiomeKey(), + layer.bankBiomeKey(), layer.floodedCaveBiomeKey())); + } + return new HydrologyColumnSample(tapered.x(), tapered.z(), tapered.naturalHeight(), Math.max(tapered.seaLevel(), seaLevel), + tapered.ocean(), tapered.parentBiomeKey(), layers); } static HydrologyColumnSample taperHydrologySample( @@ -1067,6 +1112,9 @@ public class IrisComplex implements DataProvider { } ArrayList taperedLayers = new ArrayList<>(sample.layers().size()); for (HydrologyColumnLayer layer : sample.layers()) { + if (hydrologyWeight == 0D && !layer.feature().type().isUnderground() && !layer.feature().type().isDeepFluid()) { + continue; + } taperedLayers.add(taperHydrologyLayer(layer, sample.naturalHeight(), hydrologyWeight)); } return new HydrologyColumnSample( @@ -1087,19 +1135,21 @@ public class IrisComplex implements DataProvider { if (layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid()) { return layer; } + int bed = GenerationBlend.interpolateHeight(naturalHeight, layer.bedY(), hydrologyWeight); + boolean dryChannel = layer.channel() && bed > layer.fluidHeadY(); return new HydrologyColumnLayer( layer.feature(), - GenerationBlend.interpolateHeight(naturalHeight, layer.bedY(), hydrologyWeight), - GenerationBlend.interpolateHeight(naturalHeight, layer.fluidHeadY(), hydrologyWeight), - GenerationBlend.interpolateHeight(naturalHeight, layer.ceilingY(), hydrologyWeight), - layer.channel(), + bed, + layer.fluidHeadY(), + layer.ceilingY(), + layer.channel() && !dryChannel, layer.shore(), layer.grading(), - layer.connectedFluid(), - layer.fallingFluid(), - layer.receivingPool(), + layer.connectedFluid() && !dryChannel, + layer.fallingFluid() && !dryChannel, + layer.receivingPool() && !dryChannel, layer.terrainOwned(), - layer.fluidOwned(), + layer.fluidOwned() && !dryChannel, layer.oceanApron(), layer.profileKey(), layer.surfaceBiomeKey(), @@ -1114,14 +1164,14 @@ public class IrisComplex implements DataProvider { } private double transitionHydrologyWeight(double x, double z) { - return transitionGenerationPlan == null + return transitionDisplacement == null ? 1D - : transitionGenerationPlan.hydrologyWeightAt(blockCoordinate(x), blockCoordinate(z)); + : transitionDisplacement.seamWeight(blockCoordinate(x), blockCoordinate(z)); } /** Whether the column's hydrology can be sampled without waiting for a plan (see Engine.answersFromNaturalTerrain). */ public boolean isHydrologyPlanned(int x, int z) { - if (hydrologyRuntime == null || transitionHydrologyWeight(x, z) == 0D) { + if (hydrologyRuntime == null) { return true; } if (!hydrologyRuntime.isPlanned(x, z)) { @@ -1144,23 +1194,7 @@ public class IrisComplex implements DataProvider { } public ProceduralStream getHeightStream() { - return placementHeightStream; - } - - public Optional resolvedTerrainColumn(int blockX, int blockZ) { - if (transitionGenerationPlan == null || isNaturalTerrainContext() - || !transitionGenerationPlan.hasTransitionAtChunk(blockX >> 4, blockZ >> 4)) { - return Optional.empty(); - } - return Optional.of(resolvedTerrain.column(blockX, blockZ)); - } - - public OptionalInt resolvedTerrainHeight(int blockX, int blockZ, boolean ignoreFluid) { - if (transitionGenerationPlan == null || isNaturalTerrainContext() - || !transitionGenerationPlan.hasTransitionAtChunk(blockX >> 4, blockZ >> 4)) { - return OptionalInt.empty(); - } - return OptionalInt.of(resolvedTerrain.height(blockX, blockZ, ignoreFluid)); + return heightStream; } boolean isNaturalTerrainContext() { @@ -1170,11 +1204,6 @@ public class IrisComplex implements DataProvider { && context.getChunkContext().isNaturalTerrain(); } - private double samplePlacementHeight(double x, double z) { - OptionalInt resolved = resolvedTerrainHeight(blockCoordinate(x), blockCoordinate(z), true); - return resolved.isPresent() ? resolved.getAsInt() : heightStream.getDouble(x, z); - } - public boolean isHistoricalChunk(int chunkX, int chunkZ) { return transitionGenerationPlan != null && transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ); @@ -1214,8 +1243,16 @@ public class IrisComplex implements DataProvider { int maximumX, int maximumZ ) { - return transitionGenerationPlan == null - || transitionGenerationPlan.allowsNewFootprint(minimumX, minimumZ, maximumX, maximumZ); + if (transitionGenerationPlan == null) { + return true; + } + if (!transitionGenerationPlan.allowsNewFootprint(minimumX, minimumZ, maximumX, maximumZ)) { + return false; + } + GenerationHistoryRuntimeRouter router = terrainEngine instanceof IrisEngine irisEngine + ? irisEngine.getGenerationHistoryRuntimeRouter().orElse(null) : null; + return router == null || router.allowsObjectFootprint(transitionGenerationPlan, + new ObjectContinuationBundle.Bounds(minimumX, minimumZ, maximumX, maximumZ)); } public Optional historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { @@ -1248,7 +1285,8 @@ public class IrisComplex implements DataProvider { return mapped; } double terrainHeight = naturalHeightStream.getDouble(x, z); - return fixBiomeType(terrainHeight, baseBiomeStream.get(x, z), regionStream.get(x, z), (double) x, (double) z, fluidHeight); + return fixBiomeType(terrainHeight, baseBiomeStream.get(x, z), regionStream.get(x, z), (double) x, (double) z, + transitionFluidHeight(x, z, fluidHeight)); } private HydrologyColumnLayer surfaceLayer(double x, double z) { @@ -1336,7 +1374,7 @@ public class IrisComplex implements DataProvider { regionStream.get(x, z), x, z, - fluidHeight + transitionFluidHeight(x, z, fluidHeight) ); } @@ -2094,6 +2132,8 @@ public class IrisComplex implements DataProvider { hydrologyRuntime.close(); } releaseHydrologyNoiseCaches(); - resolvedTerrain.clear(); + if (transitionDisplacement != null) { + transitionDisplacement.clear(); + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 841e5f0f9..1a14334f6 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -54,6 +54,9 @@ import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.matter.MatterStructurePOI; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import lombok.AccessLevel; @@ -1478,7 +1481,18 @@ public class IrisEngine implements Engine { boolean skipRealFlag = platformHooks.shouldBypassMantleStages(this); if (!skipRealFlag) { - getMantle().getMantle().flag(x >> 4, z >> 4, MantleFlag.REAL, true); + MantleChunk chunk = getMantle().getMantle().getChunk(x >> 4, z >> 4).use(); + try { + synchronized (chunk) { + Matter section = chunk.get(0); + if (section != null) { + section.deleteSlice(ObjectContinuationBundle.class); + } + chunk.flag(MantleFlag.REAL, true); + } + } finally { + chunk.release(); + } } getMetrics().getTotal().put(p.getMilliseconds()); generated.incrementAndGet(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/ResolvedTerrainProvider.java b/core/src/main/java/art/arcane/iris/generation/runtime/ResolvedTerrainProvider.java deleted file mode 100644 index a975ce738..000000000 --- a/core/src/main/java/art/arcane/iris/generation/runtime/ResolvedTerrainProvider.java +++ /dev/null @@ -1,261 +0,0 @@ -package art.arcane.iris.generation.runtime; - -import art.arcane.iris.generation.stage.IrisTransitionGeometryActuator; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; -import art.arcane.iris.world.history.FloatingBiomeOverlay; -import art.arcane.iris.world.history.SavedTerrainChunk; -import art.arcane.iris.world.history.TerrainBoundarySignature; -import art.arcane.iris.world.history.TransitionGenerationPlan; -import art.arcane.iris.world.history.TransitionFluidContainment; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.volmlib.nativelib.terrain.NativeBiome; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.spi.PlatformRegistries; -import art.arcane.iris.generation.context.ChunkContext; -import art.arcane.iris.generation.context.IrisContext; -import art.arcane.volmlib.util.hunk.Hunk; - -import java.io.IOException; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; - -public final class ResolvedTerrainProvider { - private static final int MAXIMUM_CACHED_CHUNKS = 64; - private static final int MAXIMUM_HEIGHT_CHUNKS = 4_096; - - private final Engine engine; - private final LinkedHashMap completed = new LinkedHashMap<>(64, 0.75F, true); - private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); - private final LinkedHashMap rawCompleted = new LinkedHashMap<>(64, 0.75F, true); - private final ConcurrentHashMap> rawPending = new ConcurrentHashMap<>(); - private final LinkedHashMap heights = new LinkedHashMap<>(64, 0.75F, true); - - public ResolvedTerrainProvider(Engine engine) { - this.engine = engine; - } - - public TerrainBoundarySignature column(int blockX, int blockZ) { - return resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ); - } - - public int height(int blockX, int blockZ, boolean ignoreFluid) { - long key = chunkKey(blockX >> 4, blockZ >> 4); - synchronized (heights) { - int[] cached = heights.get(key); - if (cached != null) { - return cached[(ignoreFluid ? 256 : 0) + (blockX & 15) * 16 + (blockZ & 15)]; - } - } - TerrainBoundarySignature column = resolve(blockX >> 4, blockZ >> 4).terrain().column(blockX, blockZ); - return ignoreFluid ? column.oceanFloorHeight() : column.surfaceHeight(); - } - - public void generate(EngineMode mode, int x, int z, Hunk blocks, - Hunk biomes, boolean multicore, ChunkContext context) { - SavedTerrainChunk terrain; - if (context.getComplex().getTransitionGenerationPlan() != null - && context.getComplex().getTransitionGenerationPlan().hasTransitionAtChunk(x >> 4, z >> 4)) { - ResolvedTerrain resolved = resolve(x >> 4, z >> 4); - terrain = resolved.terrain(); - context.setFloatingBiomes(resolved.floatingBiomes()); - copy(terrain, blocks, biomes, context); - } else { - mode.generateTerrain(x, z, blocks, biomes, multicore, context); - terrain = capture(x, z, blocks, biomes, context, true); - } - if (engine instanceof IrisEngine irisEngine) { - GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); - if (router != null) { - router.recordNaturalTerrain(terrain.boundaryOnly()); - router.recordFloatingBiomes(x >> 4, z >> 4, context.getFloatingBiomes()); - } - } - } - - public void clear() { - synchronized (completed) { - completed.clear(); - } - synchronized (rawCompleted) { - rawCompleted.clear(); - } - synchronized (heights) { - heights.clear(); - } - } - - private ResolvedTerrain resolve(int chunkX, int chunkZ) { - return resolve(chunkX, chunkZ, false); - } - - private ResolvedTerrain resolve(int chunkX, int chunkZ, boolean raw) { - LinkedHashMap cache = raw ? rawCompleted : completed; - ConcurrentHashMap> loads = raw ? rawPending : pending; - long key = chunkKey(chunkX, chunkZ); - synchronized (cache) { - ResolvedTerrain cached = cache.get(key); - if (cached != null) { - return cached; - } - } - CompletableFuture result = new CompletableFuture<>(); - CompletableFuture existing = loads.putIfAbsent(key, result); - if (existing != null) { - return existing.join(); - } - try { - synchronized (cache) { - ResolvedTerrain published = cache.get(key); - if (published != null) { - result.complete(published); - return published; - } - } - ResolvedTerrain terrain = raw ? compute(chunkX, chunkZ) : contain(chunkX, chunkZ); - if (!raw) { - cacheHeights(key, terrain.terrain()); - } - synchronized (cache) { - cache.put(key, terrain); - while (cache.size() > MAXIMUM_CACHED_CHUNKS) { - cache.pollFirstEntry(); - } - } - result.complete(terrain); - return terrain; - } catch (RuntimeException | Error failure) { - result.completeExceptionally(failure); - throw failure; - } finally { - loads.remove(key, result); - } - } - - private static long chunkKey(int chunkX, int chunkZ) { - return ((long) chunkX << 32) | (chunkZ & 0xffffffffL); - } - - private void cacheHeights(long key, SavedTerrainChunk terrain) { - int[] values = new int[512]; - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - TerrainBoundarySignature column = terrain.column((terrain.chunkX() << 4) + x, - (terrain.chunkZ() << 4) + z); - int index = x * 16 + z; - values[index] = column.surfaceHeight(); - values[256 + index] = column.oceanFloorHeight(); - } - } - synchronized (heights) { - heights.put(key, values); - while (heights.size() > MAXIMUM_HEIGHT_CHUNKS) { - heights.pollFirstEntry(); - } - } - } - - private ResolvedTerrain contain(int chunkX, int chunkZ) { - ResolvedTerrain raw = resolve(chunkX, chunkZ, true); - TransitionGenerationPlan plan = engine.getComplex().getTransitionGenerationPlan(); - if (plan == null || !plan.hasTransitionAtChunk(chunkX, chunkZ)) { - return raw; - } - try { - return new ResolvedTerrain(TransitionFluidContainment.contain(raw.terrain(), plan, - (x, z) -> rawColumn(plan, raw.terrain(), x, z)), raw.floatingBiomes()); - } catch (IOException failure) { - throw new IllegalStateException("Unable to contain transition fluids at " + chunkX + "," + chunkZ, failure); - } - } - - private BoundaryColumnGeometry rawColumn(TransitionGenerationPlan plan, SavedTerrainChunk current, int x, int z) - throws IOException { - if (plan.isHistoricalBlock(x, z)) { - BoundaryColumnGeometry geometry = plan.terrainSignatures().signatureAt(x, z) - .orElseThrow(() -> new IOException("Missing saved terrain boundary column at " + x + "," + z)) - .geometry(); - if (geometry.minimumY() != engine.getMinHeight() || geometry.height() != engine.getHeight()) { - throw new IOException("Saved terrain boundary column has the wrong vertical layout at " + x + "," + z); - } - return geometry; - } - return (x >> 4) == current.chunkX() && (z >> 4) == current.chunkZ() - ? current.column(x, z).geometry() : resolve(x >> 4, z >> 4, true).terrain().column(x, z).geometry(); - } - - private ResolvedTerrain compute(int chunkX, int chunkZ) { - int x = Math.multiplyExact(chunkX, 16); - int z = Math.multiplyExact(chunkZ, 16); - try (GenerationHistoryRuntimeRouter.CoordinateScope runtimeScope = engine instanceof IrisEngine irisEngine - ? irisEngine.openGenerationHistoryCoordinateScope(x, z) : null) { - IrisContext active = IrisContext.get(); - long sessionId = active == null ? 0L : active.getGenerationSessionId(); - ChunkContext context = new ChunkContext(x, z, engine.getComplex(), sessionId, true, - ChunkContext.PrefillPlan.NATURAL_TERRAIN, engine.getMetrics(), engine.getDimensionStackContext()); - context.beginSpeculativeTerrain(); - Hunk blocks = Hunk.newArrayHunk(16, engine.getHeight(), 16); - Hunk biomes = Hunk.newArrayHunk(16, engine.getHeight(), 16); - try (IrisContext.Scope ignored = IrisContext.open(engine, sessionId, context)) { - engine.getMode().generateTerrain(x, z, blocks, biomes, false, context); - return new ResolvedTerrain(capture(x, z, blocks, biomes, context, false), context.getFloatingBiomes()); - } - } catch (IOException failure) { - throw new IllegalStateException("Unable to scope natural terrain at " + chunkX + "," + chunkZ, failure); - } - } - - private SavedTerrainChunk capture(int x, int z, Hunk blocks, - Hunk biomes, ChunkContext context, boolean boundaryOnly) { - FloatingBiomeOverlay floating = context.getFloatingBiomes(); - if (floating != null) { - floating.retainHighestSurfaces((localX, localZ) -> highestSolid(blocks, localX, localZ)); - } - try { - return IrisTransitionGeometryActuator.capture(x, z, blocks, biomes, engine.getMinHeight(), context, boundaryOnly); - } catch (IOException failure) { - throw new IllegalStateException("Unable to capture natural terrain at " + (x >> 4) + "," + (z >> 4), failure); - } - } - - private static int highestSolid(Hunk blocks, int localX, int localZ) { - for (int y = blocks.getHeight() - 1; y >= 0; y--) { - NativeBlockState state = blocks.getRaw(localX, y, localZ); - if (state != null && !state.isAir() && !state.isFluid()) { - return y; - } - } - return -1; - } - - private static void copy(SavedTerrainChunk terrain, Hunk blocks, - Hunk biomes, ChunkContext context) { - PlatformRegistries registries = IrisPlatforms.get().registries(); - Map states = new HashMap<>(); - Map physicalBiomes = new HashMap<>(); - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - TerrainBoundarySignature column = terrain.column((terrain.chunkX() << 4) + x, (terrain.chunkZ() << 4) + z); - BoundaryColumnGeometry geometry = column.geometry(); - for (int offset = 0; offset < blocks.getHeight(); offset++) { - String stateKey = geometry.voxelAt(geometry.minimumY() + offset).stateKey(); - NativeBlockState state = states.computeIfAbsent(stateKey, registries::blockOrNull); - if (state == null) { - throw new IllegalStateException("Resolved terrain requires unavailable block state " + stateKey); - } - blocks.setRaw(x, offset, z, state); - String biomeKey = column.biomeAtSample(offset / 4); - biomes.setRaw(x, offset, z, physicalBiomes.computeIfAbsent(biomeKey, registries::biome)); - } - context.setTerrainHeight(x, z, column.oceanFloorHeight()); - } - } - } - - private record ResolvedTerrain(SavedTerrainChunk terrain, FloatingBiomeOverlay floatingBiomes) { - } - -} diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisBiomeActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisBiomeActuator.java index a0f13d4a6..351a35ff8 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisBiomeActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisBiomeActuator.java @@ -64,7 +64,7 @@ public class IrisBiomeActuator extends EngineAssignedActuator { int depth = h.getDepth(); int height = h.getHeight(); Engine engine = getEngine(); - Mantle mantle = context.isSpeculativeTerrain() ? null : engine.getMantle().getMantle(); + Mantle mantle = engine.getMantle().getMantle(); ChunkedDataCache biomeCache = context.getBiome(); IrisComplex complex = context.getComplex(); DimensionStackContext dimensionStackContext = engine.getDimensionStackContext(); @@ -109,7 +109,7 @@ public class IrisBiomeActuator extends EngineAssignedActuator { public static void publishNaturalMetadata(Engine engine, int x, int z, Hunk biomes, ChunkContext context) { - if (context.isSpeculativeTerrain() || !context.getComplex().allowsMantleChunkWrite(x >> 4, z >> 4)) { + if (!context.getComplex().allowsMantleChunkWrite(x >> 4, z >> 4)) { return; } Mantle mantle = engine.getMantle().getMantle(); @@ -146,7 +146,7 @@ public class IrisBiomeActuator extends EngineAssignedActuator { IrisComplex complex ) { TransitionGenerationPlan transitionPlan = complex.getTransitionGenerationPlan(); - if (transitionPlan == null) { + if (transitionPlan == null || !transitionPlan.hasTransitionAtChunk(worldX >> 4, worldZ >> 4)) { return; } TransitionGenerationPlan.TerrainSample terrainSample = transitionPlan.terrainSampleAt(worldX, worldZ); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java index d30f68a28..2df4baf8b 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java @@ -82,8 +82,7 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator mantleChunk = context.isSpeculativeTerrain() ? null - : getEngine().getMantle().getMantle().getChunk(x >> 4, z >> 4).use(); + MantleChunk mantleChunk = getEngine().getMantle().getMantle().getChunk(x >> 4, z >> 4).use(); try { MetadataCleaner metadata = new MetadataCleaner(mantleChunk, blocks.getHeight()); for (int localX = 0; localX < blocks.getWidth(); localX++) { diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java index e38ed4560..1d43804fc 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java @@ -431,9 +431,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier voxels = blended.voxels(); for (int offset = 0; offset < voxels.size(); offset++) { 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 d19c1c5df..30249d645 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 @@ -86,6 +86,30 @@ public final class Terrain3DColumn { return nearest; } + public Terrain3DColumn displaced(int offset, int height) { + if (height < 1) { + throw new IllegalArgumentException("Terrain column height must be positive"); + } + if (offset == 0) { + return this; + } + double displacedBase = Math.clamp(baseHeight + offset, 0D, height - 1D); + int[] displaced = new int[boundaries.length]; + int count = 0; + for (int index = 0; index < boundaries.length; index += 2) { + long minimum = index == 0 && boundaries[index] == 0 ? 0L : (long) boundaries[index] + offset; + long maximum = (long) boundaries[index + 1] + offset; + if (minimum >= height || maximum < 0L) { + continue; + } + displaced[count++] = (int) Math.max(0L, minimum); + displaced[count++] = (int) Math.min(height - 1L, maximum); + } + return count == 0 ? unshaped(displacedBase, height) + : new Terrain3DColumn(displacedBase, Math.clamp((long) minY + offset, 0, height), + shaped, Arrays.copyOf(displaced, count)); + } + @Override public boolean equals(Object value) { return this == value || value instanceof Terrain3DColumn other diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitStructureStagePolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitStructureStagePolicy.java index f31008015..646cb7784 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitStructureStagePolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitStructureStagePolicy.java @@ -98,7 +98,8 @@ public final class BukkitStructureStagePolicy implements StructureStagePolicy< @Override public NativeBlockPositionPredicate protectedPositions(Engine context) { - return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()), + return GenerationWritePolicy.protectedPositions(context.getDimension().getStaticObjectLayer(context.getData()) + .forTransition(context), context.getDimensionStackContext(), context.getMinHeight()); } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitTerrainColumnPolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitTerrainColumnPolicy.java index 15b974c71..fb54503f4 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitTerrainColumnPolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitTerrainColumnPolicy.java @@ -8,7 +8,6 @@ import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.studio.generation.JigsawStudioGenerator; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; -import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.world.history.TerrainNativeBlockKeys; import art.arcane.volmlib.nativelib.terrain.NativeBlockColumn; import art.arcane.volmlib.nativelib.terrain.NativeTerrainColumnPolicy; @@ -99,8 +98,7 @@ public final class BukkitTerrainColumnPolicy implements NativeTerrainColumnPolic @Override public Optional resolvedColumn() { - return engine.getComplex().resolvedTerrainColumn(query.blockX(), query.blockZ()) - .map(TerrainBoundarySignature::geometry); + return Optional.empty(); } @Override diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java b/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java index d895d4fa1..ecc7341cd 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/ImportedFeaturePolicy.java @@ -94,7 +94,8 @@ public class ImportedFeaturePolicy implements NativeImportedFeaturePolicy resolved = engine.getComplex().resolvedTerrainColumn(x, z); - if (resolved.isPresent()) { - return resolved.get().fluidHeight().isPresent() - ? coordinateShift + resolved.get().fluidHeight().getAsInt() : -1; - } return coordinateShift + (int) Math.round( engine.getComplex().getRiverWaterSurfaceStream().get(x, z) ); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisStaticObjectLayer.java b/core/src/main/java/art/arcane/iris/structure/object/IrisStaticObjectLayer.java index 05ae61c99..798cea822 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisStaticObjectLayer.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisStaticObjectLayer.java @@ -1,12 +1,18 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.iris.generation.mantle.ObjectContinuationPersistence; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.integration.Identifier; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; @@ -15,29 +21,44 @@ import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.MatterSlice; import art.arcane.volmlib.util.matter.MatterStructurePOI; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Supplier; /** * Compiles fixed placements once per dimension snapshot and writes only the current chunk's cells. * Stored Y coordinates are offsets from the dimension floor; configured positions use absolute world Y. */ public final class IrisStaticObjectLayer { - private static final IrisStaticObjectLayer EMPTY = new IrisStaticObjectLayer(Map.of()); + private static final IrisStaticObjectLayer EMPTY = new IrisStaticObjectLayer(Map.of(), List.of()); private static final List> REPLACED_METADATA = List.of( TileWrapper.class, Identifier.class, String.class, TreeBlockMaterial.class, MatterMarker.class, MatterStructurePOI.class); private final Map chunks; + private final List placements; + private final Map> placementsByChunk; + private volatile TransitionLayer transitionLayer; - private IrisStaticObjectLayer(Map chunks) { + private IrisStaticObjectLayer(Map chunks, List placements) { this.chunks = chunks; + this.placements = placements; + Map> indexed = new HashMap<>(); + for (Placement placement : placements) { + for (long chunk : placement.chunks().keySet()) { + indexed.computeIfAbsent(chunk, key -> new ArrayList<>()).add(placement); + } + } + indexed.replaceAll((chunk, entries) -> List.copyOf(entries)); + this.placementsByChunk = Map.copyOf(indexed); } public static IrisStaticObjectLayer compile(IrisDimension dimension, IrisData data) { @@ -45,7 +66,7 @@ public final class IrisStaticObjectLayer { if (dimension.getStaticObjects().isEmpty()) { return EMPTY; } - Compilation compilation = new Compilation(dimension.getMaxHeight() - dimension.getMinHeight()); + List placements = new ArrayList<>(dimension.getStaticObjects().size()); for (int index = 0; index < dimension.getStaticObjects().size(); index++) { IrisStaticObject entry = dimension.getStaticObjects().get(index); try { @@ -65,15 +86,48 @@ public final class IrisStaticObjectLayer { } else if (entry.isSmartBore()) { object = object.copy(); } + Compilation compilation = new Compilation(dimension.getMaxHeight() - dimension.getMinHeight()); IrisPosition position = entry.getPosition(); object.place(position.getX(), position.getY() - dimension.getMinHeight(), position.getZ(), compilation, entry.toPlacement(), new RNG(entry.getSeed()), data); + placements.add(compilation.freeze(new ObjectContinuationBundle.PlacementKey( + ObjectContinuationBundle.Kind.STATIC, position.getX() >> 4, position.getZ() >> 4, index))); } catch (RuntimeException failure) { throw new IllegalArgumentException("Invalid staticObjects[" + index + "] in dimension '" + dimension.getLoadKey() + "': " + failure.getMessage(), failure); } } - return compilation.freeze(); + return merge(placements); + } + + public IrisStaticObjectLayer forTransition(Engine engine) { + if (engine == null || placements.isEmpty()) { + return this; + } + IrisComplex complex = engine.getComplex(); + if (complex.getTransitionGenerationPlan() == null) { + return this; + } + GenerationHistoryRuntimeRouter router = engine instanceof IrisEngine irisEngine + ? irisEngine.getGenerationHistoryRuntimeRouter().orElse(null) : null; + boolean cacheable = !(engine instanceof IrisEngine) || router != null; + TransitionLayer cached = transitionLayer; + if (cacheable && cached != null && cached.complex() == complex && cached.router() == router) { + return cached.layer(); + } + List accepted = new ArrayList<>(placements.size()); + for (Placement placement : placements) { + Footprint footprint = placement.footprint(); + if (footprint == null || complex.allowsNewGenerationFootprint( + footprint.minimumX(), footprint.minimumZ(), footprint.maximumX(), footprint.maximumZ())) { + accepted.add(placement); + } + } + IrisStaticObjectLayer filtered = accepted.size() == placements.size() ? this : merge(accepted); + if (cacheable) { + transitionLayer = new TransitionLayer(complex, router, filtered); + } + return filtered; } public List blocks(int chunkX, int chunkZ) { @@ -99,6 +153,7 @@ public final class IrisStaticObjectLayer { if (blocks.isEmpty()) { return; } + persistContinuations(engine, x >> 4, z >> 4, output.getHeight()); MantleChunk chunk = engine.getMantle().getMantle().getChunk(x >> 4, z >> 4); chunk.use(); try { @@ -127,6 +182,58 @@ public final class IrisStaticObjectLayer { } } + private void persistContinuations(Engine engine, int chunkX, int chunkZ, int height) { + for (Placement placement : placementsByChunk.getOrDefault(chunkKey(chunkX, chunkZ), List.of())) { + if (placement.chunks().size() < 2) { + continue; + } + Map> fragments = new HashMap<>(); + for (Map.Entry destination : placement.chunks().entrySet()) { + long key = destination.getKey(); + fragments.put(new ObjectContinuationBundle.ChunkPosition((int) (key >> 32), (int) key), + () -> continuationPayload(destination.getValue(), height)); + } + Footprint footprint = placement.footprint(); + ObjectContinuationPersistence.persist(engine.getMantle().getMantle(), placement.key(), + new ObjectContinuationBundle.Bounds(footprint.minimumX(), footprint.minimumZ(), + footprint.maximumX(), footprint.maximumZ()), fragments); + } + } + + private static Matter continuationPayload(Chunk chunk, int height) { + IrisMatterSupport.ensureRegistered(); + Matter payload = new IrisMatter(16, height, 16); + for (Block block : chunk.ordered()) { + ObjectContinuationPersistence.put(payload, block.x(), block.y(), block.z(), block.state()); + if (block.tile() != null) { + ObjectContinuationPersistence.put(payload, block.x(), block.y(), block.z(), block.tile().clone()); + } + } + return payload; + } + + private static IrisStaticObjectLayer merge(List placements) { + if (placements.isEmpty()) { + return EMPTY; + } + Map> combined = new HashMap<>(); + for (Placement placement : placements) { + for (Map.Entry chunk : placement.chunks().entrySet()) { + combined.computeIfAbsent(chunk.getKey(), key -> new HashMap<>()).putAll(chunk.getValue().blocks()); + } + } + return new IrisStaticObjectLayer(freezeChunks(combined), List.copyOf(placements)); + } + + private static Map freezeChunks(Map> chunks) { + Map compiled = new HashMap<>(chunks.size()); + for (Map.Entry> chunk : chunks.entrySet()) { + Map blocks = Map.copyOf(chunk.getValue()); + compiled.put(chunk.getKey(), new Chunk(blocks, List.copyOf(blocks.values()))); + } + return Map.copyOf(compiled); + } + private static long chunkKey(int x, int z) { return ((long) x << 32) | (z & 0xffffffffL); } @@ -137,21 +244,30 @@ public final class IrisStaticObjectLayer { private record Chunk(Map blocks, List ordered) { } + private record Footprint(int minimumX, int minimumZ, int maximumX, int maximumZ) { + } + + private record Placement(ObjectContinuationBundle.PlacementKey key, Map chunks, Footprint footprint) { + } + + private record TransitionLayer(IrisComplex complex, GenerationHistoryRuntimeRouter router, IrisStaticObjectLayer layer) { + } + private static final class Compilation implements IObjectPlacer { private final int height; private final Map> chunks = new HashMap<>(); + private int minimumX = Integer.MAX_VALUE; + private int minimumZ = Integer.MAX_VALUE; + private int maximumX = Integer.MIN_VALUE; + private int maximumZ = Integer.MIN_VALUE; private Compilation(int height) { this.height = height; } - private IrisStaticObjectLayer freeze() { - Map compiled = new HashMap<>(chunks.size()); - for (Map.Entry> chunk : chunks.entrySet()) { - Map blocks = Map.copyOf(chunk.getValue()); - compiled.put(chunk.getKey(), new Chunk(blocks, List.copyOf(blocks.values()))); - } - return new IrisStaticObjectLayer(Map.copyOf(compiled)); + private Placement freeze(ObjectContinuationBundle.PlacementKey key) { + return new Placement(key, freezeChunks(chunks), chunks.isEmpty() ? null + : new Footprint(minimumX, minimumZ, maximumX, maximumZ)); } @Override @@ -166,6 +282,10 @@ public final class IrisStaticObjectLayer { if (state.isCustom() && (state.deferredPlacementKey() == null || state.placementBaseState() == null)) { throw new IllegalArgumentException("Custom object block has no placement data: " + state.key()); } + minimumX = Math.min(minimumX, x); + minimumZ = Math.min(minimumZ, z); + maximumX = Math.max(maximumX, x); + maximumZ = Math.max(maximumZ, z); Map chunk = chunks.computeIfAbsent(chunkKey(x >> 4, z >> 4), key -> new HashMap<>()); chunk.put(blockKey(x, y, z), new Block(x & 15, y, z & 15, state, null)); } diff --git a/core/src/main/java/art/arcane/iris/structure/placement/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/structure/placement/IrisStructureLocator.java index 7279f0fce..54aaf4b11 100644 --- a/core/src/main/java/art/arcane/iris/structure/placement/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/structure/placement/IrisStructureLocator.java @@ -41,6 +41,7 @@ import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.structure.nativegen.NativeStructureSuppression; import art.arcane.iris.structure.object.ObjectPlaceMode; +import art.arcane.iris.structure.object.IrisObjectVacuum; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.github.benmanes.caffeine.cache.Cache; @@ -589,7 +590,7 @@ public final class IrisStructureLocator { "assembled pieces exceed the configured structure or world bounds")) { return null; } - if (!allowsResolvedFootprint(engine, placement, pieces)) { + if (!allowsResolvedFootprint(engine, placement, structure, pieces)) { return null; } return new ResolvedPlacement(placement, selectedKey, structure, pieces, rng, originX, baseY, originZ, exactY); @@ -597,7 +598,7 @@ public final class IrisStructureLocator { public static boolean allowsResolvedFootprint(Engine engine, ResolvedPlacement resolved) { Objects.requireNonNull(resolved, "resolved placement"); - return allowsResolvedFootprint(engine, resolved.placement(), resolved.pieces()); + return allowsResolvedFootprint(engine, resolved.placement(), resolved.structure(), resolved.pieces()); } public static boolean requirePlacementOutput(IrisStructurePlacement placement, String structureKey, @@ -909,6 +910,7 @@ public final class IrisStructureLocator { private static boolean allowsResolvedFootprint( Engine engine, IrisStructurePlacement placement, + IrisStructure structure, KList pieces ) { int[] bounds = computeBounds(pieces); @@ -921,6 +923,10 @@ public final class IrisStructureLocator { || mode == StructureTerrainMode.BORE ? Math.max(0, terrain.getHorizontalPadding()) : 0; + if (pieces.size() == 1 && !placement.isAnchoredUnderground() + && IrisObjectVacuum.isVacuumMode(structure.getPlaceMode())) { + padding = Math.max(padding, IrisObjectVacuum.resolveRadius(structure.getPlaceMode(), null)); + } return engine.getComplex().allowsNewGenerationFootprint( saturatedOffset(bounds[0], -padding), saturatedOffset(bounds[2], -padding), diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationBlend.java b/core/src/main/java/art/arcane/iris/world/history/GenerationBlend.java index 3cd3cd358..40803e2f7 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationBlend.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationBlend.java @@ -58,23 +58,6 @@ public final class GenerationBlend { return (int) Math.round(historicalHeight + ((double) newHeight - historicalHeight) * newEpochWeight); } - public static boolean usesHistoricalMaterial(int blockX, int blockY, int blockZ, double newWeight) { - validateWeight(newWeight); - if (newWeight == 0D) { - return true; - } - if (newWeight == 1D) { - return false; - } - long hash = Math.floorDiv(blockX, 8) * 341873128712L - ^ Math.floorDiv(blockY, 8) * 132897987541L - ^ Math.floorDiv(blockZ, 8) * 42317861L; - hash = (hash ^ (hash >>> 30)) * 0xbf58476d1ce4e5b9L; - hash = (hash ^ (hash >>> 27)) * 0x94d049bb133111ebL; - hash ^= hash >>> 31; - return newWeight < (hash >>> 11) * 0x1.0p-53; - } - private static void validateWeight(double weight) { if (!Double.isFinite(weight) || weight < 0D || weight > 1D) { throw new IllegalArgumentException("Blend weight must be finite and between zero and one"); diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java index 5304dffb7..2b1df3d07 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java @@ -29,6 +29,7 @@ import java.util.concurrent.ArrayBlockingQueue; public final class GenerationHistory { private static final int MAXIMUM_CACHED_BOUNDARIES = 4; private static final int MAXIMUM_CACHED_TERRAIN_SIGNATURES = 4; + private static final int MAXIMUM_CACHED_TRANSITION_PLANS = 4; private static final int MAXIMUM_SEMANTIC_BATCH = 32; private static final int MAXIMUM_PENDING_SEMANTIC_CLAIMS = 128; @@ -44,6 +45,7 @@ public final class GenerationHistory { private final GenerationAdmission admission; private final Map boundaryCache; private final Map terrainSignatureCache; + private final Map transitionPlanCache; private final ArrayBlockingQueue pendingSemanticClaims = new ArrayBlockingQueue<>(MAXIMUM_PENDING_SEMANTIC_CLAIMS); @@ -68,6 +70,7 @@ public final class GenerationHistory { this.admission = new GenerationAdmission(paths.dimensionRoot()); this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES); this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES); + this.transitionPlanCache = boundedCache(MAXIMUM_CACHED_TRANSITION_PLANS); validateReferencedState(publication, true); } @@ -872,6 +875,10 @@ public final class GenerationHistory { } public synchronized TransitionGenerationPlan transitionPlan(long activationId) throws IOException { + TransitionGenerationPlan cached = transitionPlanCache.get(activationId); + if (cached != null) { + return cached; + } GenerationActivation activation = requireActivation(activationId); if (activation.isInitial()) { throw new IllegalArgumentException("The initial generation activation has no transition plan."); @@ -888,7 +895,7 @@ public final class GenerationHistory { GenerationBoundary boundary = boundary(activationId); TerrainBoundarySignatureStore.Snapshot terrainSnapshot = terrainSignatures(activationId); requireTransitionSnapshotIdentities(activation, boundary, terrainSnapshot); - return new TransitionGenerationPlan( + TransitionGenerationPlan plan = new TransitionGenerationPlan( new TransitionGenerationPlan.Specification( activation.activationId(), requireEpoch(parent.epochId()).epochId(), @@ -901,6 +908,8 @@ public final class GenerationHistory { boundary, terrainSnapshot ); + transitionPlanCache.put(activationId, plan); + return plan; } public synchronized int explicitChunkCount() { diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java index affa4ce2c..f8107ddea 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java @@ -10,14 +10,21 @@ import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngineMantle; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.generation.runtime.GenerationTransitionGate; +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.iris.world.storage.matter.IrisMatterContext; import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.volmlib.util.mantle.MantleRegionFiles; import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -32,6 +39,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { private final IrisEngine engine; private final GenerationHistory history; private final SavedBiomeRuntime biomes; + private final HistoricalObjectContinuations objectContinuations; private final GenerationBoundarySignatureSampler signatureSampler; private final ActivationRuntimeFactory runtimeFactory; private final LinkedHashMap bindings; @@ -65,6 +73,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { this.operationDepth = ThreadLocal.withInitial(() -> 0); this.scopedRoute = new ThreadLocal<>(); this.biomes = new SavedBiomeRuntime(engine, history); + this.objectContinuations = new HistoricalObjectContinuations(history, this::readObjectContinuationRegion); GenerationActivation active = history.activeActivation(); GenerationEpoch epoch = requireEpoch(active); @@ -96,6 +105,41 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { return biomes; } + public List objectContinuations(TransitionGenerationPlan plan, int chunkX, int chunkZ) { + return objectContinuations.at(plan, chunkX, chunkZ); + } + + public List decodeObjectContinuations(List continuations) throws IOException { + ArrayList decoded = new ArrayList<>(continuations.size()); + enterOperation(); + try { + int index = 0; + while (index < continuations.size()) { + long activationId = continuations.get(index).activationId(); + GenerationActivation activation = history.manifest().activation(activationId) + .orElseThrow(() -> new IOException("Missing object continuation activation " + activationId)); + SavedMantleAccess access = acquireSavedMantle(activation, requireEpoch(activation)); + try (IrisMatterContext.Scope scope = IrisMatterContext.open(access.data())) { + do { + decoded.add(continuations.get(index++).fragment().decode()); + } while (index < continuations.size() && continuations.get(index).activationId() == activationId); + } finally { + access.release().run(); + } + } + return List.copyOf(decoded); + } finally { + leaveOperation(); + } + } + + public record ObjectContinuation(long activationId, ObjectContinuationBundle.Fragment fragment) { + } + + public boolean allowsObjectFootprint(TransitionGenerationPlan plan, ObjectContinuationBundle.Bounds bounds) { + return objectContinuations.allowsFootprint(plan, bounds.minimumX(), bounds.minimumZ(), bounds.maximumX(), bounds.maximumZ()); + } + public static GenerationHistoryRuntimeRouter attach( IrisEngine engine, GenerationHistory history, @@ -461,6 +505,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } catch (Throwable biomeFailure) { failure = appendFailure(failure, biomeFailure); } + objectContinuations.clear(); try { retireBindings(retired); } catch (Throwable retirementFailure) { @@ -493,6 +538,45 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { return signatureSampler.open(engine); } + private Map readObjectContinuationRegion( + GenerationActivation activation, int regionX, int regionZ + ) throws IOException { + Path root = history.paths().activationMantleRoot(activation.activationId()); + if (!MantleRegionFiles.fileForRegion(root.toFile(), Mantle.key(regionX, regionZ), false).isFile()) { + return Map.of(); + } + enterOperation(); + try { + SavedMantleAccess access = acquireSavedMantle(activation, requireEpoch(activation)); + try { + Mantle mantle = access.mantle(); + MantleChunk anchor = mantle.getChunk(regionX << 5, regionZ << 5).use(); + try { + TectonicPlate plate = mantle.getLoadedRegions().get(Mantle.key(regionX, regionZ)); + HashMap bundles = new HashMap<>(); + for (int x = 0; x < 32; x++) { + for (int z = 0; z < 32; z++) { + MantleChunk chunk = plate.get(x, z); + Matter section = chunk == null ? null : chunk.get(0); + MatterSlice slice = section == null ? null : section.getSlice(ObjectContinuationBundle.class); + ObjectContinuationBundle bundle = slice == null ? null : slice.get(0, 0, 0); + if (bundle != null && !bundle.fragments().isEmpty()) { + bundles.put(ChunkGenerationOwnership.packChunk((regionX << 5) + x, (regionZ << 5) + z), bundle); + } + } + } + return Map.copyOf(bundles); + } finally { + anchor.release(); + } + } finally { + access.release().run(); + } + } finally { + leaveOperation(); + } + } + private SavedMantleAccess acquireSavedMantle(GenerationActivation activation, GenerationEpoch epoch) throws IOException { SavedMantleEntry saved = null; RuntimeCacheEntry runtime; @@ -526,7 +610,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { if (runtime != null) { RuntimeLease lease = awaitRuntime(runtime, activation.activationId()); try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(lease.binding())) { - return new SavedMantleAccess(engine.getMantle().getMantle(), lease::close); + return new SavedMantleAccess(engine.getMantle().getMantle(), engine.getData(), lease::close); } catch (Throwable failure) { lease.close(); throw failure; @@ -552,7 +636,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { entry.leases--; throw propagate(entry.failure, "Unable to load saved mantle for activation " + entry.activationId + "."); } - return new SavedMantleAccess(entry.mantle, () -> releaseSavedMantle(entry)); + return new SavedMantleAccess(entry.mantle, entry.data, () -> releaseSavedMantle(entry)); } finally { stateLock.unlock(); } @@ -1064,7 +1148,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } } - private record SavedMantleAccess(Mantle mantle, Runnable release) { + private record SavedMantleAccess(Mantle mantle, IrisData data, Runnable release) { } private record SavedChunkResources(GenerationHistory.GenerationStage stage, diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationKernelV1.java b/core/src/main/java/art/arcane/iris/world/history/GenerationKernelV1.java index 5af8ca069..da8f19595 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationKernelV1.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationKernelV1.java @@ -112,7 +112,7 @@ final class GenerationKernelV1 implements GenerationKernelRegistry.RuntimeFactor public void registerStaticObjects(IrisEngine engine, EngineMode mode) { IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); mode.registerStage((x, z, blocks, biomes, multicore, context) -> - staticObjects.apply(engine, x, z, blocks)); + staticObjects.forTransition(engine).apply(engine, x, z, blocks)); } @Override diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java index 6476fd032..edd4de352 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java @@ -208,8 +208,8 @@ public final class GenerationSemanticCapture { } private static boolean hasFullHydrologyWeight(IrisComplex complex, int blockX, int blockZ) { - TransitionGenerationPlan transition = complex.getTransitionGenerationPlan(); - return transition == null || transition.hydrologyWeightAt(blockX, blockZ) == 1D; + TransitionDisplacementField displacement = complex.getTransitionDisplacement(); + return displacement == null || displacement.seamWeight(blockX, blockZ) == 1D; } private static void captureCave( diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java index 4c4584afa..653c16b03 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java @@ -205,8 +205,8 @@ public final class GenerationSemanticQueries { } private static boolean allowsActiveHydrologyPrediction(Engine engine, int blockX, int blockZ) { - TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan(); - if (transition != null && transition.hydrologyWeightAt(blockX, blockZ) != 1D) { + TransitionDisplacementField displacement = engine.getComplex().getTransitionDisplacement(); + if (displacement != null && displacement.seamWeight(blockX, blockZ) != 1D) { return false; } return allowsActivePrediction( diff --git a/core/src/main/java/art/arcane/iris/world/history/HistoricalObjectContinuations.java b/core/src/main/java/art/arcane/iris/world/history/HistoricalObjectContinuations.java new file mode 100644 index 000000000..66c54961c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/history/HistoricalObjectContinuations.java @@ -0,0 +1,120 @@ +package art.arcane.iris.world.history; + +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter.ObjectContinuation; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class HistoricalObjectContinuations { + private static final Comparator PLACEMENT_ORDER = Comparator + .comparingLong(ObjectContinuation::activationId) + .thenComparing(continuation -> continuation.fragment().key().kind()) + .thenComparingInt(continuation -> continuation.fragment().key().sourceChunkX()) + .thenComparingInt(continuation -> continuation.fragment().key().sourceChunkZ()) + .thenComparingInt(continuation -> continuation.fragment().key().ordinal()); + private static final long MAXIMUM_BYTES = 32L * 1024L * 1024L; + + private final GenerationHistory history; + private final RegionReader reader; + private final Cache regions = Caffeine.newBuilder() + .maximumWeight(MAXIMUM_BYTES) + .weigher((RegionKey key, Region region) -> region.weight()) + .build(); + + HistoricalObjectContinuations(GenerationHistory history, RegionReader reader) { + this.history = history; + this.reader = reader; + } + + List at(TransitionGenerationPlan plan, int chunkX, int chunkZ) { + if (plan == null || plan.boundary().isHistoricalChunk(chunkX, chunkZ)) { + return List.of(); + } + RegionKey key = new RegionKey(plan.activationId(), chunkX >> 5, chunkZ >> 5); + Region region = regions.get(key, ignored -> load(plan, key)); + return region.chunks().getOrDefault(ChunkGenerationOwnership.packChunk(chunkX, chunkZ), List.of()); + } + + boolean allowsFootprint(TransitionGenerationPlan plan, int minimumX, int minimumZ, int maximumX, int maximumZ) { + for (int chunkX = minimumX >> 4; chunkX <= maximumX >> 4; chunkX++) { + for (int chunkZ = minimumZ >> 4; chunkZ <= maximumZ >> 4; chunkZ++) { + for (ObjectContinuation continuation : at(plan, chunkX, chunkZ)) { + ObjectContinuationBundle.Bounds bounds = continuation.fragment().bounds(); + if (bounds.minimumX() <= maximumX && bounds.maximumX() >= minimumX + && bounds.minimumZ() <= maximumZ && bounds.maximumZ() >= minimumZ) { + return false; + } + } + } + } + return true; + } + + void clear() { + regions.invalidateAll(); + } + + private Region load(TransitionGenerationPlan plan, RegionKey key) { + HashMap> chunks = new HashMap<>(); + long weight = 256L; + try { + for (GenerationActivation activation : history.manifest().activations()) { + if (activation.activationId() >= key.activationId()) { + continue; + } + Map stored = reader.read(activation, key.x(), key.z()); + for (Map.Entry entry : stored.entrySet()) { + int chunkX = ChunkGenerationOwnership.chunkX(entry.getKey()); + int chunkZ = ChunkGenerationOwnership.chunkZ(entry.getKey()); + if (plan.boundary().isHistoricalChunk(chunkX, chunkZ)) { + continue; + } + for (ObjectContinuationBundle.Fragment fragment : entry.getValue().fragments()) { + if (!intersectsSavedPlacement(plan, activation.activationId(), fragment)) { + continue; + } + chunks.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()).add(new ObjectContinuation(activation.activationId(), fragment)); + weight += 128L + fragment.encodedSize() + fragment.touchedChunks().size() * 16L; + } + } + } + } catch (IOException failure) { + throw new UncheckedIOException("Unable to read pending objects for transition " + key.activationId(), failure); + } + chunks.replaceAll((coordinate, fragments) -> { + fragments.sort(PLACEMENT_ORDER); + return List.copyOf(fragments); + }); + return new Region(Map.copyOf(chunks), (int) Math.min(Integer.MAX_VALUE, weight)); + } + + private boolean intersectsSavedPlacement(TransitionGenerationPlan plan, long activationId, + ObjectContinuationBundle.Fragment fragment) { + for (ObjectContinuationBundle.ChunkPosition chunk : fragment.touchedChunks()) { + if (plan.boundary().isHistoricalChunk(chunk.x(), chunk.z()) + && history.resolveActivation(chunk.x(), chunk.z()).activationId() == activationId) { + return true; + } + } + return false; + } + + @FunctionalInterface + interface RegionReader { + Map read(GenerationActivation activation, int regionX, int regionZ) throws IOException; + } + + private record RegionKey(long activationId, int x, int z) { + } + + private record Region(Map> chunks, int weight) { + } +} diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java index 8e452be5c..fd6615089 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java @@ -1,5 +1,7 @@ package art.arcane.iris.world.history; +import art.arcane.iris.world.storage.Durability; + import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -225,14 +227,14 @@ public final class SavedBiomeStore { } } if (!unpublished) { - output.getChannel().force(true); + Durability.force(output.getChannel()); } appendCompleted = true; } catch (IOException failure) { try { output.setLength(offset); if (!unpublished) { - output.getChannel().force(true); + Durability.force(output.getChannel()); } } catch (IOException rollback) { failure.addSuppressed(rollback); @@ -467,7 +469,7 @@ public final class SavedBiomeStore { while (header.hasRemaining()) { output.write(header); } - output.force(true); + Durability.force(output); } Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE); forceDirectory(directory); @@ -477,11 +479,11 @@ public final class SavedBiomeStore { } private static void forceDirectory(Path path) throws IOException { - if (File.separatorChar == '\\') { + if (File.separatorChar == '\\' || !Durability.enabled()) { return; } try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { - channel.force(true); + Durability.force(channel); } catch (UnsupportedOperationException failure) { throw new IOException("Saved biome directory cannot be durability-synced: " + path, failure); } @@ -498,7 +500,7 @@ public final class SavedBiomeStore { transfer(input, output, region.offsets[slot], region.lengths[slot] + 8L); } } - output.force(true); + Durability.force(output); } Files.move(temporary, region.path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); synchronized (regions) { @@ -591,7 +593,7 @@ public final class SavedBiomeStore { private static void truncateTail(RandomAccessFile file, long length) throws IOException { file.setLength(length); - file.getChannel().force(true); + Durability.force(file.getChannel()); } private static int checksum(byte[] bytes) { diff --git a/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java b/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java index f91df067c..8088aa2f6 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java @@ -48,6 +48,7 @@ import java.util.PriorityQueue; import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.function.Consumer; import java.util.concurrent.atomic.LongAdder; import java.util.zip.CRC32; @@ -243,11 +244,6 @@ public final class TerrainBoundarySignatureStore { long[] cellKeys = new long[cellCount]; Long2ObjectOpenHashMap cells = new Long2ObjectOpenHashMap<>(cellCount); - LinkedHashMap> validated = new LinkedHashMap<>( - MAXIMUM_CACHED_SHARDS, - 0.75F, - true - ); int countedEntries = 0; long previousCellKey = 0L; for (int index = 0; index < cellCount; index++) { @@ -266,19 +262,6 @@ public final class TerrainBoundarySignatureStore { throw invalid(snapshot, "truncated shard hash"); } String hash = HexFormat.of().formatHex(hashBytes); - List signatures = validated.get(hash); - if (signatures == null) { - Path shard = directory.resolve(shardFileName(hash)); - requireRegularFile(shard, "Terrain boundary shard"); - signatures = CellShard.read(shard, hash, cellX, cellZ); - validated.put(hash, signatures); - trim(validated); - } else { - signatures = relocate(signatures, cellX, cellZ); - } - if (signatures.size() != count) { - throw invalid(snapshot, "terrain signature shard count does not match its catalog entry"); - } countedEntries = Math.addExact(countedEntries, count); cellKeys[index] = cellKey; cells.put(cellKey, new CellReference(hash, count)); @@ -603,33 +586,6 @@ public final class TerrainBoundarySignatureStore { } } - private static List relocate( - List source, - int cellX, - int cellZ - ) { - int minimumX = Math.multiplyExact(cellX, CELL_SIZE); - int minimumZ = Math.multiplyExact(cellZ, CELL_SIZE); - ArrayList relocated = new ArrayList<>(source.size()); - for (TerrainBoundarySignature signature : source) { - int localX = Math.floorMod(signature.blockX(), CELL_SIZE); - int localZ = Math.floorMod(signature.blockZ(), CELL_SIZE); - relocated.add(new TerrainBoundarySignature( - new TerrainBoundarySignature.Column( - minimumX + localX, - minimumZ + localZ, - signature.surfaceHeight(), - signature.oceanFloorHeight(), - signature.fluidHeight(), - signature.upperCeilingDepth() - ), - signature.samples(), - signature.geometry() - )); - } - return List.copyOf(relocated); - } - private static void trim(LinkedHashMap cache) { while (cache.size() > MAXIMUM_CACHED_SHARDS) { Iterator entries = cache.entrySet().iterator(); @@ -776,6 +732,14 @@ public final class TerrainBoundarySignatureStore { } } + void forEachSignature(BlockBounds bounds, Consumer consumer) { + try { + source.forEachSignature(bounds, consumer); + } catch (IOException error) { + throw new IllegalStateException("Unable to query terrain boundary signatures", error); + } + } + boolean intersectsTerrainBand(BlockBounds footprint, int width) { try { return source.intersectsTerrainBand(footprint, width); @@ -1045,6 +1009,25 @@ public final class TerrainBoundarySignatureStore { }); } + private void forEachSignature(BlockBounds bounds, Consumer consumer) throws IOException { + visitSuperCells(bounds, superKey -> { + for (long cellKey : cellsBySuperCell.get(superKey)) { + BlockBounds cell = BlockBounds.cell(cellKey, CELL_SIZE); + if (cell.maximumX() < bounds.minimumX() || cell.minimumX() > bounds.maximumX() + || cell.maximumZ() < bounds.minimumZ() || cell.minimumZ() > bounds.maximumZ()) { + continue; + } + for (TerrainBoundarySignature signature : load(cellKey, cells.get(cellKey))) { + if (signature.blockX() >= bounds.minimumX() && signature.blockX() <= bounds.maximumX() + && signature.blockZ() >= bounds.minimumZ() && signature.blockZ() <= bounds.maximumZ()) { + consumer.accept(signature); + } + } + } + return false; + }); + } + private boolean visitSuperCells(BlockBounds bounds, SuperCellVisitor visitor) throws IOException { int size = CELL_SIZE * SUPER_CELL_SIZE; int minimumX = saturatedFloorDiv(bounds.minimumX(), size); diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionBandIndex.java b/core/src/main/java/art/arcane/iris/world/history/TransitionBandIndex.java new file mode 100644 index 000000000..07c20efb0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionBandIndex.java @@ -0,0 +1,93 @@ +package art.arcane.iris.world.history; + +import java.util.concurrent.atomic.AtomicLongArray; +import java.util.concurrent.atomic.AtomicReferenceArray; + +final class TransitionBandIndex { + private static final int MAXIMUM_CACHED_REGIONS = 1_024; + + private final GenerationBoundary boundary; + private final TransitionBoundarySampler sampler; + private final AtomicReferenceArray regions = new AtomicReferenceArray<>(MAXIMUM_CACHED_REGIONS); + private final ThreadLocal lastRegion = new ThreadLocal<>(); + + TransitionBandIndex(GenerationBoundary boundary, TransitionBoundarySampler sampler) { + this.boundary = boundary; + this.sampler = sampler; + } + + boolean contains(int chunkX, int chunkZ) { + long key = GenerationBoundary.packChunk(chunkX >> 5, chunkZ >> 5); + Region region = lastRegion.get(); + if (region == null || region.key != key) { + region = region(key); + lastRegion.set(region); + } + int index = ((chunkZ & 31) << 5) | (chunkX & 31); + int word = index >>> 5; + int shift = (index & 31) << 1; + long state = (region.states.get(word) >>> shift) & 3L; + if (state != 0L) { + return state == 3L; + } + synchronized (region) { + long states = region.states.get(word); + state = (states >>> shift) & 3L; + if (state == 0L) { + state = intersects(chunkX, chunkZ) ? 3L : 1L; + region.states.set(word, states | (state << shift)); + } + return state == 3L; + } + } + + int cachedRegionCount() { + int count = 0; + for (int index = 0; index < regions.length(); index++) { + if (regions.get(index) != null) { + count++; + } + } + return count; + } + + private Region region(long key) { + long mixed = key; + mixed = (mixed ^ (mixed >>> 33)) * 0xff51afd7ed558ccdL; + mixed = (mixed ^ (mixed >>> 33)) * 0xc4ceb9fe1a85ec53L; + int slot = (int) (mixed ^ (mixed >>> 33)) & (MAXIMUM_CACHED_REGIONS - 1); + Region replacement = null; + while (true) { + Region current = regions.get(slot); + if (current != null && current.key == key) { + return current; + } + if (replacement == null) { + replacement = new Region(key); + } + if (regions.compareAndSet(slot, current, replacement)) { + return replacement; + } + } + } + + private boolean intersects(int chunkX, int chunkZ) { + if (boundary.isHistoricalChunk(chunkX, chunkZ)) { + return false; + } + int minimumX = Math.multiplyExact(chunkX, GenerationBoundary.CHUNK_SIZE); + int minimumZ = Math.multiplyExact(chunkZ, GenerationBoundary.CHUNK_SIZE); + return sampler.intersectsTerrainBand(minimumX, minimumZ, + Math.addExact(minimumX, GenerationBoundary.CHUNK_SIZE - 1), + Math.addExact(minimumZ, GenerationBoundary.CHUNK_SIZE - 1)); + } + + private static final class Region { + private final long key; + private final AtomicLongArray states = new AtomicLongArray(32); + + private Region(long key) { + this.key = key; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionBoundarySampler.java b/core/src/main/java/art/arcane/iris/world/history/TransitionBoundarySampler.java index fbc2999e1..c0bac2e4f 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionBoundarySampler.java +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionBoundarySampler.java @@ -59,13 +59,14 @@ final class TransitionBoundarySampler { BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) { CandidateIndex candidates = chunkCache(blockX, blockZ).candidates; - Nearest nearest = candidates.nearest(blockX, blockZ, square(terrainWidth)); - if (nearest.count == 0 || nearest.distancesSquared[0] >= square(terrainWidth)) { + int seamWidth = Math.min(4, terrainWidth); + Nearest nearest = candidates.nearest(blockX, blockZ, square(seamWidth)); + if (nearest.count == 0 || nearest.distancesSquared[0] >= square(seamWidth)) { return BoundaryGeometryInfluence.none(); } double distance = Math.sqrt(nearest.distancesSquared[0]); double weight = GenerationBlend.newEpochWeight(Math.max(0D, distance - 1D), - Math.max(1, terrainWidth - 1)); + Math.max(1, seamWidth - 1)); int count = nearest.distancesSquared[0] <= 1D ? 1 : nearest.count; double total = 0D; for (int index = 0; index < count; index++) { @@ -77,10 +78,7 @@ final class TransitionBoundarySampler { nearest.signatures[index].geometry(), (1D / Math.max(1D, nearest.distancesSquared[index])) / total)); } - int openingDepth = Math.min(8, Math.max(1, terrainWidth / 4)); - double openingWeight = GenerationBlend.newEpochWeight(Math.max(0D, distance - openingDepth), - Math.max(1, terrainWidth - openingDepth)); - return new BoundaryGeometryInfluence(weight, openingWeight, contributions); + return new BoundaryGeometryInfluence(weight, weight, contributions); } boolean intersectsTerrainBand(int minimumX, int minimumZ, int maximumX, int maximumZ) { diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java b/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java new file mode 100644 index 000000000..ed8eb1741 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java @@ -0,0 +1,252 @@ +package art.arcane.iris.world.history; + +import java.util.LinkedHashMap; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public final class TransitionDisplacementField { + private static final int MAXIMUM_CACHED_CHUNKS = 256; + private static final int LOCAL_SUPPORT = 8; + private static final Sample NONE = new Sample(0D, Double.MAX_VALUE, 1D, 0D, 0D, 0D, null); + + private final TransitionGenerationPlan plan; + private final Sources sources; + private final LinkedHashMap completed = new LinkedHashMap<>(64, 0.75F, true); + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + private final ThreadLocal local = new ThreadLocal<>(); + + public TransitionDisplacementField(TransitionGenerationPlan plan, Sources sources) { + this.plan = Objects.requireNonNull(plan); + this.sources = Objects.requireNonNull(sources); + } + + public double height(int x, int z, double nativeHeight) { + double displacement = sample(x, z).displacement(); + return displacement == 0D ? nativeHeight : Math.clamp(nativeHeight + displacement, 0D, sources.height() - 1D); + } + + public double fluidHeight(int x, int z, double nativeHead) { + Sample sample = sample(x, z); + if (sample.newTerrainWeight() == 1D) { + return nativeHead; + } + double historicalHead = sample.wetWeight() * sample.wetHeight() + + (1D - sample.wetWeight()) * Math.min(sample.dryHeight(), nativeHead); + return GenerationBlend.interpolate(historicalHead, nativeHead, sample.newTerrainWeight()); + } + + public String fluidStateKey(int x, int z) { + Sample sample = sample(x, z); + return sample.newTerrainWeight() < 0.5D ? sample.fluidStateKey() : null; + } + + public double seamWeight(int x, int z) { + Sample sample = sample(x, z); + return sample.newTerrainWeight() == 1D ? 1D + : GenerationBlend.newEpochWeight(Math.max(0D, sample.distance() - 1D), 16); + } + + public Sample sample(int x, int z) { + int chunkX = x >> 4; + int chunkZ = z >> 4; + if (!plan.hasTransitionAtChunk(chunkX, chunkZ)) { + return NONE; + } + long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ); + ChunkField field = local.get(); + if (field == null || field.key() != key) { + field = chunk(chunkX, chunkZ, key); + local.set(field); + } + return field.samples()[(x & 15) * 16 + (z & 15)]; + } + + public void clear() { + synchronized (completed) { + completed.clear(); + } + local.remove(); + } + + private ChunkField chunk(int chunkX, int chunkZ, long key) { + synchronized (completed) { + ChunkField cached = completed.get(key); + if (cached != null) { + return cached; + } + } + CompletableFuture result = new CompletableFuture<>(); + CompletableFuture existing = pending.putIfAbsent(key, result); + if (existing != null) { + return existing.join(); + } + try { + synchronized (completed) { + ChunkField cached = completed.get(key); + if (cached != null) { + result.complete(cached); + return cached; + } + } + ChunkField field = build(chunkX, chunkZ, key); + synchronized (completed) { + completed.put(key, field); + while (completed.size() > MAXIMUM_CACHED_CHUNKS) { + completed.pollFirstEntry(); + } + } + result.complete(field); + return field; + } catch (RuntimeException | Error failure) { + result.completeExceptionally(failure); + throw failure; + } finally { + pending.remove(key, result); + } + } + + private ChunkField build(int chunkX, int chunkZ, long key) { + int minimumX = Math.multiplyExact(chunkX, 16); + int minimumZ = Math.multiplyExact(chunkZ, 16); + Accumulator[] columns = new Accumulator[256]; + double maximumSupport = 0D; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + TransitionGenerationPlan.TerrainSample terrain = plan.terrainSampleAt(minimumX + x, minimumZ + z); + double distance = terrain.distanceToHistoricalTerrain(); + double support = Math.min(plan.widthBlocks(), distance + LOCAL_SUPPORT); + double weight = terrain.hasHistoricalSignature() + ? GenerationBlend.newEpochWeight(Math.max(0D, distance - 1D), Math.max(1, plan.widthBlocks() - 1)) + : 1D; + columns[x * 16 + z] = new Accumulator(minimumX + x, minimumZ + z, distance, support, weight); + if (weight < 1D) { + maximumSupport = Math.max(maximumSupport, support); + } + } + } + long radius = (long) Math.ceil(maximumSupport); + plan.terrainSignatures().forEachSignature(new TerrainBoundarySignatureStore.BlockBounds( + minimumX - radius, minimumZ - radius, minimumX + 15L + radius, minimumZ + 15L + radius), + signature -> accumulate(columns, signature)); + Sample[] samples = new Sample[256]; + for (int index = 0; index < samples.length; index++) { + samples[index] = columns[index].finish(); + } + return new ChunkField(key, samples); + } + + private void accumulate(Accumulator[] columns, TerrainBoundarySignature signature) { + double residual = Double.NaN; + String fluidKey = null; + boolean wet = signature.fluidHeight().isPresent(); + for (Accumulator column : columns) { + double weight = column.weight(signature.blockX(), signature.blockZ()); + if (weight == 0D) { + continue; + } + if (Double.isNaN(residual)) { + double nativeHeight = sources.heights().height(signature.blockX(), signature.blockZ()); + if (!Double.isFinite(nativeHeight)) { + throw new IllegalStateException("Transition anchor has a nonfinite native height"); + } + residual = signature.oceanFloorHeight() - nativeHeight; + if (wet) { + int y = signature.geometry().minimumY() + signature.fluidHeight().getAsInt(); + fluidKey = signature.geometry().voxelAt(y).fluidStateKey(); + } + } + column.total += weight; + column.displacement += weight * residual; + if (wet) { + column.wetTotal += weight; + column.wetHeight += weight * signature.fluidHeight().getAsInt(); + column.minimumWetHeight = Math.min(column.minimumWetHeight, signature.fluidHeight().getAsInt()); + column.maximumWetHeight = Math.max(column.maximumWetHeight, signature.fluidHeight().getAsInt()); + if (weight > column.fluidKeyWeight) { + column.fluidKeyWeight = weight; + column.fluidKey = fluidKey; + } + } else { + column.dryHeight += weight * signature.oceanFloorHeight(); + column.minimumDryHeight = Math.min(column.minimumDryHeight, signature.oceanFloorHeight()); + column.maximumDryHeight = Math.max(column.maximumDryHeight, signature.oceanFloorHeight()); + } + } + } + + public record Sources(HeightSource heights, int height) { + public Sources { + Objects.requireNonNull(heights); + if (height < 1) { + throw new IllegalArgumentException("Transition world height must be positive"); + } + } + } + + @FunctionalInterface + public interface HeightSource { + double height(int x, int z); + } + + public record Sample(double displacement, double distance, double newTerrainWeight, double wetWeight, + double wetHeight, double dryHeight, String fluidStateKey) { + } + + private record ChunkField(long key, Sample[] samples) { + } + + private static final class Accumulator { + private final int x; + private final int z; + private final double distance; + private final double support; + private final double newWeight; + private double total; + private double displacement; + private double wetTotal; + private double wetHeight; + private double dryHeight; + private double minimumWetHeight = Double.POSITIVE_INFINITY; + private double maximumWetHeight = Double.NEGATIVE_INFINITY; + private double minimumDryHeight = Double.POSITIVE_INFINITY; + private double maximumDryHeight = Double.NEGATIVE_INFINITY; + private double fluidKeyWeight; + private String fluidKey; + + private Accumulator(int x, int z, double distance, double support, double newWeight) { + this.x = x; + this.z = z; + this.distance = distance; + this.support = support; + this.newWeight = newWeight; + } + + private double weight(int anchorX, int anchorZ) { + if (newWeight == 1D) { + return 0D; + } + double dx = (double) anchorX - x; + double dz = (double) anchorZ - z; + double squared = dx * dx + dz * dz; + if (squared >= support * support) { + return 0D; + } + double q = Math.sqrt(squared) / support; + double remainder = 1D - q; + return remainder * remainder * remainder * remainder * (1D + 4D * q) + / Math.max(1D, squared * squared); + } + + private Sample finish() { + if (total == 0D) { + return NONE; + } + double dryTotal = total - wetTotal; + return new Sample((1D - newWeight) * displacement / total, distance, newWeight, + Math.clamp(wetTotal / total, 0D, 1D), wetTotal == 0D ? 0D + : Math.clamp(wetHeight / wetTotal, minimumWetHeight, maximumWetHeight), + dryTotal <= 0D ? 0D : Math.clamp(dryHeight / dryTotal, minimumDryHeight, maximumDryHeight), fluidKey); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionFluidContainment.java b/core/src/main/java/art/arcane/iris/world/history/TransitionFluidContainment.java deleted file mode 100644 index e1445d5e6..000000000 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionFluidContainment.java +++ /dev/null @@ -1,149 +0,0 @@ -package art.arcane.iris.world.history; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.OptionalInt; -import java.util.Set; - -public final class TransitionFluidContainment { - private static final Set BANK_MATERIALS = Set.of( - "minecraft:stone", "minecraft:deepslate", "minecraft:granite", "minecraft:diorite", - "minecraft:andesite", "minecraft:tuff", "minecraft:calcite", "minecraft:bedrock", - "minecraft:dirt", "minecraft:coarse_dirt", "minecraft:grass_block", "minecraft:podzol", - "minecraft:mycelium", "minecraft:clay", "minecraft:terracotta", "minecraft:netherrack", - "minecraft:basalt", "minecraft:smooth_basalt", "minecraft:blackstone", "minecraft:end_stone", - "minecraft:obsidian", "minecraft:crying_obsidian"); - private static final BoundaryColumnGeometry.Voxel STONE = new BoundaryColumnGeometry.Voxel( - "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); - - private TransitionFluidContainment() { - } - - public static SavedTerrainChunk contain(SavedTerrainChunk raw, TransitionGenerationPlan plan, ColumnSource source) - throws IOException { - List columns = new ArrayList<>(256); - boolean changed = false; - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - int x = Math.addExact(Math.multiplyExact(raw.chunkX(), 16), localX); - int z = Math.addExact(Math.multiplyExact(raw.chunkZ(), 16), localZ); - BoundaryColumnGeometry original = raw.column(x, z).geometry(); - BoundaryColumnGeometry corrected = plan.isHistoricalBlock(x, z) || plan.newEpochWeightAt(x, z) == 1D - ? original : containColumn(x, z, original, source); - columns.add(corrected); - changed |= corrected != original; - } - } - if (!changed) { - return raw; - } - BoundaryColumnGeometry first = columns.getFirst(); - return SavedTerrainChunk.capture(raw.chunkX(), raw.chunkZ(), first.minimumY(), first.height(), - raw.nativeStatus(), new ContainedSource(raw, columns)); - } - - private static BoundaryColumnGeometry containColumn(int x, int z, BoundaryColumnGeometry original, - ColumnSource source) throws IOException { - List voxels = original.voxels(); - List corrected = null; - BoundaryColumnGeometry.Voxel[] banks = null; - BoundaryColumnGeometry[] neighbors = null; - for (int offset = 0; offset < voxels.size(); offset++) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(offset); - if (voxel.phase() != BoundaryColumnGeometry.Phase.FLUID || voxel.protectedContent()) { - continue; - } - if (neighbors == null) { - neighbors = new BoundaryColumnGeometry[]{ - source.column(Math.subtractExact(x, 1), z), source.column(Math.addExact(x, 1), z), - source.column(x, Math.subtractExact(z, 1)), source.column(x, Math.addExact(z, 1))}; - } - int worldY = Math.addExact(original.minimumY(), offset); - if (!exposed(original, neighbors, worldY)) { - continue; - } - if (corrected == null) { - corrected = new ArrayList<>(voxels); - banks = nearestDrySolids(voxels); - } - corrected.set(offset, banks[offset]); - } - return corrected == null ? original : BoundaryColumnGeometry.fromVoxels(original.minimumY(), corrected); - } - - private static boolean exposed(BoundaryColumnGeometry original, BoundaryColumnGeometry[] neighbors, int worldY) { - if (original.voxelAt(Math.subtractExact(worldY, 1)).phase() == BoundaryColumnGeometry.Phase.AIR) { - return true; - } - for (BoundaryColumnGeometry neighbor : neighbors) { - if (neighbor.voxelAt(worldY).phase() == BoundaryColumnGeometry.Phase.AIR) { - return true; - } - } - return false; - } - - private static BoundaryColumnGeometry.Voxel[] nearestDrySolids(List voxels) { - BoundaryColumnGeometry.Voxel[] materials = new BoundaryColumnGeometry.Voxel[voxels.size()]; - int[] distances = new int[voxels.size()]; - BoundaryColumnGeometry.Voxel nearest = STONE; - int last = -voxels.size() - 1; - for (int offset = 0; offset < voxels.size(); offset++) { - if (drySolid(voxels.get(offset))) { - nearest = voxels.get(offset); - last = offset; - } - materials[offset] = nearest; - distances[offset] = offset - last; - } - last = voxels.size() * 2; - nearest = null; - for (int offset = voxels.size() - 1; offset >= 0; offset--) { - if (drySolid(voxels.get(offset))) { - nearest = voxels.get(offset); - last = offset; - } - if (nearest != null && last - offset < distances[offset]) { - materials[offset] = nearest; - } - } - return materials; - } - - private static boolean drySolid(BoundaryColumnGeometry.Voxel voxel) { - String state = voxel.stateKey(); - int properties = state.indexOf('['); - String key = properties < 0 ? state : state.substring(0, properties); - return voxel.phase() == BoundaryColumnGeometry.Phase.SOLID - && voxel.fluidStateKey().isEmpty() && !voxel.protectedContent() && BANK_MATERIALS.contains(key); - } - - @FunctionalInterface - public interface ColumnSource { - BoundaryColumnGeometry column(int blockX, int blockZ) throws IOException; - } - - private record ContainedSource(SavedTerrainChunk raw, List columns) - implements SavedTerrainChunk.VoxelSource { - @Override - public OptionalInt groundSurface(int localX, int localZ) { - return OptionalInt.of(signature(localX, localZ).oceanFloorHeight()); - } - - @Override - public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { - return columns.get(localX * 16 + localZ).voxelAt(worldY); - } - - @Override - public String biome(int localX, int worldY, int localZ) { - TerrainBoundarySignature signature = signature(localX, localZ); - return signature.biomeAtSample((worldY - signature.samples().layout().minimumY()) / 4); - } - - private TerrainBoundarySignature signature(int x, int z) { - return raw.column((raw.chunkX() << 4) + x, (raw.chunkZ() << 4) + z); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java b/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java index f210b4f92..f082b73f1 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java @@ -8,6 +8,8 @@ public final class TransitionGenerationPlan { private final GenerationBoundary boundary; private final TerrainBoundarySignatureStore.Snapshot terrainSignatures; private final TransitionBoundarySampler terrainSampler; + private final TransitionBandIndex terrainBand; + private final TerrainSample newTerrain; public TransitionGenerationPlan( Specification specification, @@ -34,6 +36,8 @@ public final class TransitionGenerationPlan { specification.widthBlocks(), terrainSignatures ); + terrainBand = new TransitionBandIndex(boundary, terrainSampler); + newTerrain = TerrainSample.newTerrain(specification.widthBlocks()); } public long activationId() { @@ -93,18 +97,11 @@ public final class TransitionGenerationPlan { } public boolean hasTransitionAtChunk(int chunkX, int chunkZ) { - if (boundary.isHistoricalChunk(chunkX, chunkZ)) { - return false; - } - int minimumX = Math.multiplyExact(chunkX, GenerationBoundary.CHUNK_SIZE); - int minimumZ = Math.multiplyExact(chunkZ, GenerationBoundary.CHUNK_SIZE); - return terrainSampler.intersectsTerrainBand(minimumX, minimumZ, - Math.addExact(minimumX, GenerationBoundary.CHUNK_SIZE - 1), - Math.addExact(minimumZ, GenerationBoundary.CHUNK_SIZE - 1)); + return terrainBand.contains(chunkX, chunkZ); } public BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) { - if (boundary.isHistoricalBlock(blockX, blockZ)) { + if (!terrainBand.contains(blockX >> 4, blockZ >> 4)) { return BoundaryGeometryInfluence.none(); } return terrainSampler.geometryAt(blockX, blockZ); @@ -114,7 +111,7 @@ public final class TransitionGenerationPlan { if (boundary.isHistoricalBlock(blockX, blockZ)) { return TerrainSample.historicalTerrain(); } - return terrainSampler.sample(blockX, blockZ); + return terrainBand.contains(blockX >> 4, blockZ >> 4) ? terrainSampler.sample(blockX, blockZ) : newTerrain; } public Optional historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { @@ -128,12 +125,7 @@ public final class TransitionGenerationPlan { TerrainSample sample ) { Objects.requireNonNull(sample, "Terrain sample"); - if (sample.newEpochWeight() == 1D) { - return Optional.empty(); - } - double weight = GenerationBlend.newEpochWeight( - Math.max(0D, sample.distanceToHistoricalTerrain() - 1D), Math.max(1, widthBlocks() - 1)); - return GenerationBlend.usesHistoricalMaterial(blockX, blockY, blockZ, weight) + return sample.distanceToHistoricalTerrain() < Math.min(4, widthBlocks()) ? sample.historicalPhysicalBiomeKeyAt(blockY) : Optional.empty(); } diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionGeometryBlender.java b/core/src/main/java/art/arcane/iris/world/history/TransitionGeometryBlender.java index 9f5d87df0..cd5c39db6 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionGeometryBlender.java +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionGeometryBlender.java @@ -5,11 +5,8 @@ import java.util.List; import java.util.Objects; public final class TransitionGeometryBlender { - private static final int MAXIMUM_OPENING_HEIGHT = 64; private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - private static final BoundaryColumnGeometry.Voxel FLUID_BARRIER = new BoundaryColumnGeometry.Voxel( - "minecraft:obsidian", BoundaryColumnGeometry.Phase.SOLID, "", false); private TransitionGeometryBlender() { } @@ -49,8 +46,7 @@ public final class TransitionGeometryBlender { boolean changed = false; for (int offset = 0; offset < currentGeometry.height(); offset++) { BoundaryColumnGeometry.Voxel value = blendVoxel(current, historical, offset, - influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX, - currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight())); + influence.newTerrainWeight()); result.add(value); changed |= !value.equals(current.voxelAt(offset)); } @@ -61,88 +57,58 @@ public final class TransitionGeometryBlender { ColumnProfile current, List historical, int offset, - double currentWeight, - double openingWeight, - boolean historicalMaterial + double currentWeight ) { BoundaryColumnGeometry.Voxel currentVoxel = current.voxelAt(offset); - if (currentVoxel.protectedContent()) { + if (currentVoxel.protectedContent() || offset == 0) { return currentVoxel; } - boolean historicalOpening = false; double solid = 0D; - double fluid = 0D; double total = 0D; - double solidMaterialWeight = -1D; - double fluidMaterialWeight = -1D; BoundaryColumnGeometry.Voxel oldSolid = null; BoundaryColumnGeometry.Voxel oldFluid = null; + double fluidWeight = 0D; for (WeightedProfile contribution : historical) { ColumnProfile profile = contribution.profile(); - if (profile.voxelAt(offset).protectedContent()) { + BoundaryColumnGeometry.Voxel oldVoxel = profile.voxelAt(offset); + if (oldVoxel.protectedContent()) { continue; } - historicalOpening |= profile.enclosedOpeningAt(offset); - double weight = contribution.weight(); - total += weight; - solid += profile.solid.distanceAt(offset) * weight; - fluid += profile.fluid.distanceAt(offset) * weight; - if (profile.solid.materialAt(offset) != null && weight > solidMaterialWeight) { + total += contribution.weight(); + solid += profile.solid.distanceAt(offset) * contribution.weight(); + if (oldSolid == null) { oldSolid = profile.solid.materialAt(offset); - solidMaterialWeight = weight; } - if (profile.fluid.materialAt(offset) != null && weight > fluidMaterialWeight) { - oldFluid = profile.fluid.materialAt(offset); - fluidMaterialWeight = weight; + if (oldVoxel.phase() == BoundaryColumnGeometry.Phase.FLUID + && profile.geometry.hasSolidAbove(profile.geometry.minimumY() + offset) + && contribution.weight() > fluidWeight) { + oldFluid = oldVoxel; + fluidWeight = contribution.weight(); } } if (total == 0D) { return currentVoxel; } double blendedSolid = GenerationBlend.interpolate(solid / total, - current.solid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight); + current.solid.distanceAt(offset), currentWeight); if (blendedSolid > 0D) { - return selectMaterial(oldSolid, current.solid.materialAt(offset), historicalMaterial); + BoundaryColumnGeometry.Voxel material = current.solid.materialAt(offset); + return material != null ? material : oldSolid != null ? oldSolid : currentVoxel; } - double blendedFluid = GenerationBlend.interpolate(fluid / total, - current.fluid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight); - if (blendedFluid > 0D) { - BoundaryColumnGeometry.Voxel newFluid = current.fluid.materialAt(offset); - if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) { - return FLUID_BARRIER; - } - return selectMaterial(oldFluid, newFluid, currentWeight <= 0.5D); + if (currentVoxel.phase() == BoundaryColumnGeometry.Phase.FLUID) { + return currentVoxel; } - return AIR; - } - - private static String fluidFamily(BoundaryColumnGeometry.Voxel voxel) { - String key = voxel.fluidStateKey(); - int properties = key.indexOf('['); - return properties < 0 ? key : key.substring(0, properties); - } - - private static BoundaryColumnGeometry.Voxel selectMaterial( - BoundaryColumnGeometry.Voxel historical, - BoundaryColumnGeometry.Voxel current, - boolean historicalMaterial - ) { - if (historical == null) { - return current == null ? AIR : current; - } - return current == null || historicalMaterial ? historical : current; + return oldFluid != null && currentWeight < 0.5D ? oldFluid : AIR; } private static final class ColumnProfile { private final BoundaryColumnGeometry geometry; private final PhaseCursor solid; - private final PhaseCursor fluid; private int run; private ColumnProfile(BoundaryColumnGeometry geometry) { this.geometry = geometry; solid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.SOLID); - fluid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.FLUID); } private BoundaryColumnGeometry.Voxel voxelAt(int offset) { @@ -152,12 +118,6 @@ public final class TransitionGeometryBlender { return geometry.runVoxel(run); } - private boolean enclosedOpeningAt(int offset) { - solid.advanceTo(offset); - return !solid.occupied && solid.start > 0 && solid.end < geometry.height() - && solid.end - solid.start <= MAXIMUM_OPENING_HEIGHT - && !voxelAt(offset).protectedContent(); - } } private static final class PhaseCursor { diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java index f72ad9690..cf16b4c0d 100644 --- a/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/IrisMatterSupport.java @@ -40,6 +40,7 @@ public final class IrisMatterSupport { } IrisMatter.registerSliceType(new EntityMatter()); + IrisMatter.registerSliceType(new ObjectContinuationMatter()); IrisMatter.registerSliceType("art.arcane.iris.core.link.Identifier", new IdentifierMatter()); IrisMatter.registerSliceType("art.arcane.iris.engine.framework.NativeStructureOwnershipBundle", new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); diff --git a/core/src/main/java/art/arcane/iris/world/storage/matter/ObjectContinuationMatter.java b/core/src/main/java/art/arcane/iris/world/storage/matter/ObjectContinuationMatter.java new file mode 100644 index 000000000..6482c46f4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/storage/matter/ObjectContinuationMatter.java @@ -0,0 +1,36 @@ +package art.arcane.iris.world.storage.matter; + +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class ObjectContinuationMatter extends RawMatter { + public ObjectContinuationMatter() { + this(1, 1, 1); + } + + public ObjectContinuationMatter(int width, int height, int depth) { + super(width, height, depth, ObjectContinuationBundle.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(ObjectContinuationBundle bundle, DataOutputStream output) throws IOException { + bundle.write(output); + } + + @Override + public ObjectContinuationBundle readNode(DataInputStream input) throws IOException { + return ObjectContinuationBundle.read(input); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java index 3c51f939c..058b9fab0 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/FloatingObjectPlacementTransactionTest.java @@ -22,9 +22,17 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.block.TileData; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import org.junit.Test; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.inOrder; @@ -37,7 +45,8 @@ public class FloatingObjectPlacementTransactionTest { @Test public void unsupportedFinalTerrainRejectsBlocksTilesAndMarkersTogether() { IslandObjectPlacer delegate = createPlacer(false); - FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate, + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0)); NativeBlockState first = mock(NativeBlockState.class); NativeBlockState second = mock(NativeBlockState.class); TileData tile = mock(TileData.class); @@ -63,7 +72,8 @@ public class FloatingObjectPlacementTransactionTest { @Test public void supportedNeighborChunkCommitsBlocksTilesAndMarkers() { IslandObjectPlacer delegate = createPlacer(true); - FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate, + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 0)); NativeBlockState first = mock(NativeBlockState.class); NativeBlockState second = mock(NativeBlockState.class); TileData tile = mock(TileData.class); @@ -86,10 +96,59 @@ public class FloatingObjectPlacementTransactionTest { verify(delegate).set(16, 101, 8, second); } + @Test + @SuppressWarnings("unchecked") + public void freelyFloatingObjectsPersistBothPendingFragments() { + IrisMatterSupport.ensureRegistered(); + IObjectPlacer delegate = mock(IObjectPlacer.class); + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk left = mock(MantleChunk.class); + MantleChunk right = mock(MantleChunk.class); + Matter leftSection = new IrisMatter(16, 16, 16); + Matter rightSection = new IrisMatter(16, 16, 16); + when(delegate.getEngine()).thenReturn(engine); + when(engine.getHeight()).thenReturn(128); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(0, 0)).thenReturn(left); + when(mantle.getChunk(1, 0)).thenReturn(right); + when(left.use()).thenReturn(left); + when(right.use()).thenReturn(right); + when(left.getOrCreate(0)).thenReturn(leftSection); + when(right.getOrCreate(0)).thenReturn(rightSection); + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn("minecraft:oak_log"); + ObjectContinuationBundle.PlacementKey key = new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.FLOATING, 0, 0, 7); + FloatingObjectPlacementTransaction transaction = new FloatingObjectPlacementTransaction(delegate, key); + transaction.set(15, 101, 8, state); + transaction.set(16, 101, 8, state); + transaction.setData(16, 101, 8, "floating-tree@7"); + + assertEquals(FloatingObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + + for (Matter section : new Matter[]{leftSection, rightSection}) { + ObjectContinuationBundle bundle = section.getSlice(ObjectContinuationBundle.class).get(0, 0, 0); + assertNotNull(bundle); + assertEquals(key, bundle.fragments().getFirst().key()); + assertEquals(2, bundle.fragments().getFirst().touchedChunks().size()); + } + } + + @SuppressWarnings("unchecked") private IslandObjectPlacer createPlacer(boolean supportNeighborChunk) { IslandObjectPlacer placer = mock(IslandObjectPlacer.class); Engine engine = mock(Engine.class); when(engine.getHeight()).thenReturn(384); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + when(chunk.isFlagged(MantleFlag.REAL)).thenReturn(true); when(placer.getEngine()).thenReturn(engine); when(placer.canWriteObjectBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { int x = invocation.getArgument(0); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java index e86adb091..079a7be63 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java @@ -1,7 +1,5 @@ package art.arcane.iris.generation.mantle; -import art.arcane.iris.world.history.BoundaryColumnGeometry; - import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; @@ -12,20 +10,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class MantleObjectComponentCaveAnchorTest { - @Test - public void retainedCaveAnchorsUseResolvedFluidAndOccupancyWithoutRawCarveFacts() { - BoundaryColumnGeometry.Voxel air = new BoundaryColumnGeometry.Voxel( - "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - BoundaryColumnGeometry.Voxel water = new BoundaryColumnGeometry.Voxel( - "minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false); - BoundaryColumnGeometry.Voxel stone = new BoundaryColumnGeometry.Voxel( - "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); - assertTrue(MantleObjectComponent.acceptsResolvedCaveAnchor(false, air)); - assertFalse(MantleObjectComponent.acceptsResolvedCaveAnchor(false, water)); - assertTrue(MantleObjectComponent.acceptsResolvedCaveAnchor(true, water)); - assertFalse(MantleObjectComponent.acceptsResolvedCaveAnchor(true, stone)); - } - @Test public void biomeOwnedPlacementsRejectForeignCaveBands() { IrisBiome frozen = biome("carving/ice"); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterOverlayTest.java index fd16fc029..bdc9a58ac 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterOverlayTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterOverlayTest.java @@ -158,6 +158,17 @@ public class MantleWriterOverlayTest { verify(identifierSlice, never()).set(anyInt(), anyInt(), anyInt(), any(Identifier.class)); } + @Test + public void forcedStructureCarvePublishesAirForTheContentStage() { + MatterCavern cavern = new MatterCavern(true, "", (byte) 3); + + writer.setForcedCarve(X, Y, Z, cavern); + + verify(blockSlice).set(X, Y, Z, platform.block()); + verify(cavernSlice).set(X, Y, Z, cavern); + verify(identifierSlice).set(X, Y, Z, null); + } + @Test public void protectedHydrologyRejectsLaterBlockAndCavernWrites() { NativeBlockState replacement = mock(NativeBlockState.class); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java new file mode 100644 index 000000000..60aac432f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectContinuationTest.java @@ -0,0 +1,271 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.IrisRegistrant; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.storage.matter.IrisMatterContext; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; +import art.arcane.iris.world.storage.matter.TileWrapper; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +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.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ObjectContinuationTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Test + public void onlyAcceptedCrossingPlacementsLeaveFutureFragments() throws Exception { + Fixture fixture = new Fixture(); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(fixture.writer, 0, 0); + int checkpoint = source.beginObjectPlacement(); + source.setData(1, 5, 1, "inside"); + source.endObjectPlacement(checkpoint); + checkpoint = source.beginObjectPlacement(); + source.setData(15, 5, 1, "crossing"); + source.setData(16, 5, 1, "crossing"); + source.endObjectPlacement(checkpoint); + checkpoint = source.beginObjectPlacement(); + source.setData(32, 5, 1, "unrelated"); + source.setData(48, 5, 1, "unrelated"); + source.endObjectPlacement(checkpoint); + source.setData(17, 5, 1, "rejected"); + ObjectSourcePlan plan = source.sourcePlanSince(0); + + plan.persistContinuations(fixture.writer, 0, 0, 0, 0); + plan.persistContinuations(fixture.writer, 0, 0, 0, 0); + + assertEquals(1, fixture.bundles.size()); + ObjectContinuationBundle bundle = fixture.bundles.get(1); + assertEquals(1, bundle.fragments().size()); + ObjectContinuationBundle.Fragment fragment = bundle.fragments().getFirst(); + assertEquals(new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 1), fragment.key()); + assertEquals(List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), fragment.touchedChunks()); + Matter decoded = fragment.decode(); + assertEquals("crossing", decoded.getSlice(String.class).get(0, 5, 1)); + assertNull(decoded.getSlice(String.class).get(1, 5, 1)); + } + + @Test + public void generatedDestinationsDoNotRetainContinuations() { + Fixture fixture = new Fixture(); + when(fixture.chunk.isFlagged(MantleFlag.REAL)).thenReturn(true); + List mutations = List.of(marker(-1, "left"), marker(0, "right")); + ObjectSourcePlan plan = new ObjectSourcePlan(mutations, List.of(mutations)); + + plan.persistContinuations(fixture.writer, -1, 0, -1, 0); + + assertTrue(fixture.bundles.isEmpty()); + } + + @Test + public void nestedMatterRoundTripPreservesBlocksCustomIdentifiersTreesAndTiles() throws Exception { + IrisMatterSupport.ensureRegistered(); + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + NativeBlockState base = mock(NativeBlockState.class); + NativeBlockState custom = mock(NativeBlockState.class); + NativeBlockState air = mock(NativeBlockState.class); + when(base.key()).thenReturn("minecraft:chest"); + when(air.key()).thenReturn("minecraft:air"); + when(custom.placementBaseState()).thenReturn(base); + when(custom.deferredPlacementKey()).thenReturn("test:custom_chest"); + when(platform.registries()).thenReturn(registries); + when(registries.decodeBlockState("minecraft:chest")).thenReturn(base); + when(registries.decodeBlockState("minecraft:air")).thenReturn(air); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + TileData.TileReader previousReader = TileData.bindPlatformReader(input -> new TileData(input.readUTF(), + new Gson().fromJson(input.readUTF(), new TypeToken>() { }))); + try { + KMap properties = new KMap<>(); + properties.put("CustomName", "continued chest"); + TileWrapper tile = new TileWrapper(new TileData("minecraft:chest", properties)); + List mutations = List.of( + new ObjectDestinationTransaction.CustomBlockMutation(key(NativeBlockState.class), custom), + new ObjectDestinationTransaction.SetMutation(key(String.class), "tree@42"), + new ObjectDestinationTransaction.SetMutation(key(TreeBlockMaterial.class), TreeBlockMaterial.of("minecraft:oak_log")), + new ObjectDestinationTransaction.SetMutation(key(TileWrapper.class), tile), + new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(17, 5, 1, NativeBlockState.class), air)); + ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 2), new ObjectContinuationBundle.Bounds(15, 1, 17, 1), + List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), + ObjectSourcePlan.encode(mutations, 64)); + Matter outer = new IrisMatter(16, 16, 16); + outer.slice(ObjectContinuationBundle.class).set(0, 0, 0, new ObjectContinuationBundle(List.of(fragment))); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + outer.write(output); + Matter loaded = Matter.read(new ByteArrayInputStream(output.toByteArray())); + ObjectContinuationBundle saved = loaded.getSlice(ObjectContinuationBundle.class).get(0, 0, 0); + Matter payload = saved.fragments().getFirst().decode(); + + assertSame(base, payload.getSlice(NativeBlockState.class).get(0, 5, 1)); + assertSame(air, payload.getSlice(NativeBlockState.class).get(1, 5, 1)); + assertEquals(Identifier.fromString("test:custom_chest"), payload.getSlice(Identifier.class).get(0, 5, 1)); + assertEquals("tree@42", payload.getSlice(String.class).get(0, 5, 1)); + assertEquals(TreeBlockMaterial.of("minecraft:oak_log"), payload.getSlice(TreeBlockMaterial.class).get(0, 5, 1)); + assertEquals(tile, payload.getSlice(TileWrapper.class).get(0, 5, 1)); + assertFalse(saved.fragments().getFirst().encodedSize() == 0); + } finally { + TileData.restorePlatformReader(previousReader); + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + @SuppressWarnings("unchecked") + public void registryMetadataDecodesUsingItsOriginPackContext() throws Exception { + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("old-only"); + IrisData origin = mock(IrisData.class); + IrisData current = mock(IrisData.class); + ResourceLoader loader = mock(ResourceLoader.class); + when(loader.load("old-only")).thenReturn(spawner); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisSpawner.class, loader); + when(origin.getLoaders()).thenReturn(loaders); + when(current.getLoaders()).thenReturn(new KMap<>()); + ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 0), new ObjectContinuationBundle.Bounds(15, 1, 16, 1), + List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), + ObjectSourcePlan.encode(List.of(new ObjectDestinationTransaction.SetMutation(key(IrisSpawner.class), spawner)), 64)); + + try (IrisMatterContext.Scope outer = IrisMatterContext.open(current)) { + assertThrows(IOException.class, fragment::decode); + try (IrisMatterContext.Scope scope = IrisMatterContext.open(origin)) { + Matter restored = fragment.decode(); + assertSame(spawner, restored.getSlice(IrisSpawner.class).get(0, 5, 1)); + } + assertSame(current, IrisMatterContext.require()); + } + } + + @Test + public void malformedNestedPayloadCannotSilentlyDropObjectData() { + byte[] payload = ObjectSourcePlan.encode(List.of(marker(16, "tree@42")), 64); + for (int size : new int[]{payload.length - 1, payload.length + 1}) { + ObjectContinuationBundle.Fragment fragment = new ObjectContinuationBundle.Fragment( + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 0), new ObjectContinuationBundle.Bounds(15, 1, 16, 1), + List.of(new ObjectContinuationBundle.ChunkPosition(0, 0), new ObjectContinuationBundle.ChunkPosition(1, 0)), + Arrays.copyOf(payload, size)); + assertThrows(IOException.class, fragment::decode); + } + } + + @Test + @SuppressWarnings("unchecked") + public void directProducerSkipsRealAndDuplicatePayloadSuppliers() { + IrisMatterSupport.ensureRegistered(); + Mantle mantle = mock(Mantle.class); + MantleChunk saved = mock(MantleChunk.class); + MantleChunk future = mock(MantleChunk.class); + when(mantle.getChunk(0, 0)).thenReturn(saved); + when(mantle.getChunk(1, 0)).thenReturn(future); + when(saved.use()).thenReturn(saved); + when(future.use()).thenReturn(future); + when(saved.isFlagged(MantleFlag.REAL)).thenReturn(true); + Matter section = new IrisMatter(16, 16, 16); + when(future.getOrCreate(0)).thenReturn(section); + AtomicInteger supplied = new AtomicInteger(); + Supplier supplier = () -> { + supplied.incrementAndGet(); + Matter payload = new IrisMatter(16, 64, 16); + payload.slice(String.class).set(0, 5, 1, "tree@1"); + return payload; + }; + Map> destinations = Map.of( + new ObjectContinuationBundle.ChunkPosition(0, 0), supplier, + new ObjectContinuationBundle.ChunkPosition(1, 0), supplier); + ObjectContinuationBundle.Bounds bounds = new ObjectContinuationBundle.Bounds(15, 1, 16, 1); + ObjectContinuationBundle.PlacementKey biome = new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, 0); + ObjectContinuationPersistence.persist(mantle, biome, bounds, destinations); + ObjectContinuationPersistence.persist(mantle, biome, bounds, destinations); + assertEquals(1, supplied.get()); + + ObjectContinuationBundle.PlacementKey structure = new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.STRUCTURE, 0, 0, 0); + ObjectContinuationPersistence.persist(mantle, structure, bounds, destinations); + assertEquals(2, supplied.get()); + assertEquals(2, section.getSlice(ObjectContinuationBundle.class).get(0, 0, 0).fragments().size()); + } + + private static ObjectDestinationTransaction.DataKey key(Class type) { + return new ObjectDestinationTransaction.DataKey(16, 5, 1, type); + } + + private static ObjectDestinationTransaction.Mutation marker(int x, String marker) { + return new ObjectDestinationTransaction.SetMutation(new ObjectDestinationTransaction.DataKey(x, 5, 1, String.class), marker); + } + + private static final class Fixture { + private final MantleWriter writer = mock(MantleWriter.class); + private final MantleChunk chunk; + private final Map bundles = new HashMap<>(); + + @SuppressWarnings("unchecked") + private Fixture() { + chunk = mock(MantleChunk.class); + Mantle mantle = mock(Mantle.class); + Engine engine = mock(Engine.class); + when(writer.getMantle()).thenReturn(mantle); + when(mantle.getWorldHeight()).thenReturn(64); + when(writer.getEngine()).thenReturn(engine); + when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); + when(writer.acquireChunk(anyInt(), anyInt())).thenReturn(chunk); + when(writer.getDataIfPresent(anyInt(), anyInt(), anyInt(), any())).thenAnswer(call -> bundles.get(((Integer) call.getArgument(0)) >> 4)); + doAnswer(call -> { + Runnable task = call.getArgument(2); + task.run(); + return null; + }).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + doAnswer(call -> { + ObjectContinuationBundle bundle = call.getArgument(3); + bundles.put(((Integer) call.getArgument(0)) >> 4, bundle); + return null; + }).when(writer).setData(anyInt(), anyInt(), anyInt(), any(ObjectContinuationBundle.class)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index 2a905b8f1..8e46df08f 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -4,8 +4,6 @@ import art.arcane.iris.integration.Identifier; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.terrain.IrisDimension; @@ -25,9 +23,7 @@ import org.junit.Test; import org.mockito.InOrder; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Optional; import java.util.HashMap; import java.util.Map; import java.util.Random; @@ -468,30 +464,6 @@ public class ObjectDestinationTransactionTest { } } - @Test - public void historicalGeometryOwnsTransactionalCarvingOverCurrentDensity() { - try (TerrainFixture fixture = new TerrainFixture()) { - BoundaryColumnGeometry.Voxel solid = new BoundaryColumnGeometry.Voxel( - "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); - BoundaryColumnGeometry.Voxel air = new BoundaryColumnGeometry.Voxel( - "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - List voxels = new ArrayList<>(Collections.nCopies(16, solid)); - voxels.set(2, air); - TerrainBoundarySignature historical = mock(TerrainBoundarySignature.class); - when(historical.geometry()).thenReturn(BoundaryColumnGeometry.fromVoxels(-64, voxels)); - when(fixture.engine.getMinHeight()).thenReturn(-64); - when(fixture.complex.resolvedTerrainColumn(0, 0)).thenReturn(Optional.of(historical)); - fixture.matter.slice(HydrologyCaveCell.class) - .set(0, 2, 0, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); - ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(fixture.writer, 0, 0); - - assertArrayEquals(new byte[]{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - transaction.getCarvedColumn(0, 0, 16)); - assertTrue(transaction.isCarved(0, 2, 0)); - assertFalse(transaction.isCarved(0, 5, 0)); - } - } - @Test public void failedCommitRestoresEveryTouchedDestinationCell() { MantleWriter writer = writer(); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ResolvedTerrainPlacementTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainPlacementQueryTest.java similarity index 72% rename from core/src/test/java/art/arcane/iris/generation/mantle/ResolvedTerrainPlacementTest.java rename to core/src/test/java/art/arcane/iris/generation/mantle/TerrainPlacementQueryTest.java index 182664541..9f1d36133 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ResolvedTerrainPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainPlacementQueryTest.java @@ -2,8 +2,10 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import art.arcane.iris.world.history.TerrainBoundarySignature; +import art.arcane.iris.generation.runtime.EngineMode; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -21,26 +23,21 @@ import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; -import java.util.List; -import java.util.Optional; -import java.util.OptionalInt; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doReturn; 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 ResolvedTerrainPlacementTest { +public class TerrainPlacementQueryTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); @@ -67,27 +64,19 @@ public class ResolvedTerrainPlacementTest { IrisPlatforms.bind(platform); IrisMatterSupport.ensureRegistered(); - BoundaryColumnGeometry.Voxel solid = new BoundaryColumnGeometry.Voxel( - "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); - BoundaryColumnGeometry.Voxel open = new BoundaryColumnGeometry.Voxel( - "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - TerrainBoundarySignature column = mock(TerrainBoundarySignature.class); - when(column.geometry()).thenReturn(BoundaryColumnGeometry.fromVoxels(-64, - List.of(solid, open, solid, open))); - when(column.oceanFloorHeight()).thenReturn(2); - when(column.surfaceHeight()).thenReturn(5); - when(column.fluidHeight()).thenReturn(OptionalInt.of(5)); IrisComplex complex = mock(IrisComplex.class); - when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(column)); - when(complex.resolvedTerrainHeight(anyInt(), anyInt(), eq(true))) - .thenReturn(OptionalInt.of(2)); - when(complex.resolvedTerrainHeight(anyInt(), anyInt(), eq(false))) - .thenReturn(OptionalInt.of(5)); + ProceduralStream heights = mock(ProceduralStream.class); + when(heights.get(anyDouble(), anyDouble())).thenReturn(2); + when(complex.getRoundedHeighteightStream()).thenReturn(heights); + when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 5D)); Engine engine = mock(Engine.class); when(engine.getMinHeight()).thenReturn(-64); when(engine.getComplex()).thenReturn(complex); mantle = mock(Mantle.class); + when(mantle.getLoadedRegions()).thenReturn(new KMap<>()); when(mantle.getWorldHeight()).thenReturn(4); + when(mantle.get(0, 0, 0, NativeBlockState.class)).thenReturn(stone); + when(mantle.get(0, 1, 0, MatterCavern.class)).thenReturn(new MatterCavern(true, "", (byte) 0)); MantleChunk chunk = mock(MantleChunk.class); when(mantle.getChunk(0, 0)).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); @@ -105,7 +94,7 @@ public class ResolvedTerrainPlacementTest { } @Test - public void placementUsesResolvedSurfaceFluidAndEnclosedOccupancy() { + public void placementUsesTerrainStreamsAndRecordedCarving() { assertEquals(2, engineMantle.getHighest(0, 0, null, true)); assertEquals(5, engineMantle.getHighest(0, 0, null, false)); assertEquals(5, engineMantle.getFluidHeight(0, 0)); @@ -116,22 +105,27 @@ public class ResolvedTerrainPlacementTest { } @Test - public void scalarPlacementQueriesDoNotReloadColumnGeometry() { + public void scalarPlacementQueriesDoNotGenerateTerrain() { Engine engine = mock(Engine.class, CALLS_REAL_METHODS); IrisComplex complex = engineMantle.getComplex(); doReturn(complex).when(engine).getComplex(); + doReturn(null).when(engine).getData(); + doReturn(false).when(engine).answersFromNaturalTerrain(0, 0); + doReturn(engineMantle).when(engine).getMantle(); + EngineMode mode = mock(EngineMode.class); + doReturn(mode).when(engine).getMode(); assertEquals(2, engine.getHeight(0, 0, true)); assertEquals(5, engine.getHeight(0, 0, false)); assertEquals(2, engineMantle.getHighest(0, 0, null, true)); assertEquals(5, engineMantle.getHighest(0, 0, null, false)); - verify(complex, never()).resolvedTerrainColumn(anyInt(), anyInt()); + verifyNoInteractions(mode); } @Test - public void writerPrerequisitesIgnoreContentWhileLiveOccupancyIncludesBlockOnlyFills() { + public void writerPrerequisitesRetainNaturalCarvingBeforeContentFills() { matter.slice(NativeBlockState.class).set(0, 1, 0, stone); - matter.slice(PreObjectMatterCell.class).set(0, 1, 0, PreObjectMatterCell.block(null)); + matter.slice(PreObjectMatterCell.class).set(0, 1, 0, PreObjectMatterCell.block(null).captureCavern(new MatterCavern(true, "", (byte) 0))); try (MantleWriter writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false)) { assertSame(air, writer.getPrerequisiteBlock(0, 1, 0)); assertSame(stone, writer.get(0, 1, 0)); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java index 641670bf2..58b74e61c 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java @@ -1,6 +1,10 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.generation.stage.IrisDimensionStackActuator; +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; import art.arcane.iris.generation.stage.IrisTerrainNormalActuator; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; @@ -291,12 +295,19 @@ public class DimensionTerrain3DCompositionTest { return blocks; } + @SuppressWarnings("unchecked") private Hunk render(DimensionStackLayout layout, int height) { Engine engine = mock(Engine.class); when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); when(engine.getDimensionStackContext()).thenReturn(mock(DimensionStackContext.class)); ChunkContext context = mock(ChunkContext.class); - when(context.isSpeculativeTerrain()).thenReturn(true); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk mantleChunk = mock(MantleChunk.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(0, 0)).thenReturn(mantleChunk); + when(mantleChunk.use()).thenReturn(mantleChunk); when(context.getDimensionStackLayout(0, 0)).thenReturn(layout); Hunk output = Hunk.newArrayHunk(1, height, 1); for (int y = 0; y < height; y++) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java new file mode 100644 index 000000000..e7e7f93b1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java @@ -0,0 +1,268 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.FloatingBiomeOverlay; +import art.arcane.iris.world.history.TransitionGenerationPlan; +import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.volmlib.nativelib.terrain.NativeBiome; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public final class EngineModeTerrainGenerationTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Test + public void transitionChunksGenerateOnceAndKeepCustomStatesInTheDestination() throws Exception { + Fixture fixture = new Fixture(); + NativeBlockState custom = Fixture.block("itemsadder:rocks/ruby_ore", false); + when(custom.isCustom()).thenReturn(true); + when(custom.placementBaseState()).thenReturn(fixture.stone); + when(custom.deferredPlacementKey()).thenReturn("itemsadder:rocks/ruby_ore"); + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.hasTransitionAtChunk(anyInt(), anyInt())).thenReturn(true); + when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); + fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + context.setTerrainHeight(localX, localZ, 2); + for (int y = 0; y < 16; y++) { + blocks.setRaw(localX, y, localZ, y == 2 ? custom : y < 2 ? fixture.stone : fixture.air); + biomes.setRaw(localX, y, localZ, fixture.biome); + } + } + } + }; + AtomicInteger customWrites = new AtomicInteger(); + AtomicReference receipt = new AtomicReference<>(); + doAnswer(invocation -> { + receipt.set(invocation.getArgument(0)); + return null; + }).when(fixture.router).recordNaturalTerrain(any()); + Hunk blocks = Hunk.newArrayHunk(16, 16, 16).listen((x, y, z, state) -> { + if (state.isCustom()) { + customWrites.incrementAndGet(); + } + }); + + fixture.mode.generate(-16, -32, blocks, Hunk.newArrayHunk(16, 16, 16), true, 41L); + + assertEquals(Map.of("-1,-2", 1), fixture.computations); + assertEquals(256, customWrites.get()); + assertSame(custom, blocks.getRaw(0, 2, 0)); + assertEquals("itemsadder:rocks/ruby_ore", receipt.get().column(-16, -32).geometry().voxelAt(2).stateKey()); + verify(fixture.engine, never()).openGenerationHistoryCoordinateScope(anyInt(), anyInt()); + assertEquals(41L, fixture.lastSessionId); + assertNull(IrisContext.get()); + } + + @Test + public void actualGenerationPublishesFloatingIdentityWithItsNaturalReceipt() throws Exception { + Fixture fixture = new Fixture(); + fixture.floatingIdentity = new FloatingBiomeOverlay.Identity("floating-child", "region"); + AtomicReference recorded = new AtomicReference<>(); + doAnswer(invocation -> { + recorded.set(invocation.getArgument(2)); + return null; + }).when(fixture.router).recordFloatingBiomes(anyInt(), anyInt(), any()); + + fixture.mode.generate(0, 0, Hunk.newArrayHunk(16, 16, 16), Hunk.newArrayHunk(16, 16, 16), false, 41L); + + assertEquals(Map.of("0,0", 1), fixture.computations); + assertEquals(fixture.floatingIdentity, recorded.get().volumeAt(0, 0, 0)); + assertEquals(fixture.floatingIdentity, recorded.get().surfaceAt(0, 0)); + } + + @Test + public void failedTerrainLeavesTheCallerContextAndSkipsReceiptAndContent() { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("Terrain failed"); + fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { + throw failure; + }; + EngineStage content = mock(EngineStage.class); + when(fixture.mode.getStages()).thenReturn(new KList<>(content)); + ChunkContext caller = new ChunkContext(64, 64, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 73L, caller)) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> fixture.mode.generate( + 0, 0, Hunk.newArrayHunk(16, 16, 16), Hunk.newArrayHunk(16, 16, 16), false, 41L))); + assertSame(caller, IrisContext.require().getChunkContext()); + } + verify(fixture.router, never()).recordNaturalTerrain(any()); + verify(content, never()).generate(anyInt(), anyInt(), any(), any(), anyBoolean(), any()); + assertNull(IrisContext.get()); + } + + @Test + public void actualGenerationRecordsNaturalTerrainBeforeContentMutatesBlocks() { + Fixture fixture = new Fixture(); + AtomicReference recorded = new AtomicReference<>(); + doAnswer(invocation -> { + assertEquals(List.of("terrain"), fixture.events); + assertTrue(IrisContext.require().getChunkContext().isNaturalTerrain()); + recorded.set(invocation.getArgument(0)); + fixture.events.add("receipt"); + return null; + }).when(fixture.router).recordNaturalTerrain(any()); + EngineStage content = (x, z, blocks, biomes, multicore, context) -> { + assertFalse(context.isNaturalTerrain()); + assertEquals(List.of("terrain", "receipt"), fixture.events); + assertNotNull(recorded.get()); + assertEquals("minecraft:stone", recorded.get().column(x, z).geometry().voxelAt(0).stateKey()); + blocks.setRaw(0, 0, 0, fixture.air); + fixture.events.add("content"); + }; + when(fixture.mode.getStages()).thenReturn(new KList<>(content)); + doCallRealMethod().when(fixture.mode).generate(anyInt(), anyInt(), any(), any(), anyBoolean(), anyLong()); + Hunk blocks = Hunk.newArrayHunk(16, 16, 16); + Hunk biomes = Hunk.newArrayHunk(16, 16, 16); + + fixture.mode.generate(0, 0, blocks, biomes, false, 41L); + + assertEquals(List.of("terrain", "receipt", "content"), fixture.events); + assertSame(fixture.air, blocks.getRaw(0, 0, 0)); + assertEquals("minecraft:stone", recorded.get().column(0, 0).geometry().voxelAt(0).stateKey()); + assertFalse(recorded.get().hasColumn(1, 1)); + assertNull(IrisContext.get()); + } + + @Test + public void naturalContextGuardAppliesOnlyToItsOwnComplexUntilContentBegins() { + Fixture fixture = new Fixture(); + IrisComplex other = mock(IrisComplex.class); + doCallRealMethod().when(fixture.complex).isNaturalTerrainContext(); + doCallRealMethod().when(other).isNaturalTerrainContext(); + ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); + assertFalse(fixture.complex.isNaturalTerrainContext()); + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { + assertTrue(fixture.complex.isNaturalTerrainContext()); + assertFalse(other.isNaturalTerrainContext()); + context.beginContent(); + assertFalse(fixture.complex.isNaturalTerrainContext()); + } + assertFalse(fixture.complex.isNaturalTerrainContext()); + } + + private static final class Fixture { + private final IrisEngine engine = mock(IrisEngine.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final EngineMode mode = mock(EngineMode.class); + private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + private final NativeBlockState air = block("minecraft:air", true); + private final NativeBlockState stone = block("minecraft:stone", false); + private final NativeBiome biome = mock(NativeBiome.class); + private final Map computations = new HashMap<>(); + private final List events = new ArrayList<>(); + private long lastSessionId; + private EngineStage terrainOverride; + private FloatingBiomeOverlay.Identity floatingIdentity; + + @SuppressWarnings("unchecked") + private Fixture() { + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(16); + when(engine.getMinHeight()).thenReturn(0); + when(engine.getMode()).thenReturn(mode); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + when(hooks.shouldDisableChunkContextCache(engine)).thenReturn(true); + when(mode.getEngine()).thenReturn(engine); + when(mode.getComplex()).thenReturn(complex); + when(mode.getStages()).thenReturn(new KList<>()); + doCallRealMethod().when(mode).generate(anyInt(), anyInt(), any(), any(), anyBoolean(), anyLong()); + when(biome.key()).thenReturn("minecraft:plains"); + ProceduralStream height = mock(ProceduralStream.class); + when(height.getDouble(anyDouble(), anyDouble())).thenReturn(3D); + ProceduralStream biomes = mock(ProceduralStream.class); + ProceduralStream regions = mock(ProceduralStream.class); + ProceduralStream materials = mock(ProceduralStream.class); + when(materials.get(anyDouble(), anyDouble())).thenReturn(stone); + when(complex.getRawHeightStream()).thenReturn(height); + when(complex.getTrueBiomeStream()).thenReturn(biomes); + when(complex.getCaveBiomeStream()).thenReturn(biomes); + when(complex.getRegionStream()).thenReturn(regions); + when(complex.getRockStream()).thenReturn(materials); + when(complex.getFluidStream()).thenReturn(materials); + doAnswer(invocation -> { + int x = invocation.getArgument(0); + int z = invocation.getArgument(1); + Hunk blocks = invocation.getArgument(2); + Hunk physicalBiomes = invocation.getArgument(3); + ChunkContext context = invocation.getArgument(5); + fill(x, z, blocks, physicalBiomes, context); + return null; + }).when(mode).generateTerrain(anyInt(), anyInt(), any(), any(), anyBoolean(), any()); + } + + private void fill(int x, int z, Hunk blocks, + Hunk biomes, ChunkContext context) { + assertTrue(context.isNaturalTerrain()); + assertSame(context, IrisContext.require().getChunkContext()); + lastSessionId = IrisContext.require().getGenerationSessionId(); + computations.merge((x >> 4) + "," + (z >> 4), 1, Integer::sum); + events.add("terrain"); + if (terrainOverride != null) { + terrainOverride.generate(x, z, blocks, biomes, false, context); + return; + } + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int surface = 2 + Math.floorMod(x + localX + z + localZ, 5); + context.setTerrainHeight(localX, localZ, surface); + for (int y = 0; y < 16; y++) { + blocks.setRaw(localX, y, localZ, y <= surface ? stone : air); + biomes.setRaw(localX, y, localZ, biome); + } + } + } + if (floatingIdentity != null) { + context.floatingBiomes(16).record(0, 0, 0, floatingIdentity); + context.floatingBiomes(16).record(0, 2, 0, floatingIdentity); + } + } + + private static NativeBlockState block(String key, boolean air) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.isAir()).thenReturn(air); + return state; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java index 3e6fb15cf..be0ae4f35 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java @@ -316,7 +316,6 @@ public class IrisComplexHydrologyNoiseCacheTest { complex.setBaseTerrainHeightStream(height); complex.setUnblendedNaturalHeightStream(height); complex.setHydrologyRuntime(hydrology); - setField(complex, "resolvedTerrain", mock(ResolvedTerrainProvider.class)); } @Override diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexNaturalBiomeReuseTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexNaturalBiomeReuseTest.java index b1eebce52..5fcbc3f40 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexNaturalBiomeReuseTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexNaturalBiomeReuseTest.java @@ -11,6 +11,7 @@ import art.arcane.iris.generation.context.IrisContext; import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.interpolation.Interpolated; import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.world.history.TransitionDisplacementField; import org.junit.Test; import java.lang.reflect.Method; @@ -86,6 +87,20 @@ public class IrisComplexNaturalBiomeReuseTest { assertFalse(IrisComplex.hasFixedNaturalBiomeNoise(dimension, List.of(leaf))); } + @Test + public void naturalBiomeUsesTransitionWaterHead() throws Exception { + Fixture fixture = new Fixture(); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + doReturn(40D).when(displacement).fluidHeight(-17, 262144, 63D); + Field field = IrisComplex.class.getDeclaredField("transitionDisplacement"); + field.setAccessible(true); + field.set(fixture.complex, displacement); + + assertSame(fixture.sea, fixture.sample(30D)); + assertSame(fixture.shore, fixture.sample(40D)); + assertSame(fixture.land, fixture.sample(50D)); + } + private static final class Fixture { private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); private final IrisImageMapRuntime images = mock(IrisImageMapRuntime.class); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java index c726c9f73..86da599d0 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java @@ -4,12 +4,22 @@ import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyFeatureRef; import art.arcane.iris.generation.hydrology.HydrologyFeatureType; +import art.arcane.iris.world.history.TransitionDisplacementField; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.world.history.TransitionGenerationPlan; import org.junit.Test; import java.util.List; +import java.util.Optional; +import java.lang.reflect.Field; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class IrisComplexTransitionTest { @Test @@ -20,13 +30,13 @@ public class IrisComplexTransitionTest { } @Test - public void tapersHydrologyGeometryTowardNaturalTerrain() { + public void tapersRiverDepthWithoutRaisingItsFluidHead() { HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(hydrologySample(), 0.5D); HydrologyColumnLayer layer = tapered.layers().getFirst(); assertEquals(73, layer.bedY()); - assertEquals(75, layer.fluidHeadY()); - assertEquals(75, layer.ceilingY()); + assertEquals(70, layer.fluidHeadY()); + assertEquals(70, layer.ceilingY()); assertEquals(73, tapered.terrainHeight()); assertEquals("river", layer.profileKey()); } @@ -55,8 +65,144 @@ public class IrisComplexTransitionTest { assertSame(cave, tapered.layers().getFirst()); assertEquals(73, tapered.layers().getLast().bedY()); - assertEquals(75, tapered.layers().getLast().fluidHeadY()); - assertEquals(75, tapered.layers().getLast().ceilingY()); + assertEquals(70, tapered.layers().getLast().fluidHeadY()); + assertEquals(70, tapered.layers().getLast().ceilingY()); + } + + @Test + public void loweredSurfaceWaterPreservesContainedOceanCaveAndPlannerDatum() { + assertOceanWaterTransition(40, 63); + } + + @Test + public void raisedSurfaceWaterKeepsContainedOceanCaveAndRaisesPlannerDatum() { + assertOceanWaterTransition(70, 70); + } + + @Test + public void missingDisplacementPreservesHydrologyAtFullWeight() { + HydrologyColumnSample sample = hydrologySample(); + assertSame(sample, IrisComplex.transitionHydrologySample(sample, 1D, null)); + } + + @Test + public void tinyRiverWeightsExcavateGraduallyUntilReachingUnraisedWater() { + HydrologyColumnSample sample = hydrologySample(); + int previousBed = 80; + for (int step = 1; step <= 100; step++) { + double weight = step / 100D; + HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(sample, weight); + HydrologyColumnLayer layer = tapered.layers().getFirst(); + assertEquals(70, layer.fluidHeadY()); + assertTrue(layer.bedY() <= previousBed); + assertTrue(previousBed - layer.bedY() <= 1); + assertEquals(layer.bedY(), tapered.terrainHeight()); + assertEquals(layer.bedY() <= layer.fluidHeadY(), layer.channel()); + assertEquals(layer.channel(), layer.fluidOwned()); + if (layer.bedY() > layer.fluidHeadY()) { + assertFalse(layer.connectedFluid()); + assertFalse(layer.fallingFluid()); + assertFalse(layer.receivingPool()); + } + previousBed = layer.bedY(); + } + assertEquals(66, previousBed); + } + + @Test + public void exactSeamSuppressesSurfaceCutsAndPreservesCaves() { + HydrologyColumnLayer cave = hydrologySample(HydrologyFeatureType.UNDERGROUND_POOL).layers().getFirst(); + HydrologyColumnLayer surface = hydrologySample().layers().getFirst(); + HydrologyColumnSample sample = new HydrologyColumnSample(4, 6, 80, 63, false, "parent", List.of(surface, cave)); + + HydrologyColumnSample transitioned = IrisComplex.taperHydrologySample(sample, 0D); + + assertEquals(List.of(cave), transitioned.layers()); + assertEquals(80, transitioned.terrainHeight()); + } + + @Test + public void historicalRiverHeadRestoresFluidAboveTaperedBed() { + HydrologyColumnSample sample = hydrologySample(); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 8D, 0.25D, 1D, 80D, 80D, "minecraft:water")); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn(68D); + when(displacement.fluidHeight(4, 6, 70D)).thenReturn(77D); + + HydrologyColumnSample transitioned = IrisComplex.transitionHydrologySample(sample, 0.35D, displacement); + HydrologyColumnLayer river = transitioned.primarySurfaceFluidLayerOrNull(); + + assertEquals(75, transitioned.terrainHeight()); + assertEquals(75, river.bedY()); + assertEquals(77, river.fluidHeadY()); + assertTrue(river.channel()); + assertTrue(river.fluidOwned()); + assertTrue(river.connectedFluid()); + assertTrue(transitioned.surfacePublicationCellAt(76).isPresent()); + } + + @Test + public void risingSeaDoesNotKeepElevatedRiverWaterOnSeaLevelGround() { + HydrologyColumnSample sample = hydrologySample(); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 8D, 0.25D, 1D, 90D, 80D, "minecraft:water")); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn(80D); + when(displacement.fluidHeight(4, 6, 70D)).thenReturn(85D); + + HydrologyColumnSample transitioned = IrisComplex.transitionHydrologySample(sample, 1D, displacement); + + assertEquals(80, transitioned.seaLevel()); + assertTrue(transitioned.layers().isEmpty()); + } + + @Test + public void zeroWeightSeamKeepsCavesAndDoesNotClaimColdHydrologyIsPlanned() throws Exception { + IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + complex.setHydrologyRuntime(runtime); + Field plan = IrisComplex.class.getDeclaredField("transitionGenerationPlan"); + plan.setAccessible(true); + plan.set(complex, mock(TransitionGenerationPlan.class)); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + Field field = IrisComplex.class.getDeclaredField("transitionDisplacement"); + field.setAccessible(true); + field.set(complex, displacement); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 1D, 0D, 0D, 0D, 80D, null)); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn(63D); + HydrologyColumnSample sample = hydrologySample(HydrologyFeatureType.UNDERGROUND_POOL); + when(runtime.sample(4D, 6D)).thenReturn(Optional.of(sample)); + + assertFalse(complex.isHydrologyPlanned(4, 6)); + assertSame(sample.layers().getFirst(), complex.sampleHydrologyColumn(4, 6).layers().getFirst()); + } + + private static void assertOceanWaterTransition(int surfaceHead, int plannerSeaLevel) { + HydrologyColumnLayer cave = oceanLayer(HydrologyFeatureType.UNDERGROUND_POOL, 55, false); + HydrologyColumnLayer apron = oceanLayer(HydrologyFeatureType.MOUTH, 63, true); + HydrologyColumnSample sample = new HydrologyColumnSample(4, 6, 30, 63, true, "parent", List.of(cave, apron)); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.sample(4, 6)).thenReturn( + new TransitionDisplacementField.Sample(0D, 8D, 0.25D, 1D, surfaceHead, 30D, "minecraft:water")); + when(displacement.fluidHeight(4, 6, 63D)).thenReturn((double) surfaceHead); + + HydrologyColumnSample transitioned = IrisComplex.transitionHydrologySample(sample, 1D, displacement); + + assertEquals(plannerSeaLevel, transitioned.seaLevel()); + assertSame(cave, transitioned.layers().stream() + .filter(layer -> layer.feature().type() == HydrologyFeatureType.UNDERGROUND_POOL).findFirst().orElseThrow()); + HydrologyColumnLayer surface = transitioned.layers().stream() + .filter(HydrologyColumnLayer::oceanApron).findFirst().orElseThrow(); + assertEquals(surfaceHead, surface.fluidHeadY()); + assertEquals(surfaceHead, surface.ceilingY()); + } + + private static HydrologyColumnLayer oceanLayer(HydrologyFeatureType type, int head, boolean apron) { + HydrologyFeatureRef feature = new HydrologyFeatureRef(1L, type, 2L, 3L, 4, head, 6, 1, 0, true); + return new HydrologyColumnLayer(feature, 20, head, head, true, false, false, true, + false, false, false, false, apron, "river", "surface", "mouth", "shore", "bank", "cave"); } private static HydrologyColumnSample hydrologySample() { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/ResolvedTerrainProviderTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/ResolvedTerrainProviderTest.java deleted file mode 100644 index 872a6ac5b..000000000 --- a/core/src/test/java/art/arcane/iris/generation/runtime/ResolvedTerrainProviderTest.java +++ /dev/null @@ -1,517 +0,0 @@ -package art.arcane.iris.generation.runtime; - -import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; -import art.arcane.iris.world.history.FloatingBiomeOverlay; -import art.arcane.iris.world.history.BoundaryColumnGeometry; -import art.arcane.iris.world.history.TerrainBoundarySignatureStore; -import art.arcane.iris.world.history.TransitionGenerationPlan; -import art.arcane.iris.world.history.SavedTerrainChunk; -import art.arcane.iris.world.history.TerrainBoundarySignature; -import art.arcane.iris.generation.biome.IrisBiome; -import art.arcane.iris.generation.terrain.IrisRegion; -import art.arcane.volmlib.nativelib.terrain.NativeBiome; -import art.arcane.iris.spi.IrisPlatform; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.spi.PlatformRegistries; -import art.arcane.volmlib.nativelib.terrain.NativeBlockState; -import art.arcane.iris.testsupport.PlatformLeakGuard; -import art.arcane.iris.generation.context.ChunkContext; -import art.arcane.iris.generation.context.IrisContext; -import art.arcane.volmlib.util.hunk.Hunk; -import art.arcane.volmlib.util.stream.ProceduralStream; -import art.arcane.volmlib.util.collection.KList; -import org.junit.ClassRule; -import org.junit.Test; -import org.mockito.MockedStatic; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.OptionalInt; -import java.util.concurrent.atomic.AtomicReference; -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.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyDouble; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doCallRealMethod; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -public final class ResolvedTerrainProviderTest { - @ClassRule - public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - - @Test - public void cachedTransitionTerrainRetainsDeferredBlocksWhenCopied() { - Fixture fixture = new Fixture(); - String key = "itemsadder:rocks/ruby_ore"; - NativeBlockState custom = Fixture.block(key, false); - when(custom.isCustom()).thenReturn(true); - when(custom.placementBaseState()).thenReturn(fixture.stone); - when(custom.deferredPlacementKey()).thenReturn(key); - TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); - when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true); - when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); - when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); - fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - context.setTerrainHeight(localX, localZ, 2); - for (int y = 0; y < 16; y++) { - blocks.setRaw(localX, y, localZ, y == 2 ? custom : y < 2 ? fixture.stone : fixture.air); - biomes.setRaw(localX, y, localZ, fixture.biome); - } - } - } - }; - TerrainBoundarySignature captured = fixture.provider.column(0, 0); - assertEquals(key, captured.geometry().voxelAt(2).stateKey()); - IrisPlatform platform = mock(IrisPlatform.class); - PlatformRegistries registries = mock(PlatformRegistries.class); - when(platform.registries()).thenReturn(registries); - when(registries.blockOrNull(key)).thenReturn(custom); - when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone); - when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air); - when(registries.biome("minecraft:plains")).thenReturn(fixture.biome); - ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - AtomicInteger customWrites = new AtomicInteger(); - Hunk blocks = Hunk.newArrayHunk(16, 16, 16).listen((x, y, z, state) -> { - if (state.isCustom()) { - customWrites.incrementAndGet(); - } - }); - try (MockedStatic platforms = mockStatic(IrisPlatforms.class); - IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { - platforms.when(IrisPlatforms::get).thenReturn(platform); - fixture.provider.generate(fixture.mode, 0, 0, blocks, Hunk.newArrayHunk(16, 16, 16), false, context); - } - - assertEquals(256, customWrites.get()); - assertSame(custom, blocks.getRaw(0, 2, 0)); - assertEquals(key, blocks.getRaw(0, 2, 0).deferredPlacementKey()); - assertEquals(Integer.valueOf(1), fixture.computations.get("0,0")); - } - - @Test - public void cachedSpeculativeFloatingIdentityIsPublishedOnlyByActualGeneration() throws Exception { - Fixture fixture = new Fixture(); - fixture.floatingIdentity = new FloatingBiomeOverlay.Identity("floating-child", "region"); - TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); - when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true); - when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); - when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); - fixture.provider.column(0, 0); - verify(fixture.router, never()).recordFloatingBiomes(anyInt(), anyInt(), any()); - IrisPlatform platform = mock(IrisPlatform.class); - PlatformRegistries registries = mock(PlatformRegistries.class); - when(platform.registries()).thenReturn(registries); - when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone); - when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air); - when(registries.biome("minecraft:plains")).thenReturn(fixture.biome); - ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - try (MockedStatic platforms = mockStatic(IrisPlatforms.class); - IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { - platforms.when(IrisPlatforms::get).thenReturn(platform); - fixture.provider.generate(fixture.mode, 0, 0, Hunk.newArrayHunk(16, 16, 16), - Hunk.newArrayHunk(16, 16, 16), false, context); - } - assertEquals(Integer.valueOf(1), fixture.computations.get("0,0")); - assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().volumeAt(0, 0, 0)); - assertEquals(fixture.floatingIdentity, context.getFloatingBiomes().surfaceAt(0, 0)); - verify(fixture.router).recordFloatingBiomes(0, 0, context.getFloatingBiomes()); - } - - @Test - public void columnRequestsAreDeterministicAcrossOrderAndNegativeCoordinates() { - Fixture first = new Fixture(); - Fixture second = new Fixture(); - int[][] columns = {{0, 0}, {-1, -17}, {31, 16}, {7, 13}}; - List expected = new ArrayList<>(); - for (int[] column : columns) { - expected.add(first.provider.column(column[0], column[1])); - } - for (int index = columns.length - 1; index >= 0; index--) { - int[] coordinate = columns[index]; - assertColumnEquals(expected.get(index), second.provider.column(coordinate[0], coordinate[1])); - } - assertEquals(3, first.computations.size()); - assertEquals(3, second.computations.size()); - assertNull(IrisContext.get()); - } - - @Test - public void evictedChunksRegenerateTheSameGeometryAndBiomes() { - Fixture fixture = new Fixture(); - TerrainBoundarySignature original = fixture.provider.column(0, 0); - assertSame(original, fixture.provider.column(0, 0)); - for (int chunkX = 1; chunkX <= 64; chunkX++) { - fixture.provider.column(chunkX * 16, 0); - } - - TerrainBoundarySignature regenerated = fixture.provider.column(0, 0); - - assertColumnEquals(original, regenerated); - assertEquals(Integer.valueOf(2), fixture.computations.get("0,0")); - fixture.provider.clear(); - assertColumnEquals(original, fixture.provider.column(0, 0)); - assertEquals(Integer.valueOf(3), fixture.computations.get("0,0")); - } - - @Test - public void scalarHeightsSurviveGeometryEvictionForEveryColumnAndClearWithProvider() { - Fixture fixture = new Fixture(); - int expected = fixture.provider.height(-1, -17, true); - for (int chunkX = 1; chunkX <= 64; chunkX++) { - fixture.provider.column(chunkX * 16, 0); - } - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - int x = -16 + localX; - int z = -32 + localZ; - int height = 2 + Math.floorMod(x + z, 5); - assertEquals(height, fixture.provider.height(x, z, true)); - assertEquals(height, fixture.provider.height(x, z, false)); - } - } - assertEquals(Integer.valueOf(1), fixture.computations.get("-1,-2")); - assertEquals(expected, fixture.provider.column(-1, -17).oceanFloorHeight()); - assertEquals(Integer.valueOf(2), fixture.computations.get("-1,-2")); - fixture.provider.clear(); - assertEquals(expected, fixture.provider.height(-1, -17, true)); - assertEquals(Integer.valueOf(3), fixture.computations.get("-1,-2")); - verify(fixture.router, never()).recordNaturalTerrain(any()); - } - - @Test - public void scalarHeightLookupHonorsNaturalContextAndTransitionBand() throws Exception { - Fixture fixture = new Fixture(); - TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); - when(plan.hasTransitionAtChunk(-1, -2)).thenReturn(true); - Field transitionField = IrisComplex.class.getDeclaredField("transitionGenerationPlan"); - transitionField.setAccessible(true); - transitionField.set(fixture.complex, plan); - Field providerField = IrisComplex.class.getDeclaredField("resolvedTerrain"); - providerField.setAccessible(true); - providerField.set(fixture.complex, fixture.provider); - doCallRealMethod().when(fixture.complex).resolvedTerrainHeight(anyInt(), anyInt(), anyBoolean()); - doCallRealMethod().when(fixture.complex).isNaturalTerrainContext(); - ChunkContext context = new ChunkContext(-16, -32, fixture.complex, false, - ChunkContext.PrefillPlan.NONE, null); - - assertTrue(fixture.complex.resolvedTerrainHeight(0, 0, true).isEmpty()); - try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { - assertTrue(fixture.complex.resolvedTerrainHeight(-1, -17, true).isEmpty()); - assertTrue(fixture.computations.isEmpty()); - context.beginContent(); - assertEquals(OptionalInt.of(4), fixture.complex.resolvedTerrainHeight(-1, -17, true)); - } - transitionField.set(fixture.complex, null); - assertTrue(fixture.complex.resolvedTerrainHeight(-1, -17, true).isEmpty()); - } - - @Test - public void speculativeQueriesRestoreTheCallerContextAndNeverRecordTerrain() { - Fixture fixture = new Fixture(); - ChunkContext outer = new ChunkContext(64, 64, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - outer.beginContent(); - try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 73L, outer)) { - IrisContext caller = IrisContext.require(); - fixture.provider.column(-1, -1); - assertSame(caller, IrisContext.require()); - assertSame(outer, IrisContext.require().getChunkContext()); - assertEquals(73L, fixture.lastSessionId); - } - verify(fixture.router, never()).recordNaturalTerrain(any()); - assertNull(IrisContext.get()); - } - - @Test - public void neighborRawTerrainKeepsOneRuntimeScopeThroughPrefillAndRestoresCaller() throws Exception { - Fixture fixture = new Fixture(); - ChunkContext outer = new ChunkContext(64, 64, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - outer.beginContent(); - AtomicBoolean scoped = new AtomicBoolean(); - GenerationHistoryRuntimeRouter.CoordinateScope runtimeScope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); - when(fixture.engine.openGenerationHistoryCoordinateScope(-16, -16)).thenAnswer(invocation -> { - assertFalse(scoped.getAndSet(true)); - return runtimeScope; - }); - ProceduralStream height = fixture.complex.getRawHeightStream(); - when(fixture.complex.getRawHeightStream()).thenAnswer(invocation -> { - assertTrue(scoped.get()); - return height; - }); - doAnswer(invocation -> { - assertTrue(scoped.get()); - assertSame(outer, IrisContext.require().getChunkContext()); - scoped.set(false); - return null; - }).when(runtimeScope).close(); - doAnswer(invocation -> { - assertTrue(scoped.get()); - fixture.fill(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), - invocation.getArgument(3), invocation.getArgument(5)); - return null; - }).when(fixture.mode).generateTerrain(anyInt(), anyInt(), any(), any(), anyBoolean(), any()); - - try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 73L, outer)) { - TerrainBoundarySignature result = fixture.provider.column(-1, -1); - assertSame(result, fixture.provider.column(-1, -1)); - assertSame(outer, IrisContext.require().getChunkContext()); - assertFalse(scoped.get()); - } - verify(fixture.engine, times(1)).openGenerationHistoryCoordinateScope(-16, -16); - verify(runtimeScope, times(1)).close(); - verify(fixture.router, never()).recordNaturalTerrain(any()); - } - - @Test - public void failedNeighborTerrainClosesItsRuntimeScopeAndRestoresCaller() throws Exception { - Fixture fixture = new Fixture(); - ChunkContext outer = new ChunkContext(64, 64, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - GenerationHistoryRuntimeRouter.CoordinateScope runtimeScope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); - when(fixture.engine.openGenerationHistoryCoordinateScope(16, 0)).thenReturn(runtimeScope); - IllegalStateException expected = new IllegalStateException("terrain failed"); - fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { - throw expected; - }; - - try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 73L, outer)) { - assertSame(expected, assertThrows(IllegalStateException.class, () -> fixture.provider.column(16, 0))); - assertSame(outer, IrisContext.require().getChunkContext()); - } - verify(runtimeScope, times(1)).close(); - assertNull(IrisContext.get()); - } - - @Test - public void actualGenerationRecordsNaturalTerrainBeforeContentMutatesBlocks() { - Fixture fixture = new Fixture(); - AtomicReference recorded = new AtomicReference<>(); - doAnswer(invocation -> { - assertEquals(List.of("terrain"), fixture.events); - assertTrue(IrisContext.require().getChunkContext().isNaturalTerrain()); - recorded.set(invocation.getArgument(0)); - fixture.events.add("receipt"); - return null; - }).when(fixture.router).recordNaturalTerrain(any()); - EngineStage content = (x, z, blocks, biomes, multicore, context) -> { - assertFalse(context.isNaturalTerrain()); - assertEquals(List.of("terrain", "receipt"), fixture.events); - assertNotNull(recorded.get()); - assertEquals("minecraft:stone", recorded.get().column(x, z).geometry().voxelAt(0).stateKey()); - blocks.setRaw(0, 0, 0, fixture.air); - fixture.events.add("content"); - }; - when(fixture.mode.getStages()).thenReturn(new KList<>(content)); - doCallRealMethod().when(fixture.mode).generate(anyInt(), anyInt(), any(), any(), anyBoolean(), anyLong()); - Hunk blocks = Hunk.newArrayHunk(16, 16, 16); - Hunk biomes = Hunk.newArrayHunk(16, 16, 16); - - fixture.mode.generate(0, 0, blocks, biomes, false, 41L); - - assertEquals(List.of("terrain", "receipt", "content"), fixture.events); - assertSame(fixture.air, blocks.getRaw(0, 0, 0)); - assertEquals("minecraft:stone", recorded.get().column(0, 0).geometry().voxelAt(0).stateKey()); - assertFalse(recorded.get().hasColumn(1, 1)); - assertNull(IrisContext.get()); - } - - @Test - public void naturalContextGuardAppliesOnlyToItsOwnComplexUntilContentBegins() { - Fixture fixture = new Fixture(); - IrisComplex other = mock(IrisComplex.class); - doCallRealMethod().when(fixture.complex).isNaturalTerrainContext(); - doCallRealMethod().when(other).isNaturalTerrainContext(); - ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null); - assertFalse(fixture.complex.isNaturalTerrainContext()); - try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) { - assertTrue(fixture.complex.isNaturalTerrainContext()); - assertFalse(other.isNaturalTerrainContext()); - context.beginContent(); - assertFalse(fixture.complex.isNaturalTerrainContext()); - } - assertFalse(fixture.complex.isNaturalTerrainContext()); - } - - @Test - public void containmentReadsHistoricalFacesFromSavedColumnsWithoutGeneratingOldChunks() throws Exception { - Fixture fixture = fluidBoundary(); - TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); - when(fixture.complex.getTransitionGenerationPlan().terrainSignatures()).thenReturn(snapshot); - SavedTerrainChunk saved = SavedTerrainChunk.capture(0, 0, 0, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - return new BoundaryColumnGeometry.Voxel("minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); - when(snapshot.signatureAt(anyInt(), anyInt())).thenAnswer(invocation -> - Optional.of(saved.column(invocation.getArgument(0), invocation.getArgument(1)))); - - assertEquals(3, fixture.provider.height(16, 8, true)); - assertEquals(3, fixture.provider.height(16, 8, false)); - assertEquals(0, fixture.provider.height(17, 8, true)); - assertEquals(3, fixture.provider.height(17, 8, false)); - TerrainBoundarySignature bank = fixture.provider.column(16, 8); - - assertEquals("minecraft:stone", bank.geometry().voxelAt(2).stateKey()); - assertEquals("minecraft:water[level=0]", fixture.provider.column(17, 8).geometry().voxelAt(2).stateKey()); - assertFalse(fixture.computations.containsKey("0,0")); - verify(fixture.router, never()).recordNaturalTerrain(any()); - } - - @Test - public void containmentFailsIfAHistoricalFaceIsMissing() { - Fixture fixture = fluidBoundary(); - TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); - when(fixture.complex.getTransitionGenerationPlan().terrainSignatures()).thenReturn(snapshot); - when(snapshot.signatureAt(anyInt(), anyInt())).thenReturn(Optional.empty()); - - IllegalStateException failure = assertThrows(IllegalStateException.class, () -> fixture.provider.column(16, 8)); - - assertTrue(failure.getCause() instanceof IOException); - assertFalse(fixture.computations.containsKey("0,0")); - verify(fixture.router, never()).recordNaturalTerrain(any()); - } - - private static Fixture fluidBoundary() { - Fixture fixture = new Fixture(); - TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); - when(plan.hasTransitionAtChunk(anyInt(), anyInt())).thenAnswer(invocation -> (int) invocation.getArgument(0) == 1); - when(plan.isHistoricalBlock(anyInt(), anyInt())).thenAnswer(invocation -> (int) invocation.getArgument(0) < 16); - when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(0.5D); - when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan); - NativeBlockState water = Fixture.block("minecraft:water[level=0]", false); - when(water.isFluid()).thenReturn(true); - fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> { - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - context.setTerrainHeight(localX, localZ, 0); - for (int y = 0; y < 16; y++) { - blocks.setRaw(localX, y, localZ, y == 0 ? fixture.stone : y < 4 ? water : fixture.air); - biomes.setRaw(localX, y, localZ, fixture.biome); - } - } - } - }; - return fixture; - } - - private static void assertColumnEquals(TerrainBoundarySignature expected, TerrainBoundarySignature actual) { - assertEquals(expected.column(), actual.column()); - assertEquals(expected.samples(), actual.samples()); - assertEquals(expected.geometry(), actual.geometry()); - } - - private static final class Fixture { - private final IrisEngine engine = mock(IrisEngine.class); - private final IrisComplex complex = mock(IrisComplex.class); - private final EngineMode mode = mock(EngineMode.class); - private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); - private final NativeBlockState air = block("minecraft:air", true); - private final NativeBlockState stone = block("minecraft:stone", false); - private final NativeBiome biome = mock(NativeBiome.class); - private final Map computations = new HashMap<>(); - private final List events = new ArrayList<>(); - private final ResolvedTerrainProvider provider; - private long lastSessionId; - private EngineStage terrainOverride; - private FloatingBiomeOverlay.Identity floatingIdentity; - - @SuppressWarnings("unchecked") - private Fixture() { - when(engine.getComplex()).thenReturn(complex); - when(engine.getHeight()).thenReturn(16); - when(engine.getMinHeight()).thenReturn(0); - when(engine.getMode()).thenReturn(mode); - when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); - EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); - when(engine.getPlatformHooks()).thenReturn(hooks); - when(mode.getEngine()).thenReturn(engine); - when(mode.getComplex()).thenReturn(complex); - when(biome.key()).thenReturn("minecraft:plains"); - ProceduralStream height = mock(ProceduralStream.class); - when(height.getDouble(anyDouble(), anyDouble())).thenReturn(3D); - ProceduralStream biomes = mock(ProceduralStream.class); - ProceduralStream regions = mock(ProceduralStream.class); - ProceduralStream materials = mock(ProceduralStream.class); - when(materials.get(anyDouble(), anyDouble())).thenReturn(stone); - when(complex.getRawHeightStream()).thenReturn(height); - when(complex.getTrueBiomeStream()).thenReturn(biomes); - when(complex.getCaveBiomeStream()).thenReturn(biomes); - when(complex.getRegionStream()).thenReturn(regions); - when(complex.getRockStream()).thenReturn(materials); - when(complex.getFluidStream()).thenReturn(materials); - provider = new ResolvedTerrainProvider(engine); - when(complex.getResolvedTerrain()).thenReturn(provider); - doAnswer(invocation -> { - int x = invocation.getArgument(0); - int z = invocation.getArgument(1); - Hunk blocks = invocation.getArgument(2); - Hunk physicalBiomes = invocation.getArgument(3); - ChunkContext context = invocation.getArgument(5); - fill(x, z, blocks, physicalBiomes, context); - return null; - }).when(mode).generateTerrain(anyInt(), anyInt(), any(), any(), anyBoolean(), any()); - } - - private void fill(int x, int z, Hunk blocks, - Hunk biomes, ChunkContext context) { - assertTrue(context.isNaturalTerrain()); - assertSame(context, IrisContext.require().getChunkContext()); - lastSessionId = IrisContext.require().getGenerationSessionId(); - computations.merge((x >> 4) + "," + (z >> 4), 1, Integer::sum); - events.add("terrain"); - if (terrainOverride != null) { - terrainOverride.generate(x, z, blocks, biomes, false, context); - return; - } - for (int localX = 0; localX < 16; localX++) { - for (int localZ = 0; localZ < 16; localZ++) { - int surface = 2 + Math.floorMod(x + localX + z + localZ, 5); - context.setTerrainHeight(localX, localZ, surface); - for (int y = 0; y < 16; y++) { - blocks.setRaw(localX, y, localZ, y <= surface ? stone : air); - biomes.setRaw(localX, y, localZ, biome); - } - } - } - if (floatingIdentity != null) { - context.floatingBiomes(16).record(0, 0, 0, floatingIdentity); - context.floatingBiomes(16).record(0, 2, 0, floatingIdentity); - } - } - - private static NativeBlockState block(String key, boolean air) { - NativeBlockState state = mock(NativeBlockState.class); - when(state.key()).thenReturn(key); - when(state.isAir()).thenReturn(air); - return state; - } - } -} 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 80e836bb9..4c49a09c5 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 @@ -41,7 +41,7 @@ public class UpperDimensionTransitionTest { IrisComplex complex = mock(IrisComplex.class); Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(80, 0, 30, 40, 60, 100, 110); when(complex.naturalTerrainColumn(-17, 8)).thenReturn(terrain); - when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 110D)); + when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 110D)); when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); Engine engine = mock(Engine.class); when(engine.getDimension()).thenReturn(dimension); @@ -62,7 +62,7 @@ public class UpperDimensionTransitionTest { when(dimension.getLoadKey()).thenReturn("main"); when(dimension.getUpperDimensionGap()).thenReturn(32); IrisComplex complex = mock(IrisComplex.class); - when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 139D)); + when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 139D)); when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 75D + 4D * x)); TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); when(complex.getTransitionGenerationPlan()).thenReturn(plan); @@ -80,12 +80,12 @@ public class UpperDimensionTransitionTest { } @Test - public void upperTerrainUsesUnblendedCurrentGeneratorBeforeGeometryReconciliation() { + public void upperTerrainUsesDisplacedNaturalGeneratorBeforeMirroring() { IrisDimension dimension = mock(IrisDimension.class); when(dimension.getLoadKey()).thenReturn("main"); when(dimension.getUpperDimensionGap()).thenReturn(32); IrisComplex complex = mock(IrisComplex.class); - when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); + when(complex.getNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D)); when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D)); TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); when(complex.getTransitionGenerationPlan()).thenReturn(plan); @@ -98,8 +98,7 @@ public class UpperDimensionTransitionTest { assertEquals(175, context.getEffectiveSurfaceY(-17, 8)); verify(plan, never()).terrainSampleAt(anyInt(), anyInt()); - verify(complex, never()).getNaturalHeightStream(); - verify(complex, never()).getNaturalTrueBiomeStream(); + verify(complex, never()).getUnblendedNaturalHeightStream(); } @Test diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisBiomeActuatorCoordinateTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisBiomeActuatorCoordinateTest.java index 6027f8040..5b81665e6 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisBiomeActuatorCoordinateTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisBiomeActuatorCoordinateTest.java @@ -55,10 +55,12 @@ public class IrisBiomeActuatorCoordinateTest { @Test @SuppressWarnings("unchecked") - public void speculativeTerrainDoesNotPublishPersistentMetadata() { + public void historicalChunkDoesNotPublishNewPersistentMetadata() { Engine engine = mock(Engine.class); ChunkContext context = mock(ChunkContext.class); - when(context.isSpeculativeTerrain()).thenReturn(true); + IrisComplex complex = mock(IrisComplex.class); + when(context.getComplex()).thenReturn(complex); + when(complex.allowsMantleChunkWrite(0, 0)).thenReturn(false); IrisBiomeActuator.publishNaturalMetadata(engine, 0, 0, mock(Hunk.class), context); @@ -119,6 +121,7 @@ public class IrisBiomeActuatorCoordinateTest { IrisComplex complex = mock(IrisComplex.class); when(complex.allowsNewDiscreteContentAt(100, -200)).thenReturn(false); TransitionGenerationPlan transitionPlan = mock(TransitionGenerationPlan.class); + when(transitionPlan.hasTransitionAtChunk(6, -13)).thenReturn(true); TransitionGenerationPlan.TerrainSample terrainSample = mock(TransitionGenerationPlan.TerrainSample.class); when(complex.getTransitionGenerationPlan()).thenReturn(transitionPlan); when(transitionPlan.terrainSampleAt(100, -200)).thenReturn(terrainSample); @@ -154,6 +157,7 @@ public class IrisBiomeActuatorCoordinateTest { IrisComplex complex = mock(IrisComplex.class); when(complex.allowsNewDiscreteContentAt(100, -200)).thenReturn(false); TransitionGenerationPlan transitionPlan = mock(TransitionGenerationPlan.class); + when(transitionPlan.hasTransitionAtChunk(6, -13)).thenReturn(true); TransitionGenerationPlan.TerrainSample terrainSample = mock(TransitionGenerationPlan.TerrainSample.class); when(complex.getTransitionGenerationPlan()).thenReturn(transitionPlan); when(transitionPlan.terrainSampleAt(100, -200)).thenReturn(terrainSample); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java index cf5c79e94..ab15563e6 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java @@ -26,7 +26,6 @@ import java.util.List; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -110,14 +109,16 @@ public class IrisTransitionGeometryActuatorTest { } @Test - public void refusesUnresolvableRetainedMaterialInsteadOfReplacingItWithAir() { + public void usesNativeMaterialWhenHistoricalPaletteIsUnavailable() { Engine engine = mock(Engine.class); ChunkContext context = context("removed:stone"); Hunk blocks = filled(); Hunk biomes = Hunk.newArrayHunk(16, 16, 16); - assertThrows(IllegalStateException.class, () -> new IrisTransitionGeometryActuator(engine) - .generate(16, 0, blocks, biomes, false, context)); + new IrisTransitionGeometryActuator(engine).generate(16, 0, blocks, biomes, false, context); + + assertSame(stone, blocks.getRaw(0, 3, 0)); + assertSame(air, blocks.getRaw(0, 2, 0)); } private ChunkContext context(String material) { @@ -139,6 +140,7 @@ public class IrisTransitionGeometryActuatorTest { when(complex.getTransitionGenerationPlan()).thenReturn(plan); ChunkContext context = mock(ChunkContext.class); when(context.getComplex()).thenReturn(complex); + when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3); return context; } 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 new file mode 100644 index 000000000..3e92cca66 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DColumnTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.generation.terrain; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class Terrain3DColumnTest { + @Test + public void displacementPreservesCavitiesAndUpperShelvesWhileKeepingBedrockGrounded() { + Terrain3DColumn original = new Terrain3DColumn(40D, 25, true, new int[]{0, 30, 36, 42, 50, 54}); + Terrain3DColumn shifted = original.displaced(10, 128); + + assertEquals(50D, shifted.baseHeight(), 0D); + assertEquals(64, shifted.topY()); + assertEquals(35, shifted.minY()); + assertEquals(3, shifted.spanCount()); + assertTrue(shifted.isSolid(0)); + assertTrue(shifted.isSolid(40)); + assertFalse(shifted.isSolid(41)); + assertFalse(shifted.isSolid(45)); + assertTrue(shifted.isSolid(46)); + assertTrue(shifted.isSolid(52)); + assertFalse(shifted.isSolid(53)); + assertTrue(shifted.isSolid(60)); + assertEquals(54, original.topY()); + assertSame(original, original.displaced(0, 128)); + } + + @Test + public void loweringTerrainClipsOnlySpansOutsideTheWorld() { + Terrain3DColumn original = new Terrain3DColumn(40D, 25, true, new int[]{0, 30, 36, 42, 50, 54}); + Terrain3DColumn shifted = original.displaced(-38, 128); + + assertEquals(2, shifted.spanCount()); + assertEquals(0, shifted.ceiling(0)); + assertEquals(4, shifted.floor(0)); + assertEquals(12, shifted.ceiling(1)); + assertEquals(16, shifted.floor(1)); + assertFalse(shifted.isSolid(8)); + } + + @Test + public void upwardDisplacementClipsTheWorldCeilingWithoutCreatingAnEmptyColumn() { + Terrain3DColumn column = new Terrain3DColumn(40D, 25, true, new int[]{0, 30, 36, 42}); + Terrain3DColumn shifted = column.displaced(100, 128); + assertEquals(127, shifted.topY()); + assertEquals(1, shifted.spanCount()); + assertTrue(shifted.isSolid(0)); + assertTrue(shifted.isSolid(127)); + assertEquals(0, column.displaced(-100, 128).topY()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DSurfaceSlopeTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DSurfaceSlopeTest.java index 5f4b0311a..33e5960fe 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DSurfaceSlopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DSurfaceSlopeTest.java @@ -37,7 +37,7 @@ public class Terrain3DSurfaceSlopeTest { public void neighboringContinuousRiverTerrainUsesItsPlacementHeight() { IrisComplex complex = complex(); when(complex.terrainColumn(0, 0)).thenReturn(column(60, 100, 120)); - when(complex.getPlacementHeightStream()).thenReturn( + when(complex.getHeightStream()).thenReturn( ProceduralStream.ofDouble((x, z) -> x == 3D ? 63D : 64D)); assertEquals(5D, complex.terrainSurfaceSlope(0, 60, 0), 0D); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisStaticObjectLayerTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisStaticObjectLayerTest.java index bd464df55..aab08a4ae 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisStaticObjectLayerTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisStaticObjectLayerTest.java @@ -1,12 +1,20 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.world.storage.matter.IrisMatterSupport; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.ResourceLoader; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.GenerationBoundary; +import art.arcane.iris.world.history.TransitionGenerationPlan; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -21,6 +29,8 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.matter.MatterSlice; import org.junit.After; import org.junit.Before; @@ -30,10 +40,12 @@ import org.junit.Test; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -41,6 +53,8 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -59,6 +73,7 @@ public class IrisStaticObjectLayerTest { IrisPlatforms.unbind(); PlatformRegistries registries = mock(PlatformRegistries.class); when(registries.block(anyString())).thenAnswer(call -> state(call.getArgument(0))); + when(registries.decodeBlockState(anyString())).thenAnswer(call -> state(call.getArgument(0))); IrisPlatform platform = mock(IrisPlatform.class); when(platform.registries()).thenReturn(registries); IrisPlatforms.bind(platform); @@ -174,7 +189,9 @@ public class IrisStaticObjectLayerTest { when(engineMantle.getMantle()).thenReturn(mantle); when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); when(chunk.getOrCreate(anyInt())).thenReturn(section); + when(chunk.use()).thenReturn(chunk); when(section.getSlice(TileWrapper.class)).thenReturn(tiles); + when(chunk.isFlagged(MantleFlag.REAL)).thenReturn(true); Hunk left = Hunk.newArrayHunk(16, 384, 16); Hunk right = Hunk.newArrayHunk(16, 384, 16); @@ -190,6 +207,104 @@ public class IrisStaticObjectLayerTest { verify(tiles).set(15, 0, 15, null); } + @Test + public void transitionRejectsWholeRotatedPlacementBeforeChunkWrites() { + IrisObject object = object("rotated", 1, 1, 5); + object.getBlocks().put(new IrisBlockVector(0, 0, -2), state("minecraft:stone")); + object.getBlocks().put(new IrisBlockVector(0, 0, 2), state("minecraft:stone")); + IrisStaticObjectLayer layer = compile(entry("rotated", 15, 0, 0) + .setRotation(new IrisStaticObjectRotation().setY(90))); + assertFalse(layer.blocks(0, 0).isEmpty()); + assertFalse(layer.blocks(1, 0).isEmpty()); + Engine runtime = historicalChunk(0, 0); + IrisStaticObjectLayer filtered = layer.forTransition(runtime); + + assertTrue(filtered.isEmpty()); + assertFalse(filtered.contains(17, 64, 0)); + assertSame(filtered, layer.forTransition(runtime)); + Engine engine = mock(Engine.class); + filtered.apply(engine, 16, 0, Hunk.newArrayHunk(16, 384, 16)); + verifyNoInteractions(engine); + assertSame(layer, layer.forTransition(null)); + } + + @Test + public void rejectedLaterPlacementRetainsEarlierBlocksAndTiles() { + IrisObject chest = object("chest", 1, 1, 1); + chest.getBlocks().put(new IrisBlockVector(0, 0, 0), state("minecraft:chest")); + TileData tile = new TileData("minecraft:chest", new KMap<>()); + chest.getStates().put(new IrisBlockVector(0, 0, 0), tile); + IrisObject bridge = object("bridge", 5, 1, 1); + bridge.getBlocks().put(new IrisBlockVector(-2, 0, 0), state("minecraft:stone")); + bridge.getBlocks().put(new IrisBlockVector(2, 0, 0), state("minecraft:stone")); + IrisStaticObjectLayer layer = compile(entry("chest", 17, 0, 0), + entry("bridge", 15, 0, 0).setBore(true)); + assertSame(state("minecraft:stone"), block(layer, 17, 64, 0).state()); + IrisStaticObjectLayer filtered = layer.forTransition(historicalChunk(0, 0)); + + assertSame(state("minecraft:chest"), block(filtered, 17, 64, 0).state()); + assertEquals(tile, block(filtered, 17, 64, 0).tile()); + assertFalse(filtered.contains(16, 64, 0)); + assertTrue(layer.forTransition(historicalChunk(1, 0)).isEmpty()); + } + + @Test + public void continuationOverlapRejectsWholePlacementWithoutRejectingOtherCellsInTheChunk() { + IrisObject line = object("line", 3, 1, 1); + for (int x = -1; x <= 1; x++) { + line.getBlocks().put(new IrisBlockVector(x, 0, 0), state("minecraft:stone")); + } + IrisStaticObjectLayer layer = compile(entry("line", 17, 0, 0), entry("line", 24, 0, 0)); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getTransitionGenerationPlan()).thenReturn(mock(TransitionGenerationPlan.class)); + when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer(call -> (int) call.getArgument(0) > 18); + Engine runtime = runtime(complex); + IrisStaticObjectLayer filtered = layer.forTransition(runtime); + + assertFalse(filtered.contains(16, 64, 0)); + assertFalse(filtered.contains(18, 64, 0)); + assertTrue(filtered.contains(23, 64, 0)); + assertTrue(filtered.contains(25, 64, 0)); + verify(complex).allowsNewGenerationFootprint(16, 0, 18, 0); + verify(complex).allowsNewGenerationFootprint(23, 0, 25, 0); + } + + private static Engine historicalChunk(int x, int z) { + GenerationBoundary boundary = GenerationBoundary.freeze("static-test", + List.of(new GenerationBoundary.ChunkCoordinate(x, z))); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getTransitionGenerationPlan()).thenReturn(mock(TransitionGenerationPlan.class)); + when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer(call -> !boundary.intersectsHistoricalBlocks(call.getArgument(0), call.getArgument(1), + call.getArgument(2), call.getArgument(3))); + return runtime(complex); + } + + private static Engine runtime(IrisComplex complex) { + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(complex); + return engine; + } + + @Test + public void routerAttachmentInvalidatesEarlierStaticAdmission() { + IrisObject object = object("single", 1, 1, 1); + object.getBlocks().put(new IrisBlockVector(0, 0, 0), state("minecraft:stone")); + IrisStaticObjectLayer layer = compile(entry("single", 16, 0, 0)); + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getTransitionGenerationPlan()).thenReturn(mock(TransitionGenerationPlan.class)); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty()); + when(complex.allowsNewGenerationFootprint(16, 0, 16, 0)).thenReturn(true); + assertSame(layer, layer.forTransition(engine)); + + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(mock(GenerationHistoryRuntimeRouter.class))); + when(complex.allowsNewGenerationFootprint(16, 0, 16, 0)).thenReturn(false); + assertTrue(layer.forTransition(engine).isEmpty()); + } + @Test public void placementKeepsSavedTilesWithoutSharingTheirInstances() { IrisObject object = object("chest", 1, 1, 1); @@ -214,6 +329,103 @@ public class IrisStaticObjectLayerTest { assertTrue(failure.getMessage().contains("outside the dimension height")); } + @Test + public void savedStaticPlacementRetainsPendingBlocksCustomIdentityAndTiles() throws Exception { + NativeBlockState chest = state("minecraft:chest"); + NativeBlockState custom = state("test:custom_chest"); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("test:custom_chest"); + when(custom.placementBaseState()).thenReturn(chest); + IrisObject object = object("crossing", 3, 1, 1); + object.getBlocks().put(new IrisBlockVector(-1, 0, 0), state("minecraft:stone")); + object.getBlocks().put(new IrisBlockVector(0, 0, 0), custom); + object.getBlocks().put(new IrisBlockVector(1, 0, 0), state("minecraft:stone")); + TileData tile = new TileData("minecraft:chest", new KMap<>()); + object.getStates().put(new IrisBlockVector(0, 0, 0), tile); + IrisStaticObjectLayer layer = compile(entry("crossing", 16, 0, 0), entry("crossing", 48, 0, 0)); + ContinuationFixture fixture = new ContinuationFixture(); + TileData.TileReader previous = TileData.bindPlatformReader(input -> { + String material = input.readUTF(); + assertEquals("{}", input.readUTF()); + return new TileData(material, new KMap<>()); + }); + try { + layer.apply(fixture.engine, 0, 0, Hunk.newArrayHunk(16, 384, 16)); + ObjectContinuationBundle pending = fixture.bundle(1, 0); + assertNotNull(pending); + assertEquals(1, pending.fragments().size()); + ObjectContinuationBundle.Fragment fragment = pending.fragments().getFirst(); + assertEquals(ObjectContinuationBundle.Kind.STATIC, fragment.key().kind()); + assertEquals(0, fragment.key().ordinal()); + Matter restored = fragment.decode(); + assertSame(chest, restored.getSlice(NativeBlockState.class).get(0, 64, 0)); + assertSame(state("minecraft:stone"), restored.getSlice(NativeBlockState.class).get(1, 64, 0)); + assertEquals(Identifier.fromString("test:custom_chest"), restored.getSlice(Identifier.class).get(0, 64, 0)); + assertEquals(tile, restored.getSlice(TileWrapper.class).get(0, 64, 0).getData()); + assertNull(fixture.bundle(2, 0)); + assertNull(fixture.bundle(3, 0)); + layer.apply(fixture.engine, 0, 0, Hunk.newArrayHunk(16, 384, 16)); + assertSame(pending, fixture.bundle(1, 0)); + } finally { + TileData.restorePlatformReader(previous); + } + } + + @Test + public void realStaticDestinationsDoNotBuildOrEncodeContinuationPayloads() { + NativeBlockState stone = state("minecraft:stone"); + IrisObject object = object("crossing", 3, 1, 1); + object.getBlocks().put(new IrisBlockVector(-1, 0, 0), stone); + object.getBlocks().put(new IrisBlockVector(1, 0, 0), stone); + IrisStaticObjectLayer layer = compile(entry("crossing", 16, 0, 0)); + ContinuationFixture fixture = new ContinuationFixture(); + when(fixture.chunk(0, 0).isFlagged(MantleFlag.REAL)).thenReturn(true); + when(fixture.chunk(1, 0).isFlagged(MantleFlag.REAL)).thenReturn(true); + clearInvocations(stone); + + layer.apply(fixture.engine, 0, 0, Hunk.newArrayHunk(16, 384, 16)); + + assertNull(fixture.bundle(0, 0)); + assertNull(fixture.bundle(1, 0)); + verify(stone, never()).key(); + } + + private static final class ContinuationFixture { + private final Engine engine = mock(Engine.class); + private final Map> chunks = new HashMap<>(); + + @SuppressWarnings("unchecked") + private ContinuationFixture() { + IrisMatterSupport.ensureRegistered(); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getWorldHeight()).thenReturn(384); + when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(call -> chunk(call.getArgument(0), call.getArgument(1))); + } + + @SuppressWarnings("unchecked") + private MantleChunk chunk(int x, int z) { + return chunks.computeIfAbsent(new ObjectContinuationBundle.ChunkPosition(x, z), key -> { + MantleChunk chunk = mock(MantleChunk.class); + Map sections = new HashMap<>(); + when(chunk.use()).thenReturn(chunk); + when(chunk.get(anyInt())).thenAnswer(call -> sections.get((int) call.getArgument(0))); + when(chunk.getOrCreate(anyInt())).thenAnswer(call -> sections.computeIfAbsent(call.getArgument(0), + section -> new IrisMatter(16, 16, 16))); + return chunk; + }); + } + + private ObjectContinuationBundle bundle(int x, int z) { + MantleChunk chunk = chunks.get(new ObjectContinuationBundle.ChunkPosition(x, z)); + Matter section = chunk == null ? null : chunk.get(0); + MatterSlice slice = section == null ? null : section.getSlice(ObjectContinuationBundle.class); + return slice == null ? null : slice.get(0, 0, 0); + } + } + @Test public void repeatedCompilationDoesNotChangeSavedBlocksOrPlacement() { IrisObject object = object("line", 3, 1, 1); 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 25bd9afea..63bcec1e0 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 @@ -565,6 +565,41 @@ public class IrisStructureLocatorContractTest { assertTrue(IrisStructureLocator.matchesResolved(resolved, "minecraft:second")); } + @Test + public void vacuumStructureRejectsApronThatCrossesHistoricalTerrain() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer(call -> (int) call.getArgument(0) >= 16); + IrisStructurePlacement placement = new IrisStructurePlacement(); + IrisStructure structure = new IrisStructure().setPlaceMode(ObjectPlaceMode.VACUUM_HIGH); + IrisStructureLocator.ResolvedPlacement resolved = new IrisStructureLocator.ResolvedPlacement( + placement, "test:vacuum", structure, + new KList<>(piece(24, 64, 24, 28, 68, 28)), new RNG(1L), 26, 64, 26, false); + + assertFalse(IrisStructureLocator.allowsResolvedFootprint(engine, resolved)); + verify(complex).allowsNewGenerationFootprint(4, 4, 48, 48); + structure.setPlaceMode(ObjectPlaceMode.STRUCTURE_PIECE); + assertTrue(IrisStructureLocator.allowsResolvedFootprint(engine, resolved)); + verify(complex).allowsNewGenerationFootprint(24, 24, 28, 28); + } + + @Test + public void multiPieceAssembliesDoNotUseSinglePieceVacuumAprons() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.allowsNewGenerationFootprint(24, 24, 34, 28)).thenReturn(true); + IrisStructureLocator.ResolvedPlacement resolved = new IrisStructureLocator.ResolvedPlacement( + new IrisStructurePlacement(), "test:vacuum", new IrisStructure().setPlaceMode(ObjectPlaceMode.VACUUM), + new KList<>(piece(24, 64, 24, 28, 68, 28), piece(30, 64, 24, 34, 68, 28)), + new RNG(1L), 26, 64, 26, false); + + assertTrue(IrisStructureLocator.allowsResolvedFootprint(engine, resolved)); + verify(complex).allowsNewGenerationFootprint(24, 24, 34, 28); + } + @Test public void undergroundAssemblyIsShiftedToFitOrRejectedWhole() { IrisStructurePlacement placement = new IrisStructurePlacement(); diff --git a/core/src/test/java/art/arcane/iris/world/history/DenseTransitionGeometryReference.java b/core/src/test/java/art/arcane/iris/world/history/DenseTransitionGeometryReference.java index bbf9fefc0..4f4f680ec 100644 --- a/core/src/test/java/art/arcane/iris/world/history/DenseTransitionGeometryReference.java +++ b/core/src/test/java/art/arcane/iris/world/history/DenseTransitionGeometryReference.java @@ -5,12 +5,8 @@ import java.util.List; import java.util.Objects; final class DenseTransitionGeometryReference { - private static final int MAXIMUM_OPENING_HEIGHT = 64; private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); - private static final BoundaryColumnGeometry.Voxel FLUID_BARRIER = new BoundaryColumnGeometry.Voxel( - "minecraft:obsidian", BoundaryColumnGeometry.Phase.SOLID, "", false); - private DenseTransitionGeometryReference() { } @@ -39,8 +35,7 @@ final class DenseTransitionGeometryReference { boolean changed = false; for (int offset = 0; offset < currentGeometry.height(); offset++) { BoundaryColumnGeometry.Voxel value = blendVoxel(current, historical, offset, - influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX, - currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight())); + influence.newTerrainWeight()); result.add(value); changed |= !value.equals(current.voxels.get(offset)); } @@ -48,79 +43,45 @@ final class DenseTransitionGeometryReference { } private static BoundaryColumnGeometry.Voxel blendVoxel( - ColumnProfile current, - List historical, - int offset, - double currentWeight, - double openingWeight, - boolean historicalMaterial + ColumnProfile current, List historical, int offset, double currentWeight ) { BoundaryColumnGeometry.Voxel currentVoxel = current.voxels.get(offset); - if (currentVoxel.protectedContent()) { + if (currentVoxel.protectedContent() || offset == 0) { return currentVoxel; } - boolean historicalOpening = false; double solid = 0D; - double fluid = 0D; double total = 0D; - double solidMaterialWeight = -1D; - double fluidMaterialWeight = -1D; + double fluidWeight = 0D; BoundaryColumnGeometry.Voxel oldSolid = null; BoundaryColumnGeometry.Voxel oldFluid = null; for (WeightedProfile contribution : historical) { ColumnProfile profile = contribution.profile(); - if (profile.voxels.get(offset).protectedContent()) { + BoundaryColumnGeometry.Voxel voxel = profile.voxels.get(offset); + if (voxel.protectedContent()) { continue; } - historicalOpening |= profile.enclosedOpenings[offset]; - double weight = contribution.weight(); - total += weight; - solid += profile.solidDistances[offset] * weight; - fluid += profile.fluidDistances[offset] * weight; - if (profile.solidMaterials[offset] != null && weight > solidMaterialWeight) { + total += contribution.weight(); + solid += profile.solidDistances[offset] * contribution.weight(); + if (oldSolid == null) { oldSolid = profile.solidMaterials[offset]; - solidMaterialWeight = weight; } - if (profile.fluidMaterials[offset] != null && weight > fluidMaterialWeight) { - oldFluid = profile.fluidMaterials[offset]; - fluidMaterialWeight = weight; + if (voxel.phase() == BoundaryColumnGeometry.Phase.FLUID + && profile.solidAbove[offset] && contribution.weight() > fluidWeight) { + oldFluid = voxel; + fluidWeight = contribution.weight(); } } if (total == 0D) { return currentVoxel; } - double blendedSolid = GenerationBlend.interpolate(solid / total, - current.solidDistances[offset], historicalOpening ? openingWeight : currentWeight); - if (blendedSolid > 0D) { - return selectMaterial(oldSolid, current.solidMaterials[offset], historicalMaterial); + if (GenerationBlend.interpolate(solid / total, current.solidDistances[offset], currentWeight) > 0D) { + BoundaryColumnGeometry.Voxel material = current.solidMaterials[offset]; + return material != null ? material : oldSolid != null ? oldSolid : currentVoxel; } - double blendedFluid = GenerationBlend.interpolate(fluid / total, - current.fluidDistances[offset], historicalOpening ? openingWeight : currentWeight); - if (blendedFluid > 0D) { - BoundaryColumnGeometry.Voxel newFluid = current.fluidMaterials[offset]; - if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) { - return FLUID_BARRIER; - } - return selectMaterial(oldFluid, newFluid, currentWeight <= 0.5D); + if (currentVoxel.phase() == BoundaryColumnGeometry.Phase.FLUID) { + return currentVoxel; } - return AIR; - } - - private static String fluidFamily(BoundaryColumnGeometry.Voxel voxel) { - String key = voxel.fluidStateKey(); - int properties = key.indexOf('['); - return properties < 0 ? key : key.substring(0, properties); - } - - private static BoundaryColumnGeometry.Voxel selectMaterial( - BoundaryColumnGeometry.Voxel historical, - BoundaryColumnGeometry.Voxel current, - boolean historicalMaterial - ) { - if (historical == null) { - return current == null ? AIR : current; - } - return current == null || historicalMaterial ? historical : current; + return oldFluid != null && currentWeight < 0.5D ? oldFluid : AIR; } private static BoundaryColumnGeometry.Voxel[] nearestMaterials( @@ -155,38 +116,22 @@ final class DenseTransitionGeometryReference { return materials; } - private static boolean[] enclosedOpenings(List voxels) { - boolean[] openings = new boolean[voxels.size()]; - int lowerSolid = -1; - for (int index = 0; index < voxels.size(); index++) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(index); - if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) { - if (lowerSolid >= 0 && index - lowerSolid - 1 <= MAXIMUM_OPENING_HEIGHT) { - for (int opening = lowerSolid + 1; opening < index; opening++) { - openings[opening] = !voxels.get(opening).protectedContent(); - } - } - lowerSolid = index; - } - } - return openings; - } - private static final class ColumnProfile { private final List voxels; - private final boolean[] enclosedOpenings; + private final boolean[] solidAbove; private final double[] solidDistances; - private final double[] fluidDistances; private final BoundaryColumnGeometry.Voxel[] solidMaterials; - private final BoundaryColumnGeometry.Voxel[] fluidMaterials; private ColumnProfile(BoundaryColumnGeometry geometry) { voxels = geometry.voxels(); - enclosedOpenings = enclosedOpenings(voxels); solidDistances = geometry.solidDistances(); - fluidDistances = geometry.fluidDistances(); solidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.SOLID); - fluidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.FLUID); + solidAbove = new boolean[voxels.size()]; + boolean solidSeen = false; + for (int index = voxels.size() - 1; index >= 0; index--) { + solidAbove[index] = solidSeen; + solidSeen |= voxels.get(index).phase() == BoundaryColumnGeometry.Phase.SOLID; + } } } diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRecoveryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRecoveryTest.java index 0afd65ea8..48c5601d4 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRecoveryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryRecoveryTest.java @@ -96,6 +96,61 @@ public class GenerationHistoryRecoveryTest extends GenerationHistorySupport { assertEquals(1, reopened.manifest().activation(1L).orElseThrow().activationId()); } + @Test + public void unchangedKernelBuildRetainsActivationOwnershipAndSavedMetadata() throws Exception { + Path world = temporaryFolder.newFolder("unchanged-build-world").toPath(); + Path pack = createPack("unchanged-build-pack", "alpha"); + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(101, 1, 1); + GenerationHistory original = GenerationHistory.create(world, pack, fingerprint(pack), 42L, + contract(), GenerationRegistryContract.empty(), version, singleKernel(version, "a")); + GenerationEpoch epoch = original.activeEpoch(); + Path installedPack = original.activePackRoot(); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + SavedTerrainTestRegion.write(region, new int[][]{{0, 0}}); + byte[] savedBlocks = Files.readAllBytes(region); + byte[] manifest = Files.readAllBytes(original.paths().manifest()); + + GenerationHistory rebuilt = GenerationHistory.open(world, singleKernel(version, "a")); + assertTrue(rebuilt.usesCurrentGenerator()); + rebuilt.prepareCurrentGenerator(32); + + assertEquals(1L, rebuilt.activeActivation().activationId()); + assertEquals(1, rebuilt.manifest().activations().size()); + assertEquals(1, rebuilt.manifest().epochs().size()); + assertEquals(epoch, rebuilt.activeEpoch()); + assertEquals(installedPack, rebuilt.activePackRoot()); + assertEquals(1L, rebuilt.resolveActivation(0, 0).activationId()); + assertEquals(1L, rebuilt.resolveActivation(1, 0).activationId()); + assertTrue(rebuilt.pendingActivation().isEmpty()); + assertArrayEquals(manifest, Files.readAllBytes(rebuilt.paths().manifest())); + assertArrayEquals(savedBlocks, Files.readAllBytes(region)); + assertThrows(IllegalArgumentException.class, () -> rebuilt.transitionPlan(1L)); + } + + @Test + public void changedImplementationWithTheSameAbiCreatesATransitionAndRetainsOldMetadata() throws Exception { + Path world = temporaryFolder.newFolder("changed-implementation-world").toPath(); + Path pack = createPack("changed-implementation-pack", "alpha"); + GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(101, 1, 1); + GenerationHistory original = GenerationHistory.create(world, pack, fingerprint(pack), 42L, + contract(), GenerationRegistryContract.empty(), version, singleKernel(version, "a")); + GenerationEpoch previous = original.activeEpoch(); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + SavedTerrainTestRegion.write(region, new int[][]{{0, 0}}); + byte[] savedBlocks = Files.readAllBytes(region); + GenerationHistory changed = GenerationHistory.open(world, singleKernel(version, "b")); + assertFalse(changed.usesCurrentGenerator()); + + changed.prepareCurrentGenerator(32); + + assertEquals(2L, changed.activeActivation().activationId()); + assertEquals(previous, changed.resolveEpoch(0, 0)); + assertEquals("b".repeat(64), changed.resolveEpoch(1, 0).kernelImplementationFingerprint()); + assertEquals(previous.kernelVersion(), changed.activeEpoch().kernelVersion()); + assertTrue(changed.transitionPlan(2L).hasTransitionAtChunk(1, 0)); + assertArrayEquals(savedBlocks, Files.readAllBytes(region)); + } + @Test public void changedBuildExpandsFromSavedTerrainWithoutTheOldFactory() throws Exception { Path world = temporaryFolder.newFolder("build-upgrade-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java index b9c12ef08..4c123f06b 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java @@ -10,6 +10,8 @@ import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mockStatic; @@ -194,6 +196,42 @@ public class GenerationHistoryTest extends GenerationHistorySupport { assertEquals(history.boundary(2L).identity(), active.transition().boundaryIdentity()); } + @Test + public void transitionPlansReuseFrozenInputsAcrossPromotionsAndReopen() throws Exception { + Path world = temporaryFolder.newFolder("shared-plan-world").toPath(); + Path packA = createPack("shared-plan-a", "alpha"); + Path packB = createPack("shared-plan-b", "beta"); + GenerationHistory history = createHistory(world, packA); + Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"); + writeRegion(region, new int[][]{{0, 0}}); + stage(history, packB); + history.promotePending(signaturesForChunks(new int[][]{{0, 0}})); + TransitionGenerationPlan first = history.transitionPlan(2L); + assertSame(first, history.transitionPlan(2L)); + assertSame(history.boundary(2L), first.boundary()); + assertSame(history.terrainSignatures(2L), first.terrainSignatures()); + assertTrue(first.hasTransitionAtChunk(1, 0)); + + writeRegion(region, new int[][]{{0, 0}, {1, 0}}); + stage(history, packA); + history.promotePending(signaturesForChunks(new int[][]{{0, 0}, {1, 0}})); + TransitionGenerationPlan second = history.transitionPlan(3L); + assertSame(first, history.transitionPlan(2L)); + assertSame(second, history.transitionPlan(3L)); + assertNotSame(first, second); + assertTrue(first.hasTransitionAtChunk(1, 0)); + assertFalse(second.hasTransitionAtChunk(1, 0)); + assertEquals(history.activeActivation().transition().terrainSignatureIdentity(), + second.terrainSignatures().identity()); + + GenerationHistory reopened = GenerationHistory.open(world); + TransitionGenerationPlan restored = reopened.transitionPlan(3L); + assertNotSame(second, restored); + assertSame(restored, reopened.transitionPlan(3L)); + assertEquals(second.specification(), restored.specification()); + assertFalse(restored.hasTransitionAtChunk(1, 0)); + } + @Test public void promotionIsIdempotentForEmptyAndRecoveredWorlds() throws Exception { Path emptyWorld = temporaryFolder.newFolder("empty-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticQueriesTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticQueriesTest.java index d4d3926e2..d8ec1a0fa 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticQueriesTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticQueriesTest.java @@ -137,7 +137,7 @@ public final class GenerationSemanticQueriesTest { IrisComplex complex = mock(IrisComplex.class); IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); IrisDimension dimension = mock(IrisDimension.class); - TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class); + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); GenerationHistory history = mock(GenerationHistory.class); GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); GenerationActivation activation = mock(GenerationActivation.class); @@ -159,9 +159,9 @@ public final class GenerationSemanticQueriesTest { when(engine.getDimension()).thenReturn(dimension); when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); when(complex.getHydrologyRuntime()).thenReturn(runtime); - when(complex.getTransitionGenerationPlan()).thenReturn(transition); + when(complex.getTransitionDisplacement()).thenReturn(displacement); when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true); - when(transition.hydrologyWeightAt(anyInt(), anyInt())).thenAnswer(invocation -> + when(displacement.seamWeight(anyInt(), anyInt())).thenAnswer(invocation -> invocation.getArgument(0) == tapered.x() ? 0.5D : 1D); when(dimension.getMinHeight()).thenReturn(-64); when(router.history()).thenReturn(history); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java index a78ecf46b..4bc16b410 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationUnpublishedHistoryTest.java @@ -92,7 +92,9 @@ public final class GenerationUnpublishedHistoryTest extends GenerationHistorySup } private static void assertBiomeAppendForces(SavedBiomeStore store, Path root, int x, int count) throws Exception { - try (RandomAccessFile actual = new RandomAccessFile(region(root).toFile(), "rw")) { + try (MockedStatic durability = mockStatic(Durability.class, CALLS_REAL_METHODS); + RandomAccessFile actual = new RandomAccessFile(region(root).toFile(), "rw")) { + durability.when(Durability::enabled).thenReturn(true); FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); try (MockedConstruction ignored = mockConstruction(RandomAccessFile.class, withSettings().defaultAnswer(delegatesTo(actual)), diff --git a/core/src/test/java/art/arcane/iris/world/history/HistoricalObjectContinuationsTest.java b/core/src/test/java/art/arcane/iris/world/history/HistoricalObjectContinuationsTest.java new file mode 100644 index 000000000..57a37af75 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/HistoricalObjectContinuationsTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.world.history; + +import art.arcane.iris.generation.mantle.ObjectContinuationBundle; +import art.arcane.volmlib.util.matter.IrisMatter; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HistoricalObjectContinuationsTest { + @Test + public void keepsOnlySavedOriginPlacementsAndCachesRegionReads() throws Exception { + GenerationHistory history = mock(GenerationHistory.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation first = activation(1L); + GenerationActivation second = activation(2L); + GenerationActivation third = activation(3L); + when(history.manifest()).thenReturn(manifest); + when(manifest.activations()).thenReturn(List.of(first, second, third)); + when(history.resolveActivation(0, 0)).thenReturn(first); + TransitionGenerationPlan plan = plan(3L); + ObjectContinuationBundle.Fragment crossing = fragment(0, List.of(chunk(0), chunk(1))); + ObjectContinuationBundle.Fragment unrelated = fragment(1, List.of(chunk(1), chunk(2))); + AtomicInteger reads = new AtomicInteger(); + HistoricalObjectContinuations continuations = new HistoricalObjectContinuations(history, (activation, x, z) -> { + reads.incrementAndGet(); + return Map.of(ChunkGenerationOwnership.packChunk(1, 0), + new ObjectContinuationBundle(List.of(crossing, unrelated))); + }); + + assertEquals(List.of(new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, crossing)), continuations.at(plan, 1, 0)); + assertEquals(List.of(new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, crossing)), continuations.at(plan, 1, 0)); + assertEquals(2, reads.get()); + assertTrue(continuations.at(plan, 0, 0).isEmpty()); + assertFalse(continuations.allowsFootprint(plan, 17, 1, 18, 2)); + assertTrue(continuations.allowsFootprint(plan, 24, 1, 25, 2)); + assertEquals(2, reads.get()); + } + + @Test + public void originalPendingFragmentSurvivesAnIntermediateActivation() throws Exception { + GenerationHistory history = mock(GenerationHistory.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation first = activation(1L); + GenerationActivation second = activation(2L); + GenerationActivation third = activation(3L); + when(history.manifest()).thenReturn(manifest); + when(manifest.activations()).thenReturn(List.of(first, second, third)); + when(history.resolveActivation(0, 0)).thenReturn(first); + ObjectContinuationBundle.Fragment crossing = fragment(0, List.of(chunk(0), chunk(1), chunk(2))); + HistoricalObjectContinuations continuations = new HistoricalObjectContinuations(history, (activation, x, z) -> + activation.activationId() == 1L + ? Map.of(ChunkGenerationOwnership.packChunk(2, 0), new ObjectContinuationBundle(List.of(crossing))) + : Map.of()); + + assertEquals(List.of(new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, crossing)), continuations.at(plan(2L), 2, 0)); + assertEquals(List.of(new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, crossing)), continuations.at(plan(3L), 2, 0)); + } + + @Test + public void continuationReplayUsesSourceOrderInsteadOfConcurrentPersistenceOrder() throws Exception { + GenerationHistory history = mock(GenerationHistory.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation first = activation(1L); + when(history.manifest()).thenReturn(manifest); + GenerationActivation second = activation(2L); + when(manifest.activations()).thenReturn(List.of(first, second)); + when(history.resolveActivation(0, 0)).thenReturn(first); + ObjectContinuationBundle.Fragment earlier = fragment(0, List.of(chunk(0), chunk(1))); + ObjectContinuationBundle.Fragment later = fragment(1, List.of(chunk(0), chunk(1))); + ObjectContinuationBundle.Fragment structure = new ObjectContinuationBundle.Fragment( + new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.STRUCTURE, -1, 0, 0), + earlier.bounds(), earlier.touchedChunks(), earlier.payload()); + HistoricalObjectContinuations continuations = new HistoricalObjectContinuations(history, (activation, x, z) -> + Map.of(ChunkGenerationOwnership.packChunk(1, 0), new ObjectContinuationBundle(List.of(structure, later, earlier)))); + + assertEquals(List.of(new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, earlier), + new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, later), + new GenerationHistoryRuntimeRouter.ObjectContinuation(1L, structure)), continuations.at(plan(2L), 1, 0)); + } + + private static GenerationActivation activation(long id) { + GenerationActivation activation = mock(GenerationActivation.class); + when(activation.activationId()).thenReturn(id); + return activation; + } + + private static TransitionGenerationPlan plan(long id) { + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.activationId()).thenReturn(id); + when(plan.boundary()).thenReturn(GenerationBoundary.freeze("frontier", List.of(new GenerationBoundary.ChunkCoordinate(0, 0)))); + return plan; + } + + private static ObjectContinuationBundle.ChunkPosition chunk(int x) { + return new ObjectContinuationBundle.ChunkPosition(x, 0); + } + + private static ObjectContinuationBundle.Fragment fragment(int ordinal, List touched) throws IOException { + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + new IrisMatter(16, 64, 16).write(payload); + return new ObjectContinuationBundle.Fragment(new ObjectContinuationBundle.PlacementKey(ObjectContinuationBundle.Kind.BIOME, 0, 0, ordinal), + new ObjectContinuationBundle.Bounds(15, 1, 20, 3), touched, payload.toByteArray()); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java index ea17a71cd..4a88b8b32 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreWriteTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.world.history; +import art.arcane.iris.world.storage.Durability; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -57,6 +58,35 @@ public class SavedBiomeStoreWriteTest { assertArrayEquals(expectedRegion(), Files.readAllBytes(regionPath(root))); } + @Test + public void relaxedDurabilityPreservesRecordsWithoutForcingEachAppend() throws Exception { + String previousMode = System.getProperty(Durability.MODE_PROPERTY); + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + try { + System.setProperty(Durability.MODE_PROPERTY, "relaxed"); + try (RandomAccessFile actual = new RandomAccessFile(regionPath(root).toFile(), "rw")) { + FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); + try (MockedConstruction ignored = mockConstruction(RandomAccessFile.class, + withSettings().defaultAnswer(delegatesTo(actual)), + (file, context) -> doReturn(channel).when(file).getChannel())) { + assertTrue(store.claimAndPersist(chunk(1, 3L))); + verify(channel, never()).force(true); + verify(channel).write(any(ByteBuffer[].class), anyInt(), anyInt()); + } + } + assertEquals(chunk(0, 3L), SavedBiomeStore.open(root).get(0, 0).orElseThrow()); + assertEquals(chunk(1, 3L), SavedBiomeStore.open(root).get(1, 0).orElseThrow()); + } finally { + if (previousMode == null) { + System.clearProperty(Durability.MODE_PROPERTY); + } else { + System.setProperty(Durability.MODE_PROPERTY, previousMode); + } + } + } + @Test public void encodingAllowsOtherReadsInTheSameRegion() throws Exception { SavedBiomeStore store = SavedBiomeStore.open(temporaryFolder.newFolder().toPath()); diff --git a/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java b/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java index ec08808b9..1a10757dd 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java @@ -9,6 +9,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.DirectoryStream; import java.nio.file.Path; import java.util.Arrays; import java.util.ArrayList; @@ -30,6 +31,49 @@ public class TerrainBoundarySignatureStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("lazy-shards").toPath()); + TerrainBoundarySignature first = simpleSignature(1, 2, 70); + store.publish(1L, List.of(first)); + Path damaged; + try (DirectoryStream shards = Files.newDirectoryStream(store.directory(), "terrain-cell-*.irtm")) { + damaged = shards.iterator().next(); + } + store.publish(2L, List.of(first, simpleSignature(2048, 2, 90))); + byte[] original = Files.readAllBytes(damaged); + Files.write(damaged, Arrays.copyOf(original, original.length - 1)); + + TerrainBoundarySignatureStore.Snapshot loaded = store.load(2L); + assertEquals(0L, loaded.shardLoadCount()); + assertEquals(0, loaded.cachedShardCount()); + assertEquals(90, loaded.signatureAt(2048, 2).orElseThrow().surfaceHeight()); + assertEquals(1L, loaded.shardLoadCount()); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> loaded.signatureAt(1, 2)); + assertTrue(failure.getCause() instanceof IOException); + assertEquals(1, loaded.cachedShardCount()); + + Files.write(damaged, original); + assertEquals(70, loaded.signatureAt(1, 2).orElseThrow().surfaceHeight()); + assertEquals(2L, loaded.shardLoadCount()); + } + + @Test + public void missingShardFailsWhenItsTerrainIsRequested() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("missing-shard").toPath()); + store.publish(1L, List.of(simpleSignature(-1, -1, 70))); + try (DirectoryStream shards = Files.newDirectoryStream(store.directory(), "terrain-cell-*.irtm")) { + Files.delete(shards.iterator().next()); + } + TerrainBoundarySignatureStore.Snapshot loaded = store.load(1L); + assertTrue(loaded.signatureAt(2048, 2048).isEmpty()); + assertThrows(IllegalStateException.class, () -> loaded.signatureAt(-1, -1)); + assertEquals(0, loaded.cachedShardCount()); + } + @Test public void preservesPresentAndAbsentUpperCeilingsAcrossShardReloads() throws Exception { TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionBandIndexTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionBandIndexTest.java new file mode 100644 index 000000000..0542f2582 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionBandIndexTest.java @@ -0,0 +1,199 @@ +package art.arcane.iris.world.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class TransitionBandIndexTest extends GenerationHistorySupport { + @Test + public void membershipMatchesExposedColumnsAcrossNegativeCoordinatesAndRegionEdges() throws Exception { + List chunks = List.of( + new GenerationBoundary.ChunkCoordinate(-33, -33), + new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(31, 31), + new GenerationBoundary.ChunkCoordinate(32, 31)); + GenerationBoundary boundary = GenerationBoundary.freeze("boundary", chunks); + List columns = boundary.exposedBlockColumns(); + List signatures = new ArrayList<>(columns.size()); + for (GenerationBoundary.BlockColumn column : columns) { + signatures.add(signature(column.blockX(), column.blockZ())); + } + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("membership").toPath()); + TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(2L, signatures); + for (int width : new int[]{1, 2, 16, 17, 32, 256}) { + TransitionBandIndex index = new TransitionBandIndex(boundary, new TransitionBoundarySampler(width, snapshot)); + for (GenerationBoundary.ChunkCoordinate chunk : chunks) { + for (int x = chunk.chunkX() - 4; x <= chunk.chunkX() + 4; x++) { + for (int z = chunk.chunkZ() - 4; z <= chunk.chunkZ() + 4; z++) { + assertEquals(expected(boundary, columns, x, z, width), index.contains(x, z)); + } + } + } + } + } + + @Test + public void repeatedMembershipReusesPositiveNegativeAndHistoricalResults() { + GenerationBoundary boundary = mock(GenerationBoundary.class); + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(boundary.isHistoricalChunk(0, 0)).thenReturn(true); + when(snapshot.intersectsTerrainBand(bounds(1, 0), 32)).thenReturn(true); + TransitionBandIndex index = new TransitionBandIndex(boundary, new TransitionBoundarySampler(32, snapshot)); + + for (int query = 0; query < 10_000; query++) { + assertFalse(index.contains(0, 0)); + assertTrue(index.contains(1, 0)); + assertFalse(index.contains(2, 0)); + } + + verify(boundary, times(1)).isHistoricalChunk(0, 0); + verify(boundary, times(1)).isHistoricalChunk(1, 0); + verify(boundary, times(1)).isHistoricalChunk(2, 0); + verify(snapshot, times(2)).intersectsTerrainBand(any(), eq(32)); + } + + @Test + public void concurrentQueriesShareOneColdEvaluation() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.intersectsTerrainBand(any(), eq(32))).thenReturn(true); + TransitionBandIndex index = new TransitionBandIndex(GenerationBoundary.freeze("empty", List.of()), + new TransitionBoundarySampler(32, snapshot)); + ExecutorService workers = Executors.newFixedThreadPool(8); + CyclicBarrier start = new CyclicBarrier(8); + List> queries = new ArrayList<>(); + try { + for (int worker = 0; worker < 8; worker++) { + queries.add(workers.submit(() -> { + start.await(5, TimeUnit.SECONDS); + return index.contains(-1, -1); + })); + } + for (Future query : queries) { + assertTrue(query.get(5, TimeUnit.SECONDS)); + } + verify(snapshot, times(1)).intersectsTerrainBand(bounds(-1, -1), 32); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void cachedQueriesDoNotWaitForAColdQueryInTheSameRegion() throws Exception { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + CountDownLatch scanning = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(snapshot.intersectsTerrainBand(bounds(0, 0), 32)).thenReturn(true); + when(snapshot.intersectsTerrainBand(bounds(1, 0), 32)).thenAnswer(invocation -> { + scanning.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return false; + }); + TransitionBandIndex index = new TransitionBandIndex(GenerationBoundary.freeze("empty", List.of()), + new TransitionBoundarySampler(32, snapshot)); + assertTrue(index.contains(0, 0)); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future cold = workers.submit(() -> index.contains(1, 0)); + assertTrue(scanning.await(5, TimeUnit.SECONDS)); + Future warm = workers.submit(() -> index.contains(0, 0)); + assertTrue(warm.get(2, TimeUnit.SECONDS)); + release.countDown(); + assertFalse(cold.get(5, TimeUnit.SECONDS)); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void failedMembershipCanBeRetried() { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.intersectsTerrainBand(bounds(0, 0), 32)) + .thenThrow(new IllegalStateException("Unavailable boundary shard")) + .thenReturn(true); + TransitionBandIndex index = new TransitionBandIndex(GenerationBoundary.freeze("empty", List.of()), + new TransitionBoundarySampler(32, snapshot)); + + assertThrows(IllegalStateException.class, () -> index.contains(0, 0)); + assertTrue(index.contains(0, 0)); + assertTrue(index.contains(0, 0)); + verify(snapshot, times(2)).intersectsTerrainBand(bounds(0, 0), 32); + } + + @Test + public void negativeRegionChurnKeepsMemoryBoundedAndResultsIndependent() { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + when(snapshot.intersectsTerrainBand(any(), anyInt())) + .thenAnswer(invocation -> ((TerrainBoundarySignatureStore.BlockBounds) invocation.getArgument(0)) + .minimumX() < 0L); + TransitionBandIndex index = new TransitionBandIndex(GenerationBoundary.freeze("empty", List.of()), + new TransitionBoundarySampler(32, snapshot)); + + for (int region = 0; region < 10_000; region++) { + assertFalse(index.contains(region * 32, region * 32)); + assertTrue(index.contains(-region * 32 - 1, -region * 32 - 1)); + } + assertTrue(index.cachedRegionCount() <= 1_024); + assertFalse(index.contains(0, 0)); + assertTrue(index.contains(-1, -1)); + } + + @Test + public void historicalCoordinatesKeepTheirOriginalOverflowBehavior() { + TerrainBoundarySignatureStore.Snapshot snapshot = mock(TerrainBoundarySignatureStore.Snapshot.class); + GenerationBoundary boundary = GenerationBoundary.freeze("extreme", List.of( + new GenerationBoundary.ChunkCoordinate(Integer.MAX_VALUE, Integer.MIN_VALUE))); + TransitionBandIndex index = new TransitionBandIndex(boundary, new TransitionBoundarySampler(32, snapshot)); + + assertFalse(index.contains(Integer.MAX_VALUE, Integer.MIN_VALUE)); + assertThrows(ArithmeticException.class, () -> index.contains(Integer.MAX_VALUE, 0)); + assertThrows(ArithmeticException.class, () -> index.contains(Integer.MAX_VALUE, 0)); + verifyNoInteractions(snapshot); + } + + private static TerrainBoundarySignatureStore.BlockBounds bounds(int chunkX, int chunkZ) { + return new TerrainBoundarySignatureStore.BlockBounds(chunkX * 16, chunkZ * 16, + chunkX * 16 + 15, chunkZ * 16 + 15); + } + + private static boolean expected(GenerationBoundary boundary, List columns, + int chunkX, int chunkZ, int width) { + if (boundary.isHistoricalChunk(chunkX, chunkZ)) { + return false; + } + int minimumX = chunkX * 16; + int minimumZ = chunkZ * 16; + for (GenerationBoundary.BlockColumn column : columns) { + long dx = Math.max(0L, Math.max((long) minimumX - column.blockX(), column.blockX() - minimumX - 15L)); + long dz = Math.max(0L, Math.max((long) minimumZ - column.blockZ(), column.blockZ() - minimumZ - 15L)); + if (dx * dx + dz * dz < (long) width * width) { + return true; + } + } + return false; + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java new file mode 100644 index 000000000..cf20f2d1d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java @@ -0,0 +1,134 @@ +package art.arcane.iris.world.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CyclicBarrier; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class TransitionDisplacementFieldTest extends GenerationHistorySupport { + private static final BoundaryColumnGeometry.Voxel STONE = new BoundaryColumnGeometry.Voxel( + "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); + private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( + "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + private static final BoundaryColumnGeometry.Voxel WATER = new BoundaryColumnGeometry.Voxel( + "minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false); + + @Test + public void matchesFlatSeamAndReturnsToNativeTerrainInBothDirections() throws Exception { + for (int historical : new int[]{24, 104}) { + int nativeHeight = historical == 24 ? 104 : 24; + TransitionDisplacementField field = field(historical, -1, (x, z) -> nativeHeight); + assertEquals(historical, field.height(0, 8, nativeHeight), 0.0000001D); + double previous = historical; + for (int x = 1; x < 32; x++) { + double height = field.height(x, 8, nativeHeight); + assertTrue(historical < nativeHeight ? height >= previous - 0.0000001D : height <= previous + 0.0000001D); + previous = height; + } + assertEquals(nativeHeight, field.height(31, 8, nativeHeight), 0D); + assertEquals(nativeHeight, field.height(64, 8, nativeHeight), 0D); + assertEquals(nativeHeight, field.height(-1, 8, nativeHeight), 0D); + } + } + + @Test + public void preservesNewReliefAtFullAmplitudeInsideTheBand() throws Exception { + TransitionDisplacementField field = field(72, -1, (x, z) -> 104D); + for (int x = 0; x < 32; x++) { + double relief = 4D * Math.sin(x / 2D); + assertEquals(relief, field.height(x, 8, 104D + relief) - field.height(x, 8, 104D), 0.0000001D); + } + } + + @Test + public void unchangedSurfaceDoesNotMoveAndChangedWaterHasNoMissingFluidSentinel() throws Exception { + TransitionDisplacementField field = field(24, 40, (x, z) -> 24D); + double previous = 40D; + for (int x = 0; x < 32; x++) { + assertEquals(24D, field.height(x, 8, 24D), 0D); + double fluid = field.fluidHeight(x, 8, 48D); + assertTrue(fluid >= previous - 0.0000001D); + assertTrue(fluid >= 40D && fluid <= 48D); + previous = fluid; + } + assertEquals(40D, field.fluidHeight(0, 8, 48D), 0.0000001D); + assertEquals(48D, field.fluidHeight(31, 8, 48D), 0D); + assertEquals("minecraft:water[level=0]", field.fluidStateKey(0, 8)); + assertNull(field.fluidStateKey(31, 8)); + } + + @Test + public void dryHighGroundDoesNotRaiseTheReplacementSeaLevel() throws Exception { + TransitionDisplacementField field = field(104, -1, (x, z) -> 24D); + for (int x = 0; x < 32; x++) { + assertEquals(40D, field.fluidHeight(x, 8, 40D), 0.0000001D); + assertNull(field.fluidStateKey(x, 8)); + } + } + + @Test + public void coldConcurrentQueriesShareTheSameFieldAndWarmQueriesDoNotResampleAnchors() throws Exception { + AtomicInteger samples = new AtomicInteger(); + TransitionDisplacementField field = field(72, -1, (x, z) -> { + samples.incrementAndGet(); + return 104D; + }); + ExecutorService workers = Executors.newFixedThreadPool(4); + CyclicBarrier start = new CyclicBarrier(4); + List> heights = new ArrayList<>(); + try { + for (int index = 0; index < 4; index++) { + heights.add(workers.submit(() -> { + start.await(5, TimeUnit.SECONDS); + return field.height(0, 8, 104D); + })); + } + for (Future height : heights) { + assertEquals(72D, height.get(5, TimeUnit.SECONDS), 0.0000001D); + } + int coldSamples = samples.get(); + assertTrue(coldSamples > 0); + for (int x = 0; x < 16; x++) { + field.height(x, 8, 104D); + } + assertEquals(coldSamples, samples.get()); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private TransitionDisplacementField field(int ground, int fluid, TransitionDisplacementField.HeightSource source) + throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("boundary", List.of( + new GenerationBoundary.ChunkCoordinate(-1, 0))); + SavedTerrainChunk terrain = SavedTerrainChunk.captureBoundary(-1, 0, 0, 128, "minecraft:noise", + new SavedTerrainChunk.VoxelSource() { + @Override + public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { + return y <= ground ? STONE : y <= fluid ? WATER : AIR; + } + + @Override + public String biome(int x, int y, int z) { + return "minecraft:plains"; + } + }); + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(temporaryFolder.newFolder().toPath()); + TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(2L, boundary, terrain::column); + TransitionGenerationPlan plan = new TransitionGenerationPlan(new TransitionGenerationPlan.Specification( + 2L, "old", "new", GenerationTransition.CURRENT_ALGORITHM_VERSION, 32, boundary.identity(), snapshot.identity()), + boundary, snapshot); + return new TransitionDisplacementField(plan, new TransitionDisplacementField.Sources(source, 128)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionFluidContainmentTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionFluidContainmentTest.java deleted file mode 100644 index ea25eda18..000000000 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionFluidContainmentTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package art.arcane.iris.world.history; - -import org.junit.Test; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public final class TransitionFluidContainmentTest { - private static final BoundaryColumnGeometry.Voxel AIR = voxel("minecraft:air", BoundaryColumnGeometry.Phase.AIR); - private static final BoundaryColumnGeometry.Voxel BANK = voxel("minecraft:deepslate", BoundaryColumnGeometry.Phase.SOLID); - private static final BoundaryColumnGeometry.Voxel WATER = voxel("minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID); - - @Test - public void fluidBanksUseTheSameRawHaloAcrossChunkEdgesAndRequestOrder() throws Exception { - TransitionGenerationPlan plan = band(); - SavedTerrainChunk left = raw(0, 0); - SavedTerrainChunk right = raw(1, 0); - TransitionFluidContainment.ColumnSource source = (x, z) -> { - List voxels = new ArrayList<>(16); - for (int y = 0; y < 16; y++) { - voxels.add(y == 0 ? BANK : x < 16 && y <= 4 ? WATER : AIR); - } - return BoundaryColumnGeometry.fromVoxels(0, voxels); - }; - - SavedTerrainChunk leftFirst = TransitionFluidContainment.contain(left, plan, source); - SavedTerrainChunk rightSecond = TransitionFluidContainment.contain(right, plan, source); - SavedTerrainChunk rightFirst = TransitionFluidContainment.contain(right, plan, source); - SavedTerrainChunk leftSecond = TransitionFluidContainment.contain(left, plan, source); - - assertEquals(BANK, leftFirst.column(15, 8).geometry().voxelAt(2)); - assertEquals(WATER, leftFirst.column(14, 8).geometry().voxelAt(2)); - assertEquals(AIR, rightSecond.column(16, 8).geometry().voxelAt(2)); - assertEquals(leftFirst.column(15, 8).geometry(), leftSecond.column(15, 8).geometry()); - assertEquals(rightSecond.column(16, 8).geometry(), rightFirst.column(16, 8).geometry()); - assertEquals(WATER, left.column(15, 8).geometry().voxelAt(2)); - } - - @Test - public void unsupportedFluidGetsABottomWhileOpenFluidSurfacesStayOpen() throws Exception { - SavedTerrainChunk floating = SavedTerrainChunk.capture(0, 0, 0, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - return y >= 3 && y <= 5 ? WATER : AIR; - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); - SavedTerrainChunk result = TransitionFluidContainment.contain(floating, band(), - (x, z) -> floating.column(Math.floorMod(x, 16), Math.floorMod(z, 16)).geometry()); - - assertEquals("minecraft:stone", result.column(8, 8).geometry().voxelAt(3).stateKey()); - assertEquals(WATER, result.column(8, 8).geometry().voxelAt(4)); - assertEquals(WATER, result.column(8, 8).geometry().voxelAt(5)); - assertEquals(AIR, result.column(8, 8).geometry().voxelAt(6)); - } - - @Test - public void historicalAndOutsideBandColumnsStayUnchanged() throws Exception { - SavedTerrainChunk raw = raw(0, 0); - TransitionGenerationPlan historical = band(); - when(historical.isHistoricalBlock(anyInt(), anyInt())).thenReturn(true); - TransitionGenerationPlan outside = band(); - when(outside.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D); - TransitionFluidContainment.ColumnSource forbidden = (x, z) -> { - throw new AssertionError("Immutable columns must not need a halo"); - }; - - assertSame(raw, TransitionFluidContainment.contain(raw, historical, forbidden)); - assertSame(raw, TransitionFluidContainment.contain(raw, outside, forbidden)); - } - - @Test - public void missingHaloFailsInsteadOfTreatingUnknownTerrainAsAir() throws Exception { - assertThrows(IOException.class, () -> TransitionFluidContainment.contain(raw(0, 0), band(), - (x, z) -> { throw new IOException("Missing saved boundary column"); })); - } - - @Test - public void protectedFluidAndWaterloggedStatesArePreserved() throws Exception { - BoundaryColumnGeometry.Voxel protectedWater = new BoundaryColumnGeometry.Voxel( - WATER.stateKey(), WATER.phase(), WATER.fluidStateKey(), true); - BoundaryColumnGeometry.Voxel waterlogged = new BoundaryColumnGeometry.Voxel( - "minecraft:oak_slab[type=bottom,waterlogged=true]", BoundaryColumnGeometry.Phase.SOLID, - "minecraft:water[level=0]", false); - TransitionFluidContainment.ColumnSource forbidden = (x, z) -> { - throw new AssertionError("Protected or waterlogged blocks must not be replaced"); - }; - for (BoundaryColumnGeometry.Voxel voxel : new BoundaryColumnGeometry.Voxel[]{protectedWater, waterlogged}) { - SavedTerrainChunk raw = uniform(voxel); - assertSame(raw, TransitionFluidContainment.contain(raw, band(), forbidden)); - } - } - - @Test - public void unsupportedFluidCannotUseFallingSandAsItsBank() throws Exception { - BoundaryColumnGeometry.Voxel sand = voxel("minecraft:sand", BoundaryColumnGeometry.Phase.SOLID); - SavedTerrainChunk raw = SavedTerrainChunk.capture(0, 0, 0, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - return y == 0 ? sand : y == 2 ? WATER : AIR; - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); - SavedTerrainChunk corrected = TransitionFluidContainment.contain(raw, band(), - (x, z) -> raw.column(Math.floorMod(x, 16), Math.floorMod(z, 16)).geometry()); - assertEquals("minecraft:stone", corrected.column(8, 8).geometry().voxelAt(2).stateKey()); - assertEquals(sand, corrected.column(8, 8).geometry().voxelAt(0)); - } - - private static SavedTerrainChunk uniform(BoundaryColumnGeometry.Voxel voxel) throws IOException { - return SavedTerrainChunk.capture(0, 0, 0, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - return voxel; - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); - } - - private static TransitionGenerationPlan band() { - TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); - when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(0.5D); - return plan; - } - - private static SavedTerrainChunk raw(int chunkX, int chunkZ) throws IOException { - return SavedTerrainChunk.capture(chunkX, chunkZ, 0, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - if (y == 0) { - return BANK; - } - return chunkX * 16 + x < 16 && y <= 4 ? WATER : AIR; - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); - } - - private static BoundaryColumnGeometry.Voxel voxel(String key, BoundaryColumnGeometry.Phase phase) { - return new BoundaryColumnGeometry.Voxel(key, phase, phase == BoundaryColumnGeometry.Phase.FLUID ? key : "", false); - } -} diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java index d951ffb6e..2725806b4 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java @@ -175,7 +175,7 @@ public class TransitionGenerationPlanTest { } @Test - public void exposesTheNearestFrozenPhysicalBiomeUntilDiscreteHandoff() throws Exception { + public void exposesTheNearestFrozenPhysicalBiomeOnlyInsideTheSeamPatch() throws Exception { GenerationBoundary boundary = GenerationBoundary.freeze("biomes", List.of( new GenerationBoundary.ChunkCoordinate(0, 0) )); @@ -193,15 +193,13 @@ public class TransitionGenerationPlanTest { assertEquals("iris:middle", plan.historicalPhysicalBiomeKeyAt(16, 14, 8).orElseThrow()); assertEquals("iris:high", plan.historicalPhysicalBiomeKeyAt(16, 18, 8).orElseThrow()); + assertTrue(plan.historicalPhysicalBiomeKeyAt(19, 14, 8).isEmpty()); assertTrue(plan.historicalPhysicalBiomeKeyAt(31, 14, 8).isEmpty()); for (int blockX = -2; blockX <= 34; blockX++) { for (int blockZ : new int[]{-2, 8, 17}) { TransitionGenerationPlan.TerrainSample sample = plan.terrainSampleAt(blockX, blockZ); for (int blockY = -16; blockY <= 48; blockY++) { - double weight = GenerationBlend.newEpochWeight( - Math.max(0D, sample.distanceToHistoricalTerrain() - 1D), Math.max(1, plan.widthBlocks() - 1)); - Optional expected = sample.newEpochWeight() != 1D - && GenerationBlend.usesHistoricalMaterial(blockX, blockY, blockZ, weight) + Optional expected = sample.distanceToHistoricalTerrain() < 4D ? sample.historicalPhysicalBiomeKeyAt(blockY) : Optional.empty(); assertEquals(expected, plan.historicalPhysicalBiomeKeyAt(blockX, blockY, blockZ, sample)); assertEquals(expected, plan.historicalPhysicalBiomeKeyAt(blockX, blockY, blockZ)); diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java index 33cad58f3..add92231c 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java @@ -38,7 +38,7 @@ public class TransitionGeometryBlenderTest { } @Test - public void narrowHistoricalCaveContinuesBeforeTaperingIntoTallSolidTerrain() throws Exception { + public void narrowHistoricalCaveIsPatchedOnlyAtTheSeam() throws Exception { ArrayList oldVoxels = new ArrayList<>(768); for (int offset = 0; offset < 768; offset++) { oldVoxels.add(offset == 384 ? AIR : STONE); @@ -47,9 +47,8 @@ public class TransitionGeometryBlenderTest { BoundaryColumnGeometry current = filled(STONE, 768); TransitionGenerationPlan plan = plan(List.of(signature(15, 0, old))); - for (int x = 16; x <= 23; x++) { - assertEquals(AIR, TransitionGeometryBlender.blendColumn(plan, x, 0, current).voxelAt(380)); - } + assertEquals(AIR, TransitionGeometryBlender.blendColumn(plan, 16, 0, current).voxelAt(380)); + assertSame(current, TransitionGeometryBlender.blendColumn(plan, 19, 0, current)); assertSame(current, TransitionGeometryBlender.blendColumn(plan, 47, 0, current)); } @@ -74,7 +73,7 @@ public class TransitionGeometryBlenderTest { } @Test - public void conflictingFluidFamiliesProduceANewSideSolidPlug() throws Exception { + public void conflictingFluidFamiliesKeepTheNativeFluidWithoutSolidPlugs() throws Exception { BoundaryColumnGeometry.Voxel lava = new BoundaryColumnGeometry.Voxel("minecraft:lava[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:lava[level=0]", false); BoundaryColumnGeometry current = filled(lava, 8); @@ -83,12 +82,23 @@ public class TransitionGeometryBlenderTest { BoundaryColumnGeometry blended = TransitionGeometryBlender.blendColumn(plan, 16, 0, current); for (BoundaryColumnGeometry.Voxel voxel : blended.voxels()) { - assertEquals(BoundaryColumnGeometry.Phase.SOLID, voxel.phase()); - assertEquals("minecraft:obsidian", voxel.stateKey()); + assertEquals(lava, voxel); } assertSame(current, TransitionGeometryBlender.blendColumn(plan, 47, 0, current)); } + @Test + public void seamPatchKeepsTheNativeSolidPaletteWithoutDithering() throws Exception { + BoundaryColumnGeometry.Voxel deepslate = new BoundaryColumnGeometry.Voxel( + "minecraft:deepslate", BoundaryColumnGeometry.Phase.SOLID, "", false); + BoundaryColumnGeometry current = filled(deepslate, 8); + TransitionGenerationPlan plan = plan(List.of(signature(15, 0, filled(STONE, 8)))); + + for (int x = 16; x < 32; x++) { + assertSame(current, TransitionGeometryBlender.blendColumn(plan, x, 0, current)); + } + } + @Test public void differentLevelsOfTheSameFluidDoNotCreateSolidPlugs() throws Exception { BoundaryColumnGeometry.Voxel flowing = new BoundaryColumnGeometry.Voxel("minecraft:water[level=3]", @@ -96,7 +106,7 @@ public class TransitionGeometryBlenderTest { BoundaryColumnGeometry current = filled(flowing, 8); TransitionGenerationPlan plan = plan(List.of(signature(15, 0, filled(WATER, 8)))); - assertEquals(WATER, TransitionGeometryBlender.blendColumn(plan, 16, 0, current).voxelAt(0)); + assertEquals(flowing, TransitionGeometryBlender.blendColumn(plan, 16, 0, current).voxelAt(0)); } @Test @@ -105,13 +115,13 @@ public class TransitionGeometryBlenderTest { List.of(STONE, STONE, AIR, AIR, STONE, AIR, AIR, AIR)); BoundaryColumnGeometry current = filled(STONE, 8); TransitionGenerationPlan plan = plan(List.of(signature(15, 0, old), signature(0, 15, old))); - BoundaryColumnGeometry before = TransitionGeometryBlender.blendColumn(plan, 25, 6, current); + BoundaryColumnGeometry before = TransitionGeometryBlender.blendColumn(plan, 16, 1, current); for (int x = 46; x >= 16; x--) { TransitionGeometryBlender.blendColumn(plan, x, 0, current); } - assertEquals(before, TransitionGeometryBlender.blendColumn(plan, 25, 6, current)); + assertEquals(before, TransitionGeometryBlender.blendColumn(plan, 16, 1, current)); assertSame(current, TransitionGeometryBlender.blendColumn(plan, 80, 0, current)); } @@ -121,8 +131,8 @@ public class TransitionGeometryBlenderTest { List.of(STONE, STONE, AIR, AIR, AIR, AIR, AIR, AIR)); BoundaryColumnGeometry upper = BoundaryColumnGeometry.fromVoxels(-4, List.of(AIR, AIR, AIR, AIR, STONE, STONE, AIR, AIR)); - TerrainBoundarySignature west = signature(15, 0, lower); - TerrainBoundarySignature south = signature(0, 15, upper); + TerrainBoundarySignature west = signature(15, 15, lower); + TerrainBoundarySignature south = signature(14, 15, upper); TransitionGenerationPlan first = plan(List.of(west, south)); TransitionGenerationPlan second = plan(List.of(south, west)); BoundaryColumnGeometry current = filled(STONE, 8); diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryCursorTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryCursorTest.java index 50ef45ba6..eed765a39 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryCursorTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryCursorTest.java @@ -59,7 +59,7 @@ public class TransitionGeometryCursorTest { } @Test - public void matchesOpeningCutoffAndProtectedVoxelsInsideOpenings() { + public void matchesOpeningHeightsAndProtectedVoxelsInsideOpenings() { Random random = new Random(1234); for (int opening : new int[]{1, 63, 64, 65, 66}) { for (boolean protectedMiddle : new boolean[]{false, true}) { diff --git a/probe/build.gradle b/probe/build.gradle index 2cafe6521..512c6cd26 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -37,6 +37,7 @@ dependencies { runtimeOnly(libs.fastutil) runtimeOnly(libs.caffeine) runtimeOnly(libs.lz4) + runtimeOnly('com.fulcrumgenomics:jlibdeflate:0.2.0') runtimeOnly(libs.zip) runtimeOnly(libs.guava) runtimeOnly(libs.oshi) @@ -291,7 +292,31 @@ tasks.register('riverTransectProbe', JavaExec) { } } -['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe', 'terrain3DProbe', 'hydrologyBlockProbe'].each { String taskName -> +tasks.register('transitionProbe', JavaExec) { + group = 'verification' + description = 'Exercises a bounded two-pack Studio cutover, transition routing, receipts and semantics with a vanilla-biome pack.' + mainClass = 'art.arcane.iris.probe.TransitionProbe' + workingDir = rootProject.projectDir + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector') + timeout = java.time.Duration.ofMinutes(12) + doFirst { + Provider replacement = providers.gradleProperty('probeReplacementPack') + if (!probePack.isPresent() || !replacement.isPresent() || !probeDimension.isPresent() || !probeOutput.isPresent()) { + throw new GradleException('transitionProbe requires probePack, probeReplacementPack, probeDimension and probeOutput') + } + java.nio.file.Path output = new File(probeOutput.get()).canonicalFile.toPath() + if (output.startsWith(rootProject.projectDir.canonicalFile.toPath())) { + throw new GradleException('probeOutput must point outside the repository') + } + args(probePack.get(), replacement.get(), probeDimension.get(), probeSeed.getOrElse('1337'), + providers.gradleProperty('probeTransitionWidth').getOrElse('32'), + providers.gradleProperty('probeTransitionChunks').getOrElse('2'), output.toString(), + providers.gradleProperty('probeTransitionReverse').getOrElse('false')) + } +} + +['transitionProbe', 'hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe', 'terrain3DProbe', 'hydrologyBlockProbe'].each { String taskName -> tasks.named(taskName, JavaExec).configure { JavaExec task -> classpath += configurations.studioProbeApi doFirst { diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 9234a85d9..65c658e28 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -206,7 +206,10 @@ public final class StubPlatform implements IrisPlatform { @Override public boolean isStorageChest() { - return false; + return switch (blockKey()) { + case "minecraft:chest", "minecraft:trapped_chest", "minecraft:barrel" -> true; + default -> false; + }; } @Override diff --git a/probe/src/main/java/art/arcane/iris/probe/TransitionProbe.java b/probe/src/main/java/art/arcane/iris/probe/TransitionProbe.java new file mode 100644 index 000000000..2b299221c --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/TransitionProbe.java @@ -0,0 +1,405 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.world.history.ChunkGenerationOwnership; +import art.arcane.iris.world.history.ChunkGenerationSemantics; +import art.arcane.iris.world.history.GenerationActivation; +import art.arcane.iris.world.history.GenerationEpochContractFactory; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.history.GenerationRegistryContract; +import art.arcane.iris.world.history.NativeTerrainReceipt; +import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.iris.world.history.SavedBiomeChunk; +import art.arcane.iris.world.history.TerrainBoundarySignature; +import art.arcane.iris.world.history.TransitionGenerationPlan; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeTerrainReceiptStorage; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.nio.file.Files; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitOption; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; + +public final class TransitionProbe { + private TransitionProbe() { + } + + public static void main(String[] arguments) throws Exception { + Configuration configuration = Configuration.parse(arguments); + Files.createDirectory(configuration.output()); + long deadline = System.nanoTime() + Duration.ofMinutes(10).toNanos(); + Bindings bindings = new Bindings(); + Evidence evidence = new Evidence( + new ArrayList<>(List.of("phase,chunk_x,chunk_z,activation,band,elapsed_ns,receipt_bytes,blocks_sha256,biomes_sha256,receipt_sha256,metadata_sha256")), + new ArrayList<>(List.of("phase,x,z,ground_y,fluid_y,solid_spans,fluid_gaps")), + new ArrayList<>(List.of("phase,x,z,minimum_y,maximum_y_exclusive,block")), + new ArrayList<>(List.of("phase,chunk_x,chunk_z,block,count"))); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + new RealPackProbeSupport.WorkspaceOptions(configuration.packA().toFile(), configuration.dimension(), + "[transition-probe]", bindings, Path.of(System.getProperty("java.io.tmpdir")))); + RealPackProbeSupport.EngineSession session = workspace.openEngine(configuration.seed(), true, "transition")) { + IrisEngine engine = (IrisEngine) session.engine(); + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElseThrow(); + GenerationHistory history = router.history(); + long originalActivation = history.activeActivation().activationId(); + for (int x = 0; x < 2; x++) { + for (int z = 0; z < 2; z++) { + generate(new GenerationRequest(engine, bindings, x, z, "original", false, deadline), evidence); + } + } + Path world = engine.getWorld().worldFolder().toPath(); + Map historicalReceipts = new HashMap<>(); + for (int x = 0; x < 2; x++) { + for (int z = 0; z < 2; z++) { + historicalReceipts.put(ChunkGenerationOwnership.packChunk(x, z), SavedTerrainChunk.readReceipt(world, x, z)); + } + } + Path replacementPack = workspace.pack().toPath().getParent().resolve("replacement-pack"); + copyPack(configuration.packB(), replacementPack); + SavedBiomeChunk historicalBiomes = router.biomes().snapshot(0, 0).orElseThrow(); + IrisData replacement = IrisData.openRuntime(replacementPack.toFile()); + try { + IrisDimension dimension = replacement.getDimensionLoader().load(configuration.dimension()); + if (dimension == null) { + throw new IOException("Replacement dimension did not load"); + } + requireVanillaBiomes(replacement, dimension); + history.stageUpdate(replacementPack, GenerationPackFingerprint.compute(replacementPack, + GenerationPackFingerprint.CURRENT_VERSION), + GenerationEpochContractFactory.create(dimension, configuration.dimension(), "iris:transition_probe"), + GenerationRegistryContract.empty(), configuration.width()); + } finally { + replacement.close(); + } + try (GenerationHistoryRuntimeRouter.StudioCutover cutover = router.beginStudioCutover(30_000)) { + GenerationActivation activated = cutover.promotePending(); + require(activated.activationId() != originalActivation, "Pack update did not create another activation"); + } + require(historicalBiomes.equals(router.biomes().snapshot(0, 0).orElseThrow()), + "Historical biome data changed during promotion"); + require(history.resolveActivation(0, 0).activationId() == originalActivation, + "Historical chunk ownership changed"); + require(history.semantics(0, 0).orElseThrow().activationId() == originalActivation, + "Historical semantic ownership changed"); + for (int sequence = 0; sequence < configuration.chunks(); sequence++) { + int index = configuration.reverse() ? configuration.chunks() - 1 - sequence : sequence; + generate(new GenerationRequest(engine, bindings, 2 + index, 0, "transition", true, deadline), evidence); + generate(new GenerationRequest(engine, bindings, 2 + configuration.width() / 16 + index, 0, + "distant", false, deadline), evidence); + } + for (int x = 0; x < 2; x++) { + for (int z = 0; z < 2; z++) { + require(Arrays.equals(historicalReceipts.get(ChunkGenerationOwnership.packChunk(x, z)), + SavedTerrainChunk.readReceipt(world, x, z)), "Historical disk receipt changed after generation"); + } + } + require(historicalBiomes.equals(router.biomes().snapshot(0, 0).orElseThrow()), + "Historical biome data changed after generation"); + for (Throwable failure : RealPackProbeSupport.settleAndDrain()) { + throw new IllegalStateException("Generation reported a failure", failure); + } + require(bindings.captures > 0, "Promotion did not capture historical terrain"); + Files.write(configuration.output().resolve("chunks.csv"), evidence.chunks()); + Files.write(configuration.output().resolve("transect.csv"), evidence.transect()); + Files.write(configuration.output().resolve("sections.csv"), evidence.sections()); + Files.write(configuration.output().resolve("materials.csv"), evidence.materials()); + System.out.println("[transition-probe] PASS generated=" + bindings.terrain.size() + + " boundaryChunkCaptures=" + bindings.captures + + " timing=diagnostic-only platform=stub cutover=studio output=" + configuration.output()); + } + } + + private static void generate(GenerationRequest request, Evidence evidence) throws Exception { + require(System.nanoTime() < request.deadline(), "Probe exceeded ten-minute workload deadline"); + long key = ChunkGenerationOwnership.packChunk(request.x(), request.z()); + require(!request.bindings().terrain.containsKey(key), "Probe scheduled a duplicate chunk"); + GenerationHistoryRuntimeRouter router = request.engine().getGenerationHistoryRuntimeRouter().orElseThrow(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(request.x(), request.z()); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope scope = route.openRuntimeScope()) { + TransitionGenerationPlan plan = route.transitionPlan(); + boolean band = plan != null && plan.hasTransitionAtChunk(request.x(), request.z()); + require(band == request.band(), "Unexpected transition membership at " + request.x() + "," + request.z()); + long started = System.nanoTime(); + RealPackProbeSupport.GeneratedChunk generated = RealPackProbeSupport.generateChunk( + request.engine(), request.x(), request.z()); + require(route.claimGeneratedSemantics((x, y, z) -> { + NativeBlockState state = generated.blocks().getRaw(x & 15, y, z & 15); + return state == null || state.isAir(); + }), "Generated semantic claim was rejected"); + SavedTerrainChunk terrain = route.naturalTerrain().orElseThrow(); + byte[] receipt = NativeTerrainReceipt.encode(terrain, route.activation().activationId(), route.epoch().epochId()); + NativeTerrainReceipt.Decoded decoded = NativeTerrainReceipt.decode(receipt, "minecraft:full"); + require(decoded.activationId() == route.activation().activationId() + && decoded.epochId().equals(route.epoch().epochId()), "Receipt changed generation ownership"); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + if (!terrain.hasColumn(localX, localZ)) { + continue; + } + TerrainBoundarySignature original = terrain.column(request.x() * 16 + localX, request.z() * 16 + localZ); + TerrainBoundarySignature restored = decoded.terrain().column(request.x() * 16 + localX, request.z() * 16 + localZ); + require(original.column().equals(restored.column()) && original.geometry().equals(restored.geometry()), + "Receipt changed captured terrain"); + } + } + request.bindings().terrain.put(key, terrain); + writeReceipt(request.engine().getWorld().worldFolder().toPath(), request.x(), request.z(), receipt); + require(router.history().semantics(request.x(), request.z()).orElseThrow().activationId() + == route.activation().activationId(), "Generated semantics lost activation ownership"); + require(router.biomes().snapshot(request.x(), request.z()).orElseThrow().activationId() + == route.activation().activationId(), "Generated saved biomes lost activation ownership"); + long elapsed = System.nanoTime() - started; + GenerationOrderProbe.ChunkHash hash = GenerationOrderProbe.hashChunk( + new GenerationOrderProbe.ChunkCoordinate(request.x(), request.z()), + generated.blocks(), generated.biomes(), generated.height()); + String metadataHash = metadataHash(router.biomes().snapshot(request.x(), request.z()).orElseThrow(), + router.history().semantics(request.x(), request.z()).orElseThrow()); + for (Map.Entry material : new TreeMap<>(hash.blockCounts()).entrySet()) { + evidence.materials().add(request.phase() + "," + request.x() + "," + request.z() + "," + + material.getKey() + "," + material.getValue()); + } + evidence.chunks().add(request.phase() + "," + request.x() + "," + request.z() + "," + + route.activation().activationId() + "," + band + "," + elapsed + "," + receipt.length + + "," + hash.blocks() + "," + hash.biomes() + "," + + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(receipt)) + "," + metadataHash); + if (request.z() == 0) { + for (int x = 0; x < 16; x++) { + int ground = -1; + int fluid = -1; + int solidSpans = 0; + boolean solidBelow = false; + String stateBelow = null; + int spanStart = 0; + for (int y = 0; y < generated.height(); y++) { + NativeBlockState state = generated.blocks().getRaw(x, y, 8); + String stateKey = state == null ? "minecraft:air" : state.key(); + if (!stateKey.equals(stateBelow)) { + if (stateBelow != null) { + evidence.sections().add(request.phase() + "," + (request.x() * 16 + x) + ",8," + + (spanStart + request.engine().getMinHeight()) + "," + + (y + request.engine().getMinHeight()) + "," + stateBelow); + } + stateBelow = stateKey; + spanStart = y; + } + boolean solid = state != null && !state.isAir() && !isFluid(state); + if (solid && !solidBelow) { + solidSpans++; + } + solidBelow = solid; + if (state != null && !state.isAir()) { + if (isFluid(state)) { + fluid = y + request.engine().getMinHeight(); + } else { + ground = y + request.engine().getMinHeight(); + } + } + } + evidence.sections().add(request.phase() + "," + (request.x() * 16 + x) + ",8," + + (spanStart + request.engine().getMinHeight()) + "," + + (generated.height() + request.engine().getMinHeight()) + "," + stateBelow); + int fluidGaps = 0; + for (int y = ground + 1; y < fluid; y++) { + NativeBlockState state = generated.blocks().getRaw(x, y - request.engine().getMinHeight(), 8); + if (state == null || state.isAir()) { + fluidGaps++; + } + } + evidence.transect().add(request.phase() + "," + (request.x() * 16 + x) + ",8," + ground + "," + fluid + + "," + solidSpans + "," + fluidGaps); + } + } + } + } + + private static boolean isFluid(NativeBlockState state) { + return state.isWater() || state.key().startsWith("minecraft:lava"); + } + + private static String metadataHash(SavedBiomeChunk biomes, ChunkGenerationSemantics semantics) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + digest.update(biomes.column(x, z).toString().getBytes(StandardCharsets.UTF_8)); + } + } + for (Collection values : List.of(semantics.surfaceBiomeKeys(), semantics.caveBiomeKeys(), + semantics.regionKeys(), semantics.riverProfileKeys(), semantics.objectKeys(), semantics.riverFeatures(), + semantics.pointsOfInterest(), semantics.structures())) { + digest.update(values.stream().map(Object::toString).sorted().toList().toString().getBytes(StandardCharsets.UTF_8)); + } + return HexFormat.of().formatHex(digest.digest()); + } + + static void writeReceipt(Path world, int chunkX, int chunkZ, byte[] receipt) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream nbt = new DataOutputStream(bytes)) { + nbt.writeByte(10); + nbt.writeUTF(""); + nbt.writeByte(3); + nbt.writeUTF("xPos"); + nbt.writeInt(chunkX); + nbt.writeByte(3); + nbt.writeUTF("zPos"); + nbt.writeInt(chunkZ); + nbt.writeByte(8); + nbt.writeUTF("Status"); + nbt.writeUTF("minecraft:full"); + nbt.writeByte(7); + nbt.writeUTF(NativeTerrainReceiptStorage.NBT_KEY); + nbt.writeInt(receipt.length); + nbt.write(receipt); + nbt.writeByte(0); + } + Path region = Files.createDirectories(world.resolve("region")).resolve( + "r." + (chunkX >> 5) + "." + (chunkZ >> 5) + ".mca"); + byte[] payload = bytes.toByteArray(); + int sectors = (payload.length + 5 + 4095) / 4096; + require(sectors <= 255, "Receipt exceeds MCA sector limit"); + try (RandomAccessFile output = new RandomAccessFile(region.toFile(), "rw")) { + if (output.length() == 0) { + output.setLength(8192); + } + int offset = Math.toIntExact((output.length() + 4095) / 4096); + output.setLength((long) (offset + sectors) * 4096); + output.seek((long) offset * 4096); + output.writeInt(payload.length + 1); + output.writeByte(3); + output.write(payload); + output.seek((long) ((chunkX & 31) + (chunkZ & 31) * 32) * 4); + output.writeInt(offset << 8 | sectors); + } + } + + private static void copyPack(Path source, Path destination) throws IOException { + Files.walkFileTree(source, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, + new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { + Files.createDirectory(destination.resolve(source.relativize(directory))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.copy(file, destination.resolve(source.relativize(file))); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void requireVanillaBiomes(IrisData data, IrisDimension dimension) { + require(!dimension.getAllBiomes(() -> data).stream().anyMatch(biome -> biome.isCustom()), + "Transition probe requires vanilla physical biomes"); + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalStateException(message); + } + } + + record Configuration(Path packA, Path packB, String dimension, long seed, int width, int chunks, Path output, boolean reverse) { + static Configuration parse(String[] arguments) { + if (arguments.length != 7 && arguments.length != 8) { + throw new IllegalArgumentException("Expected packA packB dimension seed width chunks output [reverse]"); + } + int width = Integer.parseInt(arguments[4]); + int chunks = Integer.parseInt(arguments[5]); + if (width < 16 || width > 128 || width % 16 != 0 || chunks < 1 || chunks > 8 || chunks * 16 > width) { + throw new IllegalArgumentException("Width must be 16..128 in multiples of 16; chunks must be 1..8 within width"); + } + Path packA = Path.of(arguments[0]).toAbsolutePath().normalize(); + Path packB = Path.of(arguments[1]).toAbsolutePath().normalize(); + if (!Files.isDirectory(packA) || !Files.isDirectory(packB)) { + throw new IllegalArgumentException("Both source packs must be existing directories"); + } + Path output = Path.of(arguments[6]).toAbsolutePath().normalize(); + if (Files.exists(output) || !Files.isDirectory(output.getParent())) { + throw new IllegalArgumentException("Output must be a new directory beneath an existing parent"); + } + if (arguments[2].isBlank()) { + throw new IllegalArgumentException("Dimension cannot be blank"); + } + return new Configuration(packA, packB, arguments[2], Long.parseLong(arguments[3]), + width, chunks, output, arguments.length == 8 + && RealPackProbeSupport.parseBoolean(arguments[7], "reverse")); + } + } + + private record Evidence(List chunks, List transect, List sections, List materials) { + } + + private record GenerationRequest(IrisEngine engine, Bindings bindings, int x, int z, String phase, + boolean band, long deadline) { + } + + private static final class Bindings implements RealPackProbeSupport.RuntimeBindings { + private final Map terrain = new HashMap<>(); + private int captures; + + @Override + public IrisPlatform create(File platformRoot) { + return new StubPlatform(platformRoot); + } + + @Override + public void prepare(IrisData data, IrisDimension dimension) { + requireVanillaBiomes(data, dimension); + } + + @Override + public GenerationHistory createHistory(RealPackProbeSupport.HistoryRequest request) throws IOException { + Files.createDirectories(request.worldRoot()); + Path pack = request.data().getDataFolder().toPath(); + return GenerationHistory.create(request.worldRoot(), pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), request.seed(), + GenerationEpochContractFactory.create(request.dimension(), request.dimension().getLoadKey(), + "iris:transition_probe"), GenerationRegistryContract.empty()); + } + + @Override + public CompletableFuture captureSavedTerrainChunk(Engine engine, int chunkX, int chunkZ) { + captures++; + SavedTerrainChunk chunk = terrain.get(ChunkGenerationOwnership.packChunk(chunkX, chunkZ)); + return chunk == null ? CompletableFuture.failedFuture(new IOException("Missing generated historical chunk")) + : CompletableFuture.completedFuture(chunk); + } + + @Override + public CompletableFuture flushSavedTerrainCapture(Engine engine) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void close() { + terrain.clear(); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/ObjectContinuationProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/ObjectContinuationProbeTest.java new file mode 100644 index 000000000..e80e4d8de --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/ObjectContinuationProbeTest.java @@ -0,0 +1,414 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineTarget; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.structure.placement.StructurePlacementMarker; +import art.arcane.iris.testsupport.IrisRuntimeState; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationEpochContractFactory; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.history.GenerationRegistryContract; +import art.arcane.iris.world.history.IrisBoundarySignatureSampler; +import art.arcane.iris.world.history.NativeTerrainReceipt; +import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.AfterClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class ObjectContinuationProbeTest { + private static final long SEED = 1338; + private static final Fixture TREE = new Fixture("boundary-tree", "trees/boundary", Set.of("minecraft:oak_log", "minecraft:oak_leaves"), true); + private static final Fixture STRUCTURE = new Fixture("boundary-structure", "buildings/boundary", Set.of("minecraft:bricks", "minecraft:oak_planks", "minecraft:chest"), false); + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @AfterClass + public static void resetRuntime() { + IrisRuntimeState.reset(); + } + + @Test(timeout = 120_000) + public void removedTreeCompletesOnlyItsPublishedPlacementsAfterRestart() throws Exception { + verifyCompletion(false, TREE); + } + + @Test(timeout = 120_000) + public void originalTreeSurvivesTwoUpdatesBeforeItsTailGenerates() throws Exception { + verifyCompletion(true, TREE); + } + + @Test(timeout = 120_000) + public void removedStructureCompletesItsCarveAndFoundationAfterRestart() throws Exception { + verifyCompletion(false, STRUCTURE); + } + + private void verifyCompletion(boolean secondUpdate, Fixture fixture) throws Exception { + Map controlOld; + Map controlNew; + GeneratedSnapshot nativeSource; + GeneratedSnapshot nativeDestination; + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + fixture(fixture.pack()).toFile(), "transition", "[object-control]"); + RealPackProbeSupport.EngineSession session = workspace.openEngine(SEED, true, "control")) { + nativeSource = generatedObjects(session.engine(), 0, fixture); + controlOld = nativeSource.objects(); + nativeDestination = generatedObjects(session.engine(), 1, fixture); + controlNew = nativeDestination.objects(); + } + Set historicalPlacements = new HashSet<>(); + for (ObjectVoxel voxel : controlOld.values()) { + historicalPlacements.add(voxel.marker()); + } + Map expectedTail = new HashMap<>(); + Map unrelated = new HashMap<>(); + for (Map.Entry entry : controlNew.entrySet()) { + (historicalPlacements.contains(entry.getValue().marker()) ? expectedTail : unrelated) + .put(entry.getKey(), entry.getValue()); + } + assertFalse("Fixture must have old object blocks in the saved chunk", controlOld.isEmpty()); + assertFalse("Fixture must have an old placement crossing into the next chunk", expectedTail.isEmpty()); + if (fixture.tree()) { + assertFalse("Fixture must include old placements wholly inside the new area", unrelated.isEmpty()); + Map centers = new HashMap<>(); + Map control = new HashMap<>(controlOld); + control.putAll(controlNew); + int trunkY = control.keySet().stream().mapToInt(Position::y).min().orElseThrow(); + for (Map.Entry entry : control.entrySet()) { + if (entry.getKey().y() == trunkY) { + centers.put(entry.getValue().marker(), entry.getKey()); + } + } + boolean crossingFromNewSource = false; + boolean unrelatedFromNewSource = false; + for (Map.Entry entry : centers.entrySet()) { + if (entry.getValue().x() >= 16 && entry.getValue().x() < 32) { + crossingFromNewSource |= historicalPlacements.contains(entry.getKey()); + unrelatedFromNewSource |= !historicalPlacements.contains(entry.getKey()); + } + } + assertTrue("Fixture must distinguish two placements from the same new source chunk: " + centers, + crossingFromNewSource && unrelatedFromNewSource); + } else { + assertTrue("Structure fixture must place foundations", nativeDestination.foundations() > 0); + assertTrue("Structure fixture must carve solid ground", nativeDestination.carvedGround() > 0); + } + + Bindings bindings = new Bindings(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + new RealPackProbeSupport.WorkspaceOptions(fixture(fixture.pack()).toFile(), "transition", + "[object-update]", bindings, temporary.getRoot().toPath()))) { + IrisWorld world; + byte[] oldReceipt; + long originalActivation; + try (RealPackProbeSupport.EngineSession session = workspace.openEngine(SEED, true, "original")) { + IrisEngine engine = (IrisEngine) session.engine(); + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElseThrow(); + originalActivation = router.history().activeActivation().activationId(); + assertEquals(controlOld, generateClaimed(engine, bindings, 0, fixture).objects()); + world = engine.getWorld(); + oldReceipt = SavedTerrainChunk.readReceipt(world.worldFolder().toPath(), 0, 0); + Path replacement = workspace.pack().toPath().getParent().resolve("replacement"); + copyPack(fixture("lowland"), replacement); + IrisData replacementData = IrisData.openRuntime(replacement.toFile()); + try { + IrisDimension dimension = replacementData.getDimensionLoader().load("transition"); + assertNotNull(dimension); + router.history().stageUpdate(replacement, + GenerationPackFingerprint.compute(replacement, GenerationPackFingerprint.CURRENT_VERSION), + GenerationEpochContractFactory.create(dimension, "transition", "iris:object_probe"), + GenerationRegistryContract.empty(), 32); + } finally { + replacementData.close(); + } + try (GenerationHistoryRuntimeRouter.StudioCutover cutover = router.beginStudioCutover(30_000)) { + assertTrue(cutover.promotePending().activationId() > originalActivation); + } + } + GenerationHistory reopened = GenerationHistory.open(world.worldFolder().toPath(), SEED); + if (secondUpdate) { + Path third = workspace.pack().toPath().getParent().resolve("third-pack"); + copyPack(fixture("mountain"), third); + IrisData thirdData = IrisData.openRuntime(third.toFile()); + try { + IrisDimension dimension = thirdData.getDimensionLoader().load("transition"); + assertNotNull(dimension); + reopened.stageUpdate(third, + GenerationPackFingerprint.compute(third, GenerationPackFingerprint.CURRENT_VERSION), + GenerationEpochContractFactory.create(dimension, "transition", "iris:object_probe"), + GenerationRegistryContract.empty(), 32); + } finally { + thirdData.close(); + } + reopened.prepareCurrentGenerator(32); + reopened = GenerationHistory.open(world.worldFolder().toPath(), SEED); + assertTrue(reopened.activeActivation().activationId() > originalActivation + 1); + } + IrisData data = IrisData.openRuntime(reopened.activePackRoot().toFile()); + try { + data.bindGenerationRegistryContract(reopened.activeEpoch().registryContract()); + IrisDimension dimension = data.getDimensionLoader().load("transition"); + assertNotNull(dimension); + IrisEngine engine = new IrisEngine(new EngineTarget(world, dimension, data), + IrisEngine.InitializationMode.RUNTIME, + reopened.paths().activationMantleRoot(reopened.activeActivation().activationId()), + reopened.activeEpoch().kernelVersion(), reopened.transitionPlan(reopened.activeActivation().activationId())); + try { + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, reopened, IrisBoundarySignatureSampler.INSTANCE); + router.preloadActiveRuntimes(); + assertEquals("Old object blocks and metadata must reload before neighboring generation", + controlOld, historicalObjects(engine, fixture)); + assertHistoricalEdits(engine, nativeSource.edits()); + GeneratedSnapshot result = generateClaimed(engine, bindings, 1, fixture); + Map completed = result.objects(); + if (!fixture.tree()) { + for (Map.Entry edit : nativeDestination.edits().entrySet()) { + assertEquals("Structure carve/foundation at " + edit.getKey(), edit.getValue(), + result.blocks().get(edit.getKey())); + } + assertTrue("Completed storage blocks must retain structure-aware placement markers", + completed.values().stream().anyMatch(voxel -> voxel.marker() != null)); + } + assertEquals("Complete only placements with a published old portion", expectedTail, completed); + for (Map.Entry entry : unrelated.entrySet()) { + assertFalse("Unrelated old placement was imported at " + entry.getKey(), + completed.containsKey(entry.getKey())); + } + assertEquals("Old geometry and metadata must survive neighboring generation", + controlOld, historicalObjects(engine, fixture)); + assertHistoricalEdits(engine, nativeSource.edits()); + assertArrayEquals(oldReceipt, SavedTerrainChunk.readReceipt(world.worldFolder().toPath(), 0, 0)); + assertEquals(originalActivation, reopened.resolveActivation(0, 0).activationId()); + assertTrue(reopened.semantics(1, 0).orElseThrow().objectKeys().contains(fixture.objectKey())); + assertTrue("Generation must not report swallowed errors", RealPackProbeSupport.settleAndDrain().isEmpty()); + } finally { + engine.close(); + } + } finally { + data.close(); + } + } + } + + private static GeneratedSnapshot generateClaimed(IrisEngine engine, Bindings bindings, int chunkX, Fixture fixture) + throws Exception { + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElseThrow(); + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(chunkX, 0); + GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope scope = route.openRuntimeScope()) { + GeneratedSnapshot result = generatedObjects(engine, chunkX, fixture); + assertTrue(route.claimGeneratedSemantics()); + SavedTerrainChunk terrain = route.naturalTerrain().orElseThrow(); + bindings.terrain.put(chunkX, terrain); + TransitionProbe.writeReceipt(engine.getWorld().worldFolder().toPath(), chunkX, 0, + NativeTerrainReceipt.encode(terrain, route.activation().activationId(), route.epoch().epochId())); + return result; + } + } + + private static GeneratedSnapshot generatedObjects(Engine engine, int chunkX, Fixture fixture) { + RealPackProbeSupport.GeneratedChunk chunk = RealPackProbeSupport.generateChunk(engine, chunkX, 0); + Map result = objectMetadata(engine, chunkX, fixture); + for (Map.Entry entry : result.entrySet()) { + Position position = entry.getKey(); + NativeBlockState state = chunk.blocks().getRaw(position.x() & 15, position.y(), position.z()); + assertNotNull("Object metadata must correspond to a generated block", state); + assertEquals(entry.getValue().block(), state.key()); + } + Map blocks = new HashMap<>(); + Map edits = new HashMap<>(); + int objectBlocks = 0; + int foundations = 0; + int carvedGround = 0; + for (int x = 0; x < 16; x++) { + for (int y = 0; y < chunk.height(); y++) { + for (int z = 0; z < 16; z++) { + NativeBlockState state = chunk.blocks().getRaw(x, y, z); + if (state != null && fixture.materials().contains(state.key().split("\\[", 2)[0])) { + objectBlocks++; + } + if (!fixture.tree()) { + Position position = new Position(chunkX * 16 + x, y, z); + String block = state == null || state.isAir() ? "minecraft:air" : state.key(); + blocks.put(position, block); + if (state != null && state.key().contains("cobblestone")) { + foundations++; + edits.put(position, block); + } + if (y > 1 && y <= 72 && (state == null || state.isAir())) { + carvedGround++; + edits.put(position, block); + } + } + } + } + } + assertEquals("Every generated object block must keep placement metadata", objectBlocks, result.size()); + return new GeneratedSnapshot(result, blocks, edits, foundations, carvedGround); + } + + private static Map historicalObjects(IrisEngine engine, Fixture fixture) throws Exception { + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElseThrow(); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) { + return objectMetadata(saved.mantle().getChunk(0, 0), 0, engine.getHeight(), fixture); + } + } + + private static void assertHistoricalEdits(IrisEngine engine, Map edits) throws Exception { + if (edits.isEmpty()) { + return; + } + GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElseThrow(); + try (GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) { + MantleChunk chunk = saved.mantle().getChunk(0, 0).use(); + try { + for (Map.Entry edit : edits.entrySet()) { + Position position = edit.getKey(); + NativeBlockState block = chunk.get(position.x(), position.y(), position.z(), NativeBlockState.class); + assertNotNull("Historical carve/foundation must reload at " + position, block); + assertEquals("Historical carve/foundation changed at " + position, edit.getValue(), + block.isAir() ? "minecraft:air" : block.key()); + } + } finally { + chunk.release(); + } + } + } + + private static Map objectMetadata(Engine engine, int chunkX, Fixture fixture) { + return objectMetadata(engine.getMantle().getMantle().getChunk(chunkX, 0), chunkX, engine.getHeight(), fixture); + } + + private static Map objectMetadata(MantleChunk chunk, int chunkX, int height, Fixture fixture) { + Map result = new HashMap<>(); + chunk.use(); + try { + for (int x = 0; x < 16; x++) { + for (int y = 0; y < height; y++) { + for (int z = 0; z < 16; z++) { + String marker = chunk.get(x, y, z, String.class); + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + NativeBlockState block = chunk.get(x, y, z, NativeBlockState.class); + boolean objectBlock = block != null && fixture.materials().contains(block.key().split("\\[", 2)[0]); + if (decoded != null && decoded.objectKey().equals(fixture.objectKey()) || !fixture.tree() && objectBlock) { + TreeBlockMaterial material = chunk.get(x, y, z, TreeBlockMaterial.class); + assertNotNull("Placement marker must retain its object block", block); + if (fixture.tree()) { + assertNotNull("Tree material metadata must survive", material); + assertTrue(material.matches(block.key())); + } else if (block.isStorageChest()) { + assertNotNull("Structure storage blocks must have placement metadata", decoded); + assertEquals("boundary", decoded.structureKey()); + } + result.put(new Position(chunkX * 16 + x, y, z), new ObjectVoxel(block.key(), marker, material)); + } + } + } + } + } finally { + chunk.release(); + } + return result; + } + + private static Path fixture(String name) throws Exception { + URL resource = Objects.requireNonNull(ObjectContinuationProbeTest.class.getResource("/transition-packs/" + name)); + return Path.of(resource.toURI()); + } + + private static void copyPack(Path source, Path destination) throws IOException { + try (Stream paths = Files.walk(source)) { + for (Path path : paths.toList()) { + Path target = destination.resolve(source.relativize(path)); + if (Files.isDirectory(path)) { + Files.createDirectories(target); + } else { + Files.copy(path, target); + } + } + } + } + + private record Fixture(String pack, String objectKey, Set materials, boolean tree) { + } + + private record GeneratedSnapshot(Map objects, Map blocks, + Map edits, int foundations, int carvedGround) { + } + + private record Position(int x, int y, int z) { + } + + private record ObjectVoxel(String block, String marker, TreeBlockMaterial material) { + } + + private static final class Bindings implements RealPackProbeSupport.RuntimeBindings { + private final Map terrain = new HashMap<>(); + + @Override + public IrisPlatform create(File platformRoot) { + return new StubPlatform(platformRoot); + } + + @Override + public void prepare(IrisData data, IrisDimension dimension) { + } + + @Override + public GenerationHistory createHistory(RealPackProbeSupport.HistoryRequest request) throws IOException { + Files.createDirectories(request.worldRoot()); + Path pack = request.data().getDataFolder().toPath(); + return GenerationHistory.create(request.worldRoot(), pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), request.seed(), + GenerationEpochContractFactory.create(request.dimension(), "transition", "iris:object_probe"), + GenerationRegistryContract.empty()); + } + + @Override + public CompletableFuture captureSavedTerrainChunk(Engine engine, int chunkX, int chunkZ) { + SavedTerrainChunk chunk = chunkZ == 0 ? terrain.get(chunkX) : null; + return chunk == null ? CompletableFuture.failedFuture(new IOException("Missing historical chunk")) + : CompletableFuture.completedFuture(chunk); + } + + @Override + public CompletableFuture flushSavedTerrainCapture(Engine engine) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void close() { + terrain.clear(); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/TransitionFixtureProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/TransitionFixtureProbeTest.java new file mode 100644 index 000000000..39f674aa2 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/TransitionFixtureProbeTest.java @@ -0,0 +1,347 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.testsupport.IrisRuntimeState; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.junit.AfterClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.Arrays; +import java.util.TreeMap; +import java.util.stream.Stream; +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertTrue; + +public final class TransitionFixtureProbeTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @AfterClass + public static void resetRuntime() { + IrisRuntimeState.reset(); + } + + @Test(timeout = 120_000) + public void lowlandToMountainPreservesHistoricalTrackingAndActivatesTheNewPack() throws Exception { + run(new Scenario("lowland", "mountain", 72, -1, 104, -1)); + } + + @Test(timeout = 120_000) + public void oceanToMountainRetainsOldWaterAndProducesDryDistantTerrain() throws Exception { + run(new Scenario("ocean", "mountain", 24, 40, 104, -1)); + } + + @Test(timeout = 120_000) + public void mountainToOceanProducesTheReplacementWaterTable() throws Exception { + run(new Scenario("mountain", "ocean", 104, -1, 24, 40)); + } + + @Test(timeout = 120_000) + public void waterLevelChangeKeepsTheAuthoredOceanFloor() throws Exception { + run(new Scenario("ocean", "ocean-highwater", 24, 40, 24, 48)); + } + + private void run(Scenario scenario) throws Exception { + Path output = temporary.newFolder().toPath().resolve("output"); + Path originalPack = fixture(scenario.original()); + Path replacementPack = fixture(scenario.replacement()); + Map originalSource = snapshot(originalPack); + Map replacementSource = snapshot(replacementPack); + TransitionProbe.main(new String[]{originalPack.toString(), replacementPack.toString(), + "transition", "1337", "32", "2", output.toString()}); + assertSnapshotEquals(originalSource, snapshot(originalPack)); + assertSnapshotEquals(replacementSource, snapshot(replacementPack)); + List chunks = Files.readAllLines(output.resolve("chunks.csv")); + assertEquals(9, chunks.size()); + long originalActivation = Long.parseLong(chunks.get(1).split(",")[3]); + int original = 0; + int transition = 0; + int distant = 0; + for (String row : chunks.subList(1, chunks.size())) { + String[] cells = row.split(","); + long activation = Long.parseLong(cells[3]); + boolean band = Boolean.parseBoolean(cells[4]); + assertTrue(Long.parseLong(cells[6]) > 0); + switch (cells[0]) { + case "original" -> { + original++; + assertEquals(originalActivation, activation); + assertTrue(!band); + } + case "transition" -> { + transition++; + assertTrue(activation > originalActivation); + assertTrue(band); + } + case "distant" -> { + distant++; + assertTrue(activation > originalActivation); + assertTrue(!band); + } + default -> throw new AssertionError("Unexpected phase " + cells[0]); + } + } + assertEquals(4, original); + assertEquals(2, transition); + assertEquals(2, distant); + List transect = Files.readAllLines(output.resolve("transect.csv")); + assertEquals(97, transect.size()); + assertFlatSeam(scenario, transect); + for (String row : transect.subList(1, transect.size())) { + String[] cells = row.split(","); + if (cells[0].equals("original")) { + assertEquals(row, scenario.originalGround(), Integer.parseInt(cells[3])); + assertEquals(row, scenario.originalFluid(), Integer.parseInt(cells[4])); + } else if (cells[0].equals("distant")) { + assertEquals(row, scenario.replacementGround(), Integer.parseInt(cells[3])); + assertEquals(row, scenario.replacementFluid(), Integer.parseInt(cells[4])); + } + } + } + + @Test(timeout = 120_000) + public void slopedTransitionsKeepChunkOutputAndTrackingIndependentOfGenerationOrder() throws Exception { + compareOrders("slope-lowland", "slope-mountain", false); + } + + @Test(timeout = 120_000) + public void volumetricTransitionsKeepChunkOutputIndependentOfGenerationOrder() throws Exception { + compareOrders("volumetric-lowland", "volumetric-mountain", true); + } + + @Test(timeout = 120_000) + public void nearSeamDisplacementRetainsNewTerrainRippleCurvature() throws Exception { + Path original = fixture("lowland"); + Path replacement = fixture("ripple-mountain"); + Path output = temporary.newFolder().toPath().resolve("ripple"); + TransitionProbe.main(new String[]{original.toString(), replacement.toString(), "transition", "1337", + "64", "4", output.toString()}); + Map blended = columns(Files.readAllLines(output.resolve("transect.csv"))); + Map nativeHeights = new HashMap<>(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + replacement.toFile(), "transition", "[transition-ripple-control]"); + RealPackProbeSupport.EngineSession session = workspace.openEngine(1337, true, "native-control")) { + Map> displacedChunks = chunkContents(output); + for (int chunkX : new int[]{2, 3, 6, 7, 8, 9}) { + RealPackProbeSupport.GeneratedChunk chunk = RealPackProbeSupport.generateChunk(session.engine(), chunkX, 0); + if (chunkX >= 6) { + GenerationOrderProbe.ChunkHash nativeHash = GenerationOrderProbe.hashChunk( + new GenerationOrderProbe.ChunkCoordinate(chunkX, 0), chunk.blocks(), chunk.biomes(), chunk.height()); + assertEquals("Transition must not alter distant voxels", displacedChunks.get(chunkX + ",0").get(0), nativeHash.blocks()); + assertEquals("Transition must not alter distant physical biomes", displacedChunks.get(chunkX + ",0").get(1), nativeHash.biomes()); + continue; + } + for (int x = 0; x < 16; x++) { + int ground = -1; + for (int y = 0; y < chunk.height(); y++) { + NativeBlockState block = chunk.blocks().getRaw(x, y, 8); + if (block != null && !block.isAir() && !block.isWater()) { + ground = y; + } + } + nativeHeights.put(chunkX * 16 + x, ground); + } + } + } + int strongRipples = 0; + for (int x = 40; x <= 60; x++) { + int nativeCurvature = nativeHeights.get(x - 3) - 2 * nativeHeights.get(x) + nativeHeights.get(x + 3); + if (Math.abs(nativeCurvature) >= 6) { + strongRipples++; + int blendedCurvature = blended.get(x - 3)[0] - 2 * blended.get(x)[0] + blended.get(x + 3)[0]; + assertTrue("New terrain relief was attenuated near the seam at x=" + x + + " native=" + nativeCurvature + " blended=" + blendedCurvature, + Math.abs(nativeCurvature - blendedCurvature) <= 3); + } + } + assertTrue("Control fixture must contain enough measurable ripples", strongRipples >= 5); + } + + @Test(timeout = 120_000) + public void transitionSolidsUseTheNewPaletteWithoutHistoricalMaterialDither() throws Exception { + Path original = fixture("granite-lowland"); + Path replacement = fixture("calcite-mountain"); + Path output = temporary.newFolder().toPath().resolve("materials"); + TransitionProbe.main(new String[]{original.toString(), replacement.toString(), "transition", "1337", + "32", "2", output.toString()}); + int oldSolid = 0; + int newSolid = 0; + List rows = Files.readAllLines(output.resolve("materials.csv")); + for (String row : rows.subList(1, rows.size())) { + String[] cells = row.split(","); + if (cells[3].equals("minecraft:air") || cells[3].equals("minecraft:cave_air")) { + continue; + } + if (cells[0].equals("original")) { + assertEquals(row, "minecraft:granite", cells[3]); + oldSolid += Integer.parseInt(cells[4]); + } else { + assertEquals(row, "minecraft:calcite", cells[3]); + newSolid += Integer.parseInt(cells[4]); + } + } + assertTrue(oldSolid > 0); + assertTrue(newSolid > 0); + } + + @Test(timeout = 120_000) + public void oldCaveOpeningContinuesAtTheSeamAndStopsWithinFourBlocks() throws Exception { + Path original = fixture("cave-lowland"); + Path replacement = fixture("lowland"); + Path output = temporary.newFolder().toPath().resolve("cave"); + TransitionProbe.main(new String[]{original.toString(), replacement.toString(), "transition", "1337", + "32", "2", output.toString()}); + Map sections = sections(Files.readAllLines(output.resolve("sections.csv"))); + int opening = 0; + for (int y = 32; y <= 47; y++) { + if (sections.get(31)[y].equals("minecraft:air") || sections.get(31)[y].equals("minecraft:cave_air")) { + opening++; + assertTrue("Historical cave mouth must continue at the first seam column y=" + y, + sections.get(32)[y].equals("minecraft:air") || sections.get(32)[y].equals("minecraft:cave_air")); + } + assertEquals("Cave patch exceeded four blocks at y=" + y, "minecraft:stone", sections.get(35)[y]); + assertEquals("Cave must not be extruded across the transition at y=" + y, "minecraft:stone", sections.get(48)[y]); + } + assertTrue("Fixture must expose an actual cave mouth at the historical edge", opening >= 12); + assertEquals("Cave mouth requires a floor", "minecraft:stone", sections.get(31)[31]); + assertEquals("Cave mouth requires a roof", "minecraft:stone", sections.get(31)[48]); + } + + private static Map sections(List rows) { + Map result = new HashMap<>(); + for (String row : rows.subList(1, rows.size())) { + String[] cells = row.split(",", 6); + int x = Integer.parseInt(cells[1]); + String[] column = result.computeIfAbsent(x, ignored -> new String[128]); + String blockKey = cells[5].contains(":") ? cells[5] : "minecraft:" + cells[5]; + Arrays.fill(column, Integer.parseInt(cells[3]), Integer.parseInt(cells[4]), blockKey); + } + return result; + } + + private void compareOrders(String original, String replacement, boolean volumetric) throws Exception { + Path originalPack = fixture(original); + Path replacementPack = fixture(replacement); + Path forward = temporary.newFolder().toPath().resolve("forward"); + Path reverse = temporary.newFolder().toPath().resolve("reverse"); + for (Path output : List.of(forward, reverse)) { + TransitionProbe.main(new String[]{originalPack.toString(), replacementPack.toString(), "transition", "1337", + "64", "4", output.toString(), Boolean.toString(output.equals(reverse))}); + } + assertEquals(chunkContents(forward), chunkContents(reverse)); + Map transect = columns(Files.readAllLines(forward.resolve("transect.csv"))); + assertEquals("Transect must cover both joins without missing columns", 160, transect.size()); + if (volumetric) { + assertTrue("Volumetric fixture must contain more than one solid span", + transect.values().stream().anyMatch(column -> column[2] > 1)); + } else { + assertTrue("Old-side fixture must have a non-flat slope", transect.get(31)[0] != transect.get(0)[0]); + assertTrue("New-side fixture must have a non-flat slope", transect.get(159)[0] != transect.get(96)[0]); + assertTrue("Height must meet historical ground at the seam", Math.abs(transect.get(32)[0] - transect.get(31)[0]) <= 2); + assertTrue("Height must meet native ground at the end of the band", Math.abs(transect.get(96)[0] - transect.get(95)[0]) <= 2); + for (int x = 32; x < 96; x++) { + assertTrue("Slope step at x=" + x, Math.abs(transect.get(x)[0] - transect.get(x - 1)[0]) <= 3); + } + } + } + + private static Map> chunkContents(Path output) throws Exception { + Map> result = new HashMap<>(); + List rows = Files.readAllLines(output.resolve("chunks.csv")); + for (String row : rows.subList(1, rows.size())) { + String[] cells = row.split(","); + result.put(cells[1] + "," + cells[2], Arrays.asList(cells).subList(7, 11)); + } + return result; + } + + private static void assertFlatSeam(Scenario scenario, List rows) { + Map columns = columns(rows); + assertEquals("Transect must cover both joins without missing columns", 96, columns.size()); + int difference = scenario.replacementGround() - scenario.originalGround(); + int maximumStep = (int) Math.ceil(Math.abs(difference) * 1.875 / 32) + 1; + assertTrue("First new column must meet historical ground: old=" + columns.get(31)[0] + + " new=" + columns.get(32)[0], Math.abs(columns.get(32)[0] - columns.get(31)[0]) <= 1); + assertTrue("Band must reach native new ground: band=" + columns.get(63)[0] + + " native=" + columns.get(64)[0], Math.abs(columns.get(64)[0] - columns.get(63)[0]) <= 1); + for (int x = 32; x < 64; x++) { + int[] column = columns.get(x); + int step = column[0] - columns.get(x - 1)[0]; + assertTrue("Excessive terrain step at x=" + x + " step=" + step + " maximum=" + maximumStep, Math.abs(step) <= maximumStep); + assertTrue("Terrain overshoot at x=" + x, column[0] >= Math.min(scenario.originalGround(), scenario.replacementGround()) + && column[0] <= Math.max(scenario.originalGround(), scenario.replacementGround())); + assertEquals("Floating water or a dry gap below water at x=" + x, 0, column[3]); + if (difference == 0) { + assertEquals("Fluid-level transition must not create a stone dam at x=" + x, scenario.originalGround(), column[0]); + assertTrue("Fluid-level transition must remain wet at x=" + x, column[1] > column[0]); + assertTrue("Water level must stay within the old/new range at x=" + x, + column[1] >= Math.min(scenario.originalFluid(), scenario.replacementFluid()) + && column[1] <= Math.max(scenario.originalFluid(), scenario.replacementFluid())); + assertTrue("Water level must rise monotonically at x=" + x, column[1] >= columns.get(x - 1)[1]); + assertTrue("Water step at x=" + x, column[1] - columns.get(x - 1)[1] <= 1); + } else { + assertTrue("Transition must not create an isolated wall or pit at x=" + x + " step=" + step, + difference > 0 ? step >= 0 : step <= 0); + } + } + } + + private static Map columns(List rows) { + Map result = new TreeMap<>(); + for (String row : rows.subList(1, rows.size())) { + String[] cells = row.split(","); + int[] values = {Integer.parseInt(cells[3]), Integer.parseInt(cells[4]), + Integer.parseInt(cells[5]), Integer.parseInt(cells[6])}; + assertEquals("Duplicate transect column", null, result.put(Integer.parseInt(cells[1]), values)); + } + return result; + } + + private static Map snapshot(Path root) throws Exception { + Map entries = new TreeMap<>(); + try (Stream paths = Files.walk(root)) { + for (Path path : paths.toList()) { + entries.put(root.relativize(path), Files.isDirectory(path) ? null : Files.readAllBytes(path)); + } + } + return entries; + } + + private static void assertSnapshotEquals(Map expected, Map actual) { + assertEquals(expected.keySet(), actual.keySet()); + for (Map.Entry entry : expected.entrySet()) { + assertArrayEquals(entry.getKey().toString(), entry.getValue(), actual.get(entry.getKey())); + } + } + + private Path fixture(String name) throws Exception { + URL resource = Objects.requireNonNull(TransitionFixtureProbeTest.class.getClassLoader() + .getResource("transition-packs/" + name), "Transition fixture " + name); + Path source = Path.of(resource.toURI()); + Path pack = temporary.newFolder(name).toPath(); + try (Stream paths = Files.walk(source)) { + for (Path file : paths.filter(path -> Files.isRegularFile(path) && path.toString().endsWith(".json") + && !source.relativize(path).toString().startsWith(".")).toList()) { + Path target = pack.resolve(source.relativize(file)); + Files.createDirectories(target.getParent()); + Files.copy(file, target); + } + } + Files.write(pack.resolve(".source-content"), new byte[]{0, 17, -128, 127}); + return pack; + } + + private record Scenario(String original, String replacement, int originalGround, int originalFluid, + int replacementGround, int replacementFluid) { + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/TransitionProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/TransitionProbeTest.java new file mode 100644 index 000000000..6aeef1d63 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/TransitionProbeTest.java @@ -0,0 +1,76 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.world.history.BoundaryColumnGeometry; +import art.arcane.iris.world.history.NativeTerrainReceipt; +import art.arcane.iris.world.history.SavedTerrainChunk; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Path; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +public final class TransitionProbeTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void rejectsWorkloadsOutsideTheBandAndExistingOutputs() throws Exception { + Path root = temporary.newFolder().toPath(); + String[] arguments = {root.toString(), root.toString(), "overworld", "1337", "32", "2", root.resolve("result").toString()}; + assertEquals(2, TransitionProbe.Configuration.parse(arguments).chunks()); + arguments[5] = "3"; + assertThrows(IllegalArgumentException.class, () -> TransitionProbe.Configuration.parse(arguments)); + arguments[5] = "2"; + arguments[4] = "8192"; + assertThrows(IllegalArgumentException.class, () -> TransitionProbe.Configuration.parse(arguments)); + arguments[4] = "32"; + arguments[6] = root.toString(); + assertThrows(IllegalArgumentException.class, () -> TransitionProbe.Configuration.parse(arguments)); + } + + @Test + public void missingSourcesAreRejectedWithoutCreatingSourceOrOutputDirectories() throws Exception { + Path root = temporary.newFolder().toPath(); + for (int missingIndex : new int[]{0, 1}) { + Path missing = root.resolve("missing-" + missingIndex); + Path output = root.resolve("output-" + missingIndex); + String[] arguments = {root.toString(), root.toString(), "transition", "1337", "32", "2", output.toString()}; + arguments[missingIndex] = missing.toString(); + assertThrows(IllegalArgumentException.class, () -> TransitionProbe.main(arguments)); + assertFalse(Files.exists(missing)); + assertFalse(Files.exists(output)); + } + } + + @Test + public void persistsActualReceiptsAcrossNegativeRegionBoundariesAndMultipleChunks() throws Exception { + Path world = temporary.newFolder().toPath(); + SavedTerrainChunk.VoxelSource source = new SavedTerrainChunk.VoxelSource() { + @Override + public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { + return worldY <= 5 + ? new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false) + : new BoundaryColumnGeometry.Voxel("minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + } + + @Override + public String biome(int localX, int worldY, int localZ) { + return "minecraft:plains"; + } + }; + for (int x : new int[]{-33, -32, -31, 0, 1, 32}) { + SavedTerrainChunk chunk = SavedTerrainChunk.captureBoundary(x, -1, 0, 16, "minecraft:full", source); + TransitionProbe.writeReceipt(world, x, -1, NativeTerrainReceipt.encode(chunk, 7, "epoch")); + } + for (int x : new int[]{-33, -32, -31, 0, 1, 32}) { + assertEquals("minecraft:full", SavedTerrainChunk.readStatus(world, x, -1)); + assertEquals(5, SavedTerrainChunk.read(world, x, -1, 0, 16).column(x * 16, -16).oceanFloorHeight()); + assertEquals(7, NativeTerrainReceipt.decode(SavedTerrainChunk.readReceipt(world, x, -1), "minecraft:full").activationId()); + } + } +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/biomes/terrain.json b/probe/src/test/resources/transition-packs/boundary-structure/biomes/terrain.json new file mode 100644 index 000000000..aef04d871 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 32, + "max": 32 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/dimensions/transition.json b/probe/src/test/resources/transition-packs/boundary-structure/dimensions/transition.json new file mode 100644 index 000000000..8ff620567 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/dimensions/transition.json @@ -0,0 +1,49 @@ +{ + "name": "Boundary structure transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + }, + "structures": [ + { + "structures": [ + "boundary" + ], + "distribution": "RANDOM_SPREAD", + "spacing": 4, + "separation": 3, + "anchor": "HEIGHT_BAND", + "minHeight": 48, + "maxHeight": 64, + "terrain": { + "mode": "BORE", + "horizontalPadding": 2, + "floorPadding": 10, + "ceilingPadding": 2 + }, + "stilt": { + "maxDepth": 20, + "palette": { + "palette": [ + { + "block": "minecraft:cobblestone" + } + ] + } + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/generators/flat.json b/probe/src/test/resources/transition-packs/boundary-structure/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pieces/boundary.json b/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pieces/boundary.json new file mode 100644 index 000000000..9603b5a84 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pieces/boundary.json @@ -0,0 +1,4 @@ +{ + "object": "buildings/boundary", + "rotatable": false +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pools/boundary.json b/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pools/boundary.json new file mode 100644 index 000000000..1c6130922 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/jigsaw-pools/boundary.json @@ -0,0 +1,8 @@ +{ + "pieces": [ + { + "piece": "boundary", + "weight": 1 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/objects/buildings/boundary.iob b/probe/src/test/resources/transition-packs/boundary-structure/objects/buildings/boundary.iob new file mode 100644 index 0000000000000000000000000000000000000000..7619bbf54e23ed511087b60dfcf63649836d66c7 GIT binary patch literal 885 zcmYMyyKaLp6vlBwEO+O&c^cmm`K!9B!(DOlD|{sxYH zY^BtrQfeU6QvN*8irM?qZ2tE8B3b;CtruyYEH}@ed6upU^>9yqlhw!Hce0i=x~J*a zqS(mKQoYq$RCMILv9N-W~I7SCu^l*Yx^fAB? zBaDrD+jl|PdWR)eSmPcKc*GN)@q!Juc*PFQ>FwJ}>dW=^pDn}KUwOYC;t0p+po<<( zaEd+#7-EF6QTovDz>+=~ORTWQJs$9gCp_Z?8*K549cp7=`!+2!9kJw@n|z2P9HWCS RdN{!;`WRq{5ymK+_y?_=y5ax; literal 0 HcmV?d00001 diff --git a/probe/src/test/resources/transition-packs/boundary-structure/regions/terrain.json b/probe/src/test/resources/transition-packs/boundary-structure/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-structure/structures/boundary.json b/probe/src/test/resources/transition-packs/boundary-structure/structures/boundary.json new file mode 100644 index 000000000..09268683f --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-structure/structures/boundary.json @@ -0,0 +1,5 @@ +{ + "startPool": "boundary", + "maxDepth": 1, + "maxSizeChunks": 2 +} diff --git a/probe/src/test/resources/transition-packs/boundary-tree/biomes/terrain.json b/probe/src/test/resources/transition-packs/boundary-tree/biomes/terrain.json new file mode 100644 index 000000000..ababdd597 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-tree/biomes/terrain.json @@ -0,0 +1,38 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 32, + "max": 32 + } + ], + "objects": [ + { + "place": [ + "trees/boundary" + ], + "chance": 1, + "density": 2, + "rotation": { + "enabled": false + }, + "forcePlace": true, + "requireSurfaceSupport": false, + "translate": { + "y": 4 + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-tree/dimensions/transition.json b/probe/src/test/resources/transition-packs/boundary-tree/dimensions/transition.json new file mode 100644 index 000000000..ae9846d8b --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-tree/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Boundary tree transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/boundary-tree/generators/flat.json b/probe/src/test/resources/transition-packs/boundary-tree/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-tree/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/boundary-tree/objects/trees/boundary.iob b/probe/src/test/resources/transition-packs/boundary-tree/objects/trees/boundary.iob new file mode 100644 index 0000000000000000000000000000000000000000..04fc39132b09a49c185214d3fcab8c59bc953b5f GIT binary patch literal 382 zcmZ9{JqyAx5C-5&zXumbw}SWw9F;C^o!neXkswiu+DcQUKVAD3Q}fcngS#gXa=am; zIT6{q8O`D{li^_<#{1otELz;NT%1aNt~Ld~9&g1Z;dLgXI;GX$mEbQSlZPl}CM%Iw zQB^)gs<$rHTTfl;0~^@F4)$<>$~aAilLg1jV>t$Lp$B~!7vn(mxFV literal 0 HcmV?d00001 diff --git a/probe/src/test/resources/transition-packs/boundary-tree/regions/terrain.json b/probe/src/test/resources/transition-packs/boundary-tree/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/boundary-tree/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/calcite-mountain/biomes/terrain.json b/probe/src/test/resources/transition-packs/calcite-mountain/biomes/terrain.json new file mode 100644 index 000000000..12c138b53 --- /dev/null +++ b/probe/src/test/resources/transition-packs/calcite-mountain/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:calcite" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 64, + "max": 64 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/calcite-mountain/dimensions/transition.json b/probe/src/test/resources/transition-packs/calcite-mountain/dimensions/transition.json new file mode 100644 index 000000000..cfdf30b35 --- /dev/null +++ b/probe/src/test/resources/transition-packs/calcite-mountain/dimensions/transition.json @@ -0,0 +1,28 @@ +{ + "name": "Mountain transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + }, + "rockPalette": { + "palette": [ + { + "block": "minecraft:calcite" + } + ] + }, + "bedrock": false +} diff --git a/probe/src/test/resources/transition-packs/calcite-mountain/generators/flat.json b/probe/src/test/resources/transition-packs/calcite-mountain/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/calcite-mountain/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/calcite-mountain/regions/terrain.json b/probe/src/test/resources/transition-packs/calcite-mountain/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/calcite-mountain/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/cave-lowland/biomes/terrain.json b/probe/src/test/resources/transition-packs/cave-lowland/biomes/terrain.json new file mode 100644 index 000000000..aef04d871 --- /dev/null +++ b/probe/src/test/resources/transition-packs/cave-lowland/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 32, + "max": 32 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/cave-lowland/dimensions/transition.json b/probe/src/test/resources/transition-packs/cave-lowland/dimensions/transition.json new file mode 100644 index 000000000..5c3bfb61f --- /dev/null +++ b/probe/src/test/resources/transition-packs/cave-lowland/dimensions/transition.json @@ -0,0 +1,46 @@ +{ + "name": "Lowland transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": true, + "verticalRange": { + "min": 32, + "max": 47 + }, + "verticalEdgeFade": 0, + "baseDensityStyle": { + "style": "FLAT" + }, + "detailWeight": 0, + "densityThreshold": { + "min": 1, + "max": 1 + }, + "thresholdBias": 0, + "adaptiveSampling": false, + "allowSurfaceBreak": false, + "allowFluid": false, + "allowLava": false + }, + "rockPalette": { + "palette": [ + { + "block": "minecraft:stone" + } + ] + }, + "bedrock": false +} diff --git a/probe/src/test/resources/transition-packs/cave-lowland/generators/flat.json b/probe/src/test/resources/transition-packs/cave-lowland/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/cave-lowland/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/cave-lowland/regions/terrain.json b/probe/src/test/resources/transition-packs/cave-lowland/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/cave-lowland/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/granite-lowland/biomes/terrain.json b/probe/src/test/resources/transition-packs/granite-lowland/biomes/terrain.json new file mode 100644 index 000000000..8fd9260ae --- /dev/null +++ b/probe/src/test/resources/transition-packs/granite-lowland/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:granite" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 32, + "max": 32 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/granite-lowland/dimensions/transition.json b/probe/src/test/resources/transition-packs/granite-lowland/dimensions/transition.json new file mode 100644 index 000000000..ae937aea3 --- /dev/null +++ b/probe/src/test/resources/transition-packs/granite-lowland/dimensions/transition.json @@ -0,0 +1,28 @@ +{ + "name": "Lowland transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + }, + "rockPalette": { + "palette": [ + { + "block": "minecraft:granite" + } + ] + }, + "bedrock": false +} diff --git a/probe/src/test/resources/transition-packs/granite-lowland/generators/flat.json b/probe/src/test/resources/transition-packs/granite-lowland/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/granite-lowland/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/granite-lowland/regions/terrain.json b/probe/src/test/resources/transition-packs/granite-lowland/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/granite-lowland/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/lowland/biomes/terrain.json b/probe/src/test/resources/transition-packs/lowland/biomes/terrain.json new file mode 100644 index 000000000..aef04d871 --- /dev/null +++ b/probe/src/test/resources/transition-packs/lowland/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 32, + "max": 32 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/lowland/dimensions/transition.json b/probe/src/test/resources/transition-packs/lowland/dimensions/transition.json new file mode 100644 index 000000000..5e1a92dd5 --- /dev/null +++ b/probe/src/test/resources/transition-packs/lowland/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Lowland transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/lowland/generators/flat.json b/probe/src/test/resources/transition-packs/lowland/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/lowland/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/lowland/regions/terrain.json b/probe/src/test/resources/transition-packs/lowland/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/lowland/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/mountain/biomes/terrain.json b/probe/src/test/resources/transition-packs/mountain/biomes/terrain.json new file mode 100644 index 000000000..42801e70c --- /dev/null +++ b/probe/src/test/resources/transition-packs/mountain/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 64, + "max": 64 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/mountain/dimensions/transition.json b/probe/src/test/resources/transition-packs/mountain/dimensions/transition.json new file mode 100644 index 000000000..75471f60a --- /dev/null +++ b/probe/src/test/resources/transition-packs/mountain/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Mountain transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/mountain/generators/flat.json b/probe/src/test/resources/transition-packs/mountain/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/mountain/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/mountain/regions/terrain.json b/probe/src/test/resources/transition-packs/mountain/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/mountain/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean-highwater/biomes/terrain.json b/probe/src/test/resources/transition-packs/ocean-highwater/biomes/terrain.json new file mode 100644 index 000000000..f193715d7 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean-highwater/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": -24, + "max": -24 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean-highwater/dimensions/transition.json b/probe/src/test/resources/transition-packs/ocean-highwater/dimensions/transition.json new file mode 100644 index 000000000..9d608c9a3 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean-highwater/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Ocean-highwater transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 48, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/ocean-highwater/generators/flat.json b/probe/src/test/resources/transition-packs/ocean-highwater/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean-highwater/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean-highwater/regions/terrain.json b/probe/src/test/resources/transition-packs/ocean-highwater/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean-highwater/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean/biomes/terrain.json b/probe/src/test/resources/transition-packs/ocean/biomes/terrain.json new file mode 100644 index 000000000..2fb2d693a --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": -16, + "max": -16 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean/dimensions/transition.json b/probe/src/test/resources/transition-packs/ocean/dimensions/transition.json new file mode 100644 index 000000000..eeee1ef95 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Ocean transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/ocean/generators/flat.json b/probe/src/test/resources/transition-packs/ocean/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ocean/regions/terrain.json b/probe/src/test/resources/transition-packs/ocean/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/ocean/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/ripple-mountain/biomes/terrain.json b/probe/src/test/resources/transition-packs/ripple-mountain/biomes/terrain.json new file mode 100644 index 000000000..6efb34def --- /dev/null +++ b/probe/src/test/resources/transition-packs/ripple-mountain/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 48, + "max": 80 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ripple-mountain/dimensions/transition.json b/probe/src/test/resources/transition-packs/ripple-mountain/dimensions/transition.json new file mode 100644 index 000000000..75471f60a --- /dev/null +++ b/probe/src/test/resources/transition-packs/ripple-mountain/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Mountain transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/ripple-mountain/expressions/ripple.json b/probe/src/test/resources/transition-packs/ripple-mountain/expressions/ripple.json new file mode 100644 index 000000000..e5c708b9f --- /dev/null +++ b/probe/src/test/resources/transition-packs/ripple-mountain/expressions/ripple.json @@ -0,0 +1,3 @@ +{ + "expression": "0.5 + 0.125 * sin(x / 2)" +} diff --git a/probe/src/test/resources/transition-packs/ripple-mountain/generators/flat.json b/probe/src/test/resources/transition-packs/ripple-mountain/generators/flat.json new file mode 100644 index 000000000..bb5526c34 --- /dev/null +++ b/probe/src/test/resources/transition-packs/ripple-mountain/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "expression": "ripple" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/ripple-mountain/regions/terrain.json b/probe/src/test/resources/transition-packs/ripple-mountain/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/ripple-mountain/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-lowland/biomes/terrain.json b/probe/src/test/resources/transition-packs/slope-lowland/biomes/terrain.json new file mode 100644 index 000000000..d7bdddac8 --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-lowland/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 8, + "max": 40 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-lowland/dimensions/transition.json b/probe/src/test/resources/transition-packs/slope-lowland/dimensions/transition.json new file mode 100644 index 000000000..5e1a92dd5 --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-lowland/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Lowland transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/slope-lowland/expressions/slope.json b/probe/src/test/resources/transition-packs/slope-lowland/expressions/slope.json new file mode 100644 index 000000000..8990fd9d3 --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-lowland/expressions/slope.json @@ -0,0 +1,3 @@ +{ + "expression": "0.5 + 0.25 * sin(x / 64) + 0.125 * sin(y / 48)" +} diff --git a/probe/src/test/resources/transition-packs/slope-lowland/generators/flat.json b/probe/src/test/resources/transition-packs/slope-lowland/generators/flat.json new file mode 100644 index 000000000..1e040201e --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-lowland/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "expression": "slope" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-lowland/regions/terrain.json b/probe/src/test/resources/transition-packs/slope-lowland/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-lowland/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-mountain/biomes/terrain.json b/probe/src/test/resources/transition-packs/slope-mountain/biomes/terrain.json new file mode 100644 index 000000000..a3a9e94fe --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-mountain/biomes/terrain.json @@ -0,0 +1,21 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 40, + "max": 72 + } + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-mountain/dimensions/transition.json b/probe/src/test/resources/transition-packs/slope-mountain/dimensions/transition.json new file mode 100644 index 000000000..75471f60a --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-mountain/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Mountain transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 128 + }, + "logicalHeight": 128, + "fluidHeight": 40, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/slope-mountain/expressions/slope.json b/probe/src/test/resources/transition-packs/slope-mountain/expressions/slope.json new file mode 100644 index 000000000..5c0c33b0e --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-mountain/expressions/slope.json @@ -0,0 +1,3 @@ +{ + "expression": "0.5 + 0.30 * sin(x / 40 + 1) + 0.10 * sin(y / 48) + 0.03 * sin(x / 5)" +} diff --git a/probe/src/test/resources/transition-packs/slope-mountain/generators/flat.json b/probe/src/test/resources/transition-packs/slope-mountain/generators/flat.json new file mode 100644 index 000000000..1e040201e --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-mountain/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "expression": "slope" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/slope-mountain/regions/terrain.json b/probe/src/test/resources/transition-packs/slope-mountain/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/slope-mountain/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/volumetric-lowland/biomes/terrain.json b/probe/src/test/resources/transition-packs/volumetric-lowland/biomes/terrain.json new file mode 100644 index 000000000..acf05dce6 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-lowland/biomes/terrain.json @@ -0,0 +1,31 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 96, + "max": 96 + } + ], + "terrain3D": { + "amplitude": 64, + "horizontalScale": 96, + "verticalScale": 16, + "minimumSlope": 0, + "slopeFade": 1, + "fluidClearance": 0, + "fluidFade": 1, + "crackDepth": 0 + } +} diff --git a/probe/src/test/resources/transition-packs/volumetric-lowland/dimensions/transition.json b/probe/src/test/resources/transition-packs/volumetric-lowland/dimensions/transition.json new file mode 100644 index 000000000..8ca03e460 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-lowland/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Lowland transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 256 + }, + "logicalHeight": 256, + "fluidHeight": 0, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/volumetric-lowland/generators/flat.json b/probe/src/test/resources/transition-packs/volumetric-lowland/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-lowland/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/volumetric-lowland/regions/terrain.json b/probe/src/test/resources/transition-packs/volumetric-lowland/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-lowland/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +} diff --git a/probe/src/test/resources/transition-packs/volumetric-mountain/biomes/terrain.json b/probe/src/test/resources/transition-packs/volumetric-mountain/biomes/terrain.json new file mode 100644 index 000000000..2bace62f0 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-mountain/biomes/terrain.json @@ -0,0 +1,31 @@ +{ + "name": "Terrain", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 128, + "max": 128 + } + ], + "terrain3D": { + "amplitude": 64, + "horizontalScale": 96, + "verticalScale": 16, + "minimumSlope": 0, + "slopeFade": 1, + "fluidClearance": 0, + "fluidFade": 1, + "crackDepth": 0 + } +} diff --git a/probe/src/test/resources/transition-packs/volumetric-mountain/dimensions/transition.json b/probe/src/test/resources/transition-packs/volumetric-mountain/dimensions/transition.json new file mode 100644 index 000000000..978e9a178 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-mountain/dimensions/transition.json @@ -0,0 +1,20 @@ +{ + "name": "Mountain transition fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "terrain" + ], + "environment": "NORMAL", + "dimensionHeight": { + "min": 0, + "max": 256 + }, + "logicalHeight": 256, + "fluidHeight": 0, + "caveProfile": { + "enabled": false + } +} diff --git a/probe/src/test/resources/transition-packs/volumetric-mountain/generators/flat.json b/probe/src/test/resources/transition-packs/volumetric-mountain/generators/flat.json new file mode 100644 index 000000000..3c7cfc722 --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-mountain/generators/flat.json @@ -0,0 +1,15 @@ +{ + "seed": 310, + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/probe/src/test/resources/transition-packs/volumetric-mountain/regions/terrain.json b/probe/src/test/resources/transition-packs/volumetric-mountain/regions/terrain.json new file mode 100644 index 000000000..5a992923f --- /dev/null +++ b/probe/src/test/resources/transition-packs/volumetric-mountain/regions/terrain.json @@ -0,0 +1,12 @@ +{ + "name": "Terrain", + "landBiomes": [ + "terrain" + ], + "seaBiomes": [ + "terrain" + ], + "shoreBiomes": [ + "terrain" + ] +}