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 74deeedc8..a57ca67b3 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 @@ -125,7 +125,7 @@ public class IrisComplex implements DataProvider { private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int STUDIO_NOISE_CACHE_SIZE = 32_768; private static final int TERRAIN_COLUMN_CACHE_SIZE = 524_288; - /** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ + /** One million corners: about 20 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20; /** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */ private static final double SLOPE_RUN = 3D; @@ -148,7 +148,7 @@ public class IrisComplex implements DataProvider { private final transient ThreadLocal gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); @Getter(AccessLevel.NONE) private final int biomeBoundsSamplingStep; - private transient volatile SharedCornerBounds sharedCornerBounds = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); + private transient volatile SharedCornerBounds sharedCornerBounds; @Getter(AccessLevel.NONE) private final transient IrisInterpolator[] frozenInterpolators; @Getter(AccessLevel.NONE) @@ -407,11 +407,13 @@ public class IrisComplex implements DataProvider { GenerationStreams.cache2D(bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode), "baseBiomeStream", engine, cacheSize); baseBiomeStream = focusedBiomes != null || !imageMapRuntime.has(IrisImageMapApplication.BIOME) - ? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream - .convertAware2D((biome, x, z) -> { + ? proceduralBaseBiomeStream : GenerationStreams.cache2D(ProceduralStream.of((x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); - return mapped == null ? biome : mapped; - }), "imageMappedBaseBiomeStream", engine, cacheSize); + return mapped == null ? proceduralBaseBiomeStream.get(x, z) : mapped; + }, (x, y, z) -> { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null ? proceduralBaseBiomeStream.get(x, y, z) : mapped; + }, proceduralBaseBiomeStream), "imageMappedBaseBiomeStream", engine, cacheSize); proceduralTerrainHeight = new ProceduralTerrainHeightSampler( (x, z) -> getHeight(engine, x, z, engine.getSeedManager().getHeight()), engine.getDimension().getTerrainSamplingStep()); @@ -441,14 +443,9 @@ public class IrisComplex implements DataProvider { : 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, - transitionFluidHeight(x, z, fluidHeight)) - : mapped; - }), "naturalTrueBiomeStream", engine, cacheSize); + naturalTrueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "naturalTrueBiomeStream-focus", engine, cacheSize) + : GenerationStreams.cache2D(ProceduralStream.of(this::sampleNaturalTrueBiome, this::sampleNaturalTrueBiome, + Interpolated.of(value -> 0D, value -> null)), "naturalTrueBiomeStream", engine, cacheSize); IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); if (hydrologyActive(configuredHydrology)) { hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( @@ -477,10 +474,8 @@ public class IrisComplex implements DataProvider { Math.max(4096, cacheSize), data, banks3D)); } } - cachedHeightStream = new CachedDoubleStream2D("heightStream", engine, - ProceduralStream.ofDouble(this::resolveHydrologyTerrainHeight), cacheSize); - heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread() - ? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z)); + cachedHeightStream = new TerrainHeightStream(engine, cacheSize); + heightStream = cachedHeightStream; roundedHeighteightStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); slopeStream = GenerationStreams.contextInjecting(heightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) @@ -1038,6 +1033,22 @@ public class IrisComplex implements DataProvider { return sample.terrainHeight(); } + private IrisBiome sampleNaturalTrueBiome(double x, double z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(naturalHeightStream.getDouble(x, z), baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, + transitionFluidHeight(x, z, fluidHeight)) + : mapped; + } + + private IrisBiome sampleNaturalTrueBiome(double x, double y, double z) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + return mapped == null + ? fixBiomeType(naturalHeightStream.getDouble(x, y, z), baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, + transitionFluidHeight(x, z, fluidHeight)) + : mapped; + } + private double nonblockingTerrainHeight(double x, double z) { HydrologyColumnSnapshot snapshot = sampleHydrologySnapshot(blockCoordinate(x), blockCoordinate(z)); if (!snapshot.available() || snapshot.column() == null) { @@ -1693,12 +1704,17 @@ public class IrisComplex implements DataProvider { return packed; } - /** The field initializer covers every real complex; an instance built without it gets one here. */ + /** Coarse bounds allocate their shared table on the first corner sample. */ private SharedCornerBounds sharedCornerBounds() { SharedCornerBounds shared = sharedCornerBounds; if (shared == null) { - shared = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); - sharedCornerBounds = shared; + synchronized (this) { + shared = sharedCornerBounds; + if (shared == null) { + shared = new SharedCornerBounds(SHARED_CORNER_BOUNDS_CAPACITY); + sharedCornerBounds = shared; + } + } } return shared; } @@ -2214,6 +2230,62 @@ public class IrisComplex implements DataProvider { } } + private final class TerrainHeightStream extends CachedDoubleStream2D { + private TerrainHeightStream(Engine engine, int cacheSize) { + super("heightStream", engine, ProceduralStream.ofDouble(IrisComplex.this::resolveHydrologyTerrainHeight), cacheSize); + } + + @Override + public double getDouble(double x, double z) { + return terrainEngine.getPlatformHooks().isMainThread() + ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + + @Override + public Double get(double x, double y, double z) { + return getDouble(x, z); + } + + @Override + public double getDouble(double x, double y, double z) { + return getDouble(x, z); + } + + @Override + public void fillChunkDoubles(int worldX, int worldZ, double[] target) { + boolean mainThread = terrainEngine.getPlatformHooks().isMainThread(); + if (!mainThread && (worldX & 15) == 0 && (worldZ & 15) == 0) { + super.fillChunkDoubles(worldX, worldZ, target); + return; + } + if (target == null || target.length != 256) { + throw new IllegalArgumentException("Expected a 16x16 target array."); + } + for (int index = 0; index < 256; index++) { + int x = worldX + (index & 15); + int z = worldZ + (index >> 4); + target[index] = mainThread ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + } + + @Override + public void fillChunkRaw(int worldX, int worldZ, Object[] target) { + boolean mainThread = terrainEngine.getPlatformHooks().isMainThread(); + if (!mainThread && (worldX & 15) == 0 && (worldZ & 15) == 0) { + super.fillChunkRaw(worldX, worldZ, target); + return; + } + if (target == null || target.length != 256) { + throw new IllegalArgumentException("Expected a 16x16 target array."); + } + for (int index = 0; index < 256; index++) { + int x = worldX + (index & 15); + int z = worldZ + (index >> 4); + target[index] = mainThread ? nonblockingTerrainHeight(x, z) : super.getDouble(x, z); + } + } + } + static final class ChildSelectionPlan { private final IrisRaritySelection selection; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java index 1888580dc..944635e63 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBiomeBuffetTest.java @@ -84,12 +84,14 @@ public class IrisComplexBiomeBuffetTest { IrisComplex complex = new IrisComplex(engine); doReturn(complex).when(engine).getComplex(); try { + assertNull(complex.getSharedCornerBounds()); assertEquals("alpha", complex.getTrueBiomeStream().get(15, 0).getLoadKey()); assertEquals("beta", complex.getTrueBiomeStream().get(16, 0).getLoadKey()); assertEquals("gamma", complex.getTrueBiomeStream().get(0, 16).getLoadKey()); assertEquals(137D, complex.getBaseTerrainHeightStream().getDouble(15, 0), 0D); assertEquals(147D, complex.getBaseTerrainHeightStream().getDouble(16, 0), 0D); assertEquals(157D, complex.getBaseTerrainHeightStream().getDouble(0, 16), 0D); + assertNull(complex.getSharedCornerBounds()); assertEquals("owned", complex.getRegionStream().get(16, 0).getLoadKey()); assertEquals("iris-focus/", complex.getRegionStream().get(0, 16).getLoadKey().substring(0, 11)); assertEquals("alpha", dimension.getFocus()); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java index d061bf79e..7efa384c0 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexGridBoundsCacheTest.java @@ -13,20 +13,52 @@ import org.junit.runners.Parameterized.Parameters; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; @RunWith(Enclosed.class) public class IrisComplexGridBoundsCacheTest { public static class CornerCache { + @Test(timeout = 10_000L) + public void concurrentFirstSamplesShareOneCornerTable() throws Exception { + IrisComplex complex = createComplex(); + Method sharedCache = IrisComplex.class.getDeclaredMethod("sharedCornerBounds"); + sharedCache.setAccessible(true); + CountDownLatch entered = new CountDownLatch(4); + CountDownLatch release = new CountDownLatch(1); + try (ExecutorService readers = Executors.newFixedThreadPool(4)) { + List> results = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + results.add(readers.submit(() -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return (SharedCornerBounds) sharedCache.invoke(complex); + })); + } + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + release.countDown(); + SharedCornerBounds expected = results.getFirst().get(5L, TimeUnit.SECONDS); + for (Future result : results) { + assertSame(expected, result.get(5L, TimeUnit.SECONDS)); + } + } + } + @Test public void gridBoundsCacheIsIsolatedPerComplex() throws Exception { IrisComplex first = createComplex(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java new file mode 100644 index 000000000..1e0dbbac0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java @@ -0,0 +1,304 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkedDoubleDataCache; +import art.arcane.iris.generation.hydrology.HydrologyColumnSample; +import art.arcane.iris.generation.hydrology.HydrologyColumnSnapshot; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.generation.image.IrisImageMapApplication; +import art.arcane.iris.generation.image.IrisImageMapRuntime; +import art.arcane.iris.generation.stream.ChunkFillableDoubleStream2D; +import art.arcane.iris.generation.stream.ChunkFillableStream2D; +import art.arcane.iris.generation.stream.ProvisionalSampling; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisComplexHeightStreamTest { + @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisSettings previousSettings; + + @Before + public void prepareRuntime() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + } + + @After + public void releaseRuntime() { + IrisPlatforms.unbind(); + IrisServices.remove(PreservationRegistry.class); + IrisSettings.settings = previousSettings; + } + + @Test + public void sharedCornersRemainUnallocatedUntilTerrainSamplesThem() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + assertNull(fixture.complex.getSharedCornerBounds()); + fixture.complex.getBaseTerrainHeightStream().getDouble(0, 0); + assertNotNull(fixture.complex.getSharedCornerBounds()); + } + } + + @Test + public void mappedBiomesSkipProceduralSelectionAndNaturalHeight() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + IrisBiome mapped = new IrisBiome(); + when(fixture.images.sampleBiome(anyDouble(), anyDouble())).thenReturn(mapped); + MeteredCache bridge = (MeteredCache) fixture.complex.getBridgeStream(); + MeteredCache regions = (MeteredCache) fixture.complex.getRegionStream(); + MeteredCache heights = (MeteredCache) fixture.complex.getNaturalHeightStream(); + assertSame(mapped, fixture.complex.getBaseBiomeStream().get(-16, 32)); + assertSame(mapped, fixture.complex.getBaseBiomeStream().get(-16.75, 10, 32.5)); + assertSame(mapped, fixture.complex.getNaturalTrueBiomeStream().get(-16, 32)); + assertSame(mapped, fixture.complex.getNaturalTrueBiomeStream().get(-16.75, 10, 32.5)); + assertEquals(0L, bridge.getSize()); + assertEquals(0L, regions.getSize()); + assertEquals(0L, heights.getSize()); + assertNull(fixture.complex.getSharedCornerBounds()); + + when(fixture.images.sampleBiome(anyDouble(), anyDouble())).thenReturn(null); + assertEquals("plain", fixture.complex.getBaseBiomeStream().get(16, 32).getLoadKey()); + assertEquals("plain", fixture.complex.getNaturalTrueBiomeStream().get(16, 32).getLoadKey()); + assertNotNull(fixture.complex.getSharedCornerBounds()); + } + } + + @Test + public void workerPrefillUsesOneThreadCheckAndRetainsRoundedValues() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + AtomicInteger samples = fixture.installNaturalHeight(); + ChunkedDoubleDataCache cache = new ChunkedDoubleDataCache(fixture.complex.getRawHeightStream(), -16, 32); + int[] rounded = new int[256]; + clearInvocations(fixture.hooks); + cache.fillRounded(rounded); + verify(fixture.hooks, times(1)).isMainThread(); + assertEquals(256, samples.get()); + for (int index = 0; index < 256; index++) { + int x = index & 15; + int z = index >> 4; + double expected = height(-16 + x, 32 + z); + assertBits(expected, cache.getDouble(x, z)); + assertEquals((int) Math.round(expected), rounded[index]); + } + clearInvocations(fixture.hooks); + cache.fill(); + verify(fixture.hooks, times(1)).isMainThread(); + assertEquals(256, samples.get()); + } + } + + @Test + public void mainThreadPrefillNeverWaitsOrConsumesCachedHydrology() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + fixture.installNaturalHeight(); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + HydrologyColumnSample planned = mock(HydrologyColumnSample.class); + when(planned.terrainHeight()).thenReturn(100); + when(hydrology.sample(anyDouble(), anyDouble())).thenAnswer(invocation -> { + if (fixture.mainThread.get()) { + throw new AssertionError("Main-thread sampling entered blocking hydrology"); + } + return Optional.of(planned); + }); + when(hydrology.sampleSnapshot(anyInt(), anyInt())).thenAnswer(invocation -> { + ProvisionalSampling.mark(); + return HydrologyColumnSnapshot.unavailable(); + }); + fixture.complex.setHydrologyRuntime(hydrology); + ProceduralStream stream = fixture.complex.getRawHeightStream(); + ChunkFillableDoubleStream2D bulk = (ChunkFillableDoubleStream2D) stream; + double[] values = new double[256]; + fixture.mainThread.set(true); + assertThrows(ProvisionalSampling.Unmemoizable.class, () -> ProvisionalSampling.memoizable(() -> { + bulk.fillChunkDoubles(-16, 32, values); + return values[0]; + })); + assertBits(height(-16, 32), values[0]); + fixture.mainThread.set(false); + bulk.fillChunkDoubles(-16, 32, values); + assertBits(100D, values[0]); + assertBits(100D, values[255]); + verify(hydrology, times(256)).sample(anyDouble(), anyDouble()); + + fixture.mainThread.set(true); + bulk.fillChunkDoubles(-16, 32, values); + assertBits(height(-16, 32), values[0]); + assertBits(height(-16.75, 32.5), stream.getDouble(-16.75, 10, 32.5)); + assertBits(height(-16.75, 32.5), stream.get(-16.75, 10, 32.5)); + Object[] boxed = new Object[256]; + ((ChunkFillableStream2D) stream).fillChunkRaw(-16, 32, boxed); + assertBits(height(-1, 47), (Double) boxed[255]); + verify(hydrology, times(256)).sample(anyDouble(), anyDouble()); + } + } + + @Test + public void unalignedPrefillAndThreeDimensionalReadsRetainColumnCoordinates() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + fixture.installNaturalHeight(); + ProceduralStream stream = fixture.complex.getRawHeightStream(); + double[] values = new double[256]; + ((ChunkFillableDoubleStream2D) stream).fillChunkDoubles(-17, 31, values); + for (int index = 0; index < 256; index++) { + assertBits(height(-17 + (index & 15), 31 + (index >> 4)), values[index]); + } + assertBits(height(-16, 32), stream.getDouble(-16.75, 10, 32.5)); + assertBits(height(-16, 32), stream.get(-16.75, 10, 32.5)); + } + } + + @Test + public void workerPrefillDoesNotMemoizeProvisionalNaturalValues() throws Exception { + try (Fixture fixture = new Fixture(pack())) { + AtomicBoolean ready = new AtomicBoolean(); + AtomicInteger samples = new AtomicInteger(); + fixture.complex.setNaturalHeightStream(ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + if (!ready.get()) { + ProvisionalSampling.mark(); + return -1D; + } + return height(x, z); + })); + ChunkFillableDoubleStream2D stream = (ChunkFillableDoubleStream2D) fixture.complex.getRawHeightStream(); + double[] values = new double[256]; + stream.fillChunkDoubles(-16, 32, values); + for (double value : values) { + assertBits(-1D, value); + } + ready.set(true); + int before = samples.get(); + stream.fillChunkDoubles(-16, 32, values); + assertEquals(before + 256, samples.get()); + for (int index = 0; index < 256; index++) { + assertBits(height(-16 + (index & 15), 32 + (index >> 4)), values[index]); + } + stream.fillChunkDoubles(-16, 32, values); + assertEquals(before + 256, samples.get()); + } + } + + private Path pack() throws Exception { + Path pack = temporaryFolder.newFolder().toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("regions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("generators")); + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"regions":["plain"],"landChance":1, + "carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/plain.json"), "{\"landBiomes\":[\"plain\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + Files.writeString(pack.resolve("biomes/plain.json"), + "{\"generators\":[{\"generator\":\"flat\",\"min\":10,\"max\":10}]}"); + return pack; + } + + private static double height(double x, double z) { + return x * 0.125D - z * 0.03125D; + } + + private static void assertBits(double expected, double actual) { + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + + private static final class Fixture implements AutoCloseable { + private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + private final AtomicBoolean mainThread = new AtomicBoolean(); + private final MockedStatic blocks = mockStatic(B.class); + private final MockedStatic imageRuntimes = mockStatic(IrisImageMapRuntime.class); + private final IrisImageMapRuntime images = mock(IrisImageMapRuntime.class); + private final IrisData data; + private final IrisComplex complex; + + private Fixture(Path pack) { + NativeBlockState block = mock(NativeBlockState.class); + when(block.isFluid()).thenReturn(true); + blocks.when(() -> B.getState(anyString())).thenReturn(block); + blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block); + imageRuntimes.when(() -> IrisImageMapRuntime.compile(engine)).thenReturn(images); + when(images.has(IrisImageMapApplication.BIOME)).thenReturn(true); + when(images.sampleTerrainHeight(anyDouble(), anyDouble(), anyDouble())) + .thenAnswer(invocation -> invocation.getArgument(2)); + data = IrisData.openRuntime(pack.toFile()); + IrisDimension dimension = data.getDimensionLoader().load("main"); + doReturn(data).when(engine).getData(); + doReturn(dimension).when(engine).getDimension(); + doReturn(384).when(engine).getHeight(); + doReturn(-64).when(engine).getMinHeight(); + doReturn(320).when(engine).getMaxHeight(); + when(hooks.isMainThread()).thenAnswer(ignored -> mainThread.get()); + doReturn(hooks).when(engine).getPlatformHooks(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + complex = new IrisComplex(engine); + doReturn(complex).when(engine).getComplex(); + } + + private AtomicInteger installNaturalHeight() { + AtomicInteger samples = new AtomicInteger(); + complex.setNaturalHeightStream(ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + return height(x, z); + })); + return samples; + } + + @Override + public void close() { + try { + complex.close(); + data.close(); + } finally { + imageRuntimes.close(); + blocks.close(); + } + } + } +}