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 2db689d42..709d50b7c 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 @@ -177,7 +177,7 @@ public class IrisEngine implements Engine { volatile GenerationHistoryRuntimeRouter generationHistoryRuntimeRouter; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) - private boolean generationHistoryRoutingRequired; + private volatile boolean generationHistoryRoutingRequired; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) volatile EngineTarget publishedTarget; @@ -1011,13 +1011,7 @@ public class IrisEngine implements Engine { } public Optional getGenerationHistoryRuntimeRouter() { - GenerationHistoryRuntimeRouter router = generationHistoryRuntimeRouter; - if (router != null) { - return Optional.of(router); - } - synchronized (generationHistoryRuntimeRouterLock) { - return Optional.ofNullable(generationHistoryRuntimeRouter); - } + return Optional.ofNullable(generationHistoryRuntimeRouter); } public GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope( @@ -1028,13 +1022,14 @@ public class IrisEngine implements Engine { if (router != null) { return router.openCoordinateScope(blockX, blockZ); } - synchronized (generationHistoryRuntimeRouterLock) { - router = generationHistoryRuntimeRouter; - if (router == null && generationHistoryRoutingRequired) { - throw new IllegalStateException("Iris generation-history runtime router is detached."); - } + if (!generationHistoryRoutingRequired) { + return null; } - return router == null ? null : router.openCoordinateScope(blockX, blockZ); + router = generationHistoryRuntimeRouter; + if (router == null) { + throw new IllegalStateException("Iris generation-history runtime router is detached."); + } + return router.openCoordinateScope(blockX, blockZ); } public boolean hasGenerationRuntimeScope() { diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index 97a1f2ec9..ced2742a0 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -79,15 +79,20 @@ public class IrisCarveModifier extends EngineAssignedModifier private static final int CAVE_BIOME_BLEND_CENTER_WEIGHT = 4; private static final int CAVE_BIOME_BLEND_TOTAL_WEIGHT = 8; private static final int SUBMERGED_FLOOR_SUBSTRATE_DEPTH = 8; + private static final long RANDOM_MULTIPLIER = 0x5DEECE66DL; + private static final long RANDOM_ADDEND = 0xBL; + private static final long RANDOM_MASK = (1L << 48) - 1; private static final MatterCavern BASIC_CAVERN = new MatterCavern(true, "", (byte) 0); private final RNG rng; + private final long caveBlendSeed; private final NativeBlockState AIR = B.getState("CAVE_AIR"); private final NativeBlockState LAVA = B.getState("LAVA"); private final IrisDecorantActuator decorant; public IrisCarveModifier(Engine engine) { super(engine, "Carve"); - rng = new RNG(getEngine().getSeedManager().getCarve()); + caveBlendSeed = getEngine().getSeedManager().getCarve(); + rng = new RNG(caveBlendSeed); decorant = new IrisDecorantActuator(engine); } @@ -1233,7 +1238,7 @@ public class IrisCarveModifier extends EngineAssignedModifier return null; } - int roll = Math.floorMod(rng.nextParallelRNG(BlockPosition.toLong(x, y, z)).nextInt(), CAVE_BIOME_BLEND_TOTAL_WEIGHT); + int roll = Math.floorMod(caveBlendRandom(caveBlendSeed, x, y, z), CAVE_BIOME_BLEND_TOTAL_WEIGHT); if (roll < CAVE_BIOME_BLEND_CENTER_WEIGHT) { return center; } @@ -1247,6 +1252,11 @@ public class IrisCarveModifier extends EngineAssignedModifier return neighbor != null ? neighbor : center; } + static int caveBlendRandom(long seed, int x, int y, int z) { + long scrambled = ((seed + BlockPosition.toLong(x, y, z)) ^ RANDOM_MULTIPLIER) & RANDOM_MASK; + return (int) (((scrambled * RANDOM_MULTIPLIER + RANDOM_ADDEND) & RANDOM_MASK) >>> 16); + } + private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, CaveInputs resolverState) { long key = BlockPosition.toLong(x, y, z); IrisBiome cachedBiome = caveBiomeCache.get(key); diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java index be1920c7c..03548a3d8 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java @@ -6,14 +6,21 @@ import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.atomic.AtomicReferenceArray; import java.util.function.Predicate; public final class SubterrainPlanner { + private static final int PLAN_CACHE_SLOTS = 4096; + private static final int TILE_CACHE_SLOTS = 1024; + private static final Comparator PLAN_ORDER = + Comparator.comparingInt(SubterrainPlan::priority).reversed().thenComparing(SubterrainPlan::id); + private final List definitions; private final long seed; private final int minY; private final int maxYExclusive; - private final ThreadLocal sampleTile = new ThreadLocal<>(); + private final AtomicReferenceArray planCache = new AtomicReferenceArray<>(PLAN_CACHE_SLOTS); + private final AtomicReferenceArray tileCache = new AtomicReferenceArray<>(TILE_CACHE_SLOTS); private final Predicate allowedBounds; private final RegionResolver regionResolver; @@ -70,8 +77,9 @@ public final class SubterrainPlanner { if (minX > maxX || minZ > maxZ) { throw new IllegalArgumentException("Subterrain query bounds are inverted"); } - List result = new ArrayList<>(); - for (Definition definition : definitions) { + List result = null; + for (int definitionIndex = 0; definitionIndex < definitions.size(); definitionIndex++) { + Definition definition = definitions.get(definitionIndex); long firstX = Math.floorDiv(minX, definition.spacing()) - 1L; long lastX = Math.floorDiv(maxX, definition.spacing()) + 1L; long firstZ = Math.floorDiv(minZ, definition.spacing()) - 1L; @@ -81,14 +89,20 @@ public final class SubterrainPlanner { } for (long gridX = firstX; gridX <= lastX; gridX++) { for (long gridZ = firstZ; gridZ <= lastZ; gridZ++) { - SubterrainPlan plan = create(definition, gridX, gridZ); + SubterrainPlan plan = plan(definitionIndex, definition, gridX, gridZ); if (plan != null && plan.bounds().intersects(minX, minZ, maxX, maxZ)) { + if (result == null) { + result = new ArrayList<>(); + } result.add(plan); } } } } - result.sort(Comparator.comparingInt(SubterrainPlan::priority).reversed().thenComparing(SubterrainPlan::id)); + if (result == null) { + return List.of(); + } + result.sort(PLAN_ORDER); return List.copyOf(result); } @@ -96,20 +110,7 @@ public final class SubterrainPlanner { if (isEmpty() || y < minY || y >= maxYExclusive) { return SubterrainCell.OUTSIDE; } - int chunkX = Math.floorDiv(x, 16); - int chunkZ = Math.floorDiv(z, 16); - CandidateTile tile = sampleTile.get(); - if (tile == null || tile.chunkX() != chunkX || tile.chunkZ() != chunkZ) { - long baseX = (long) chunkX * 16; - long baseZ = (long) chunkZ * 16; - tile = new CandidateTile(chunkX, chunkZ, plansForBounds( - (int) Math.max(Integer.MIN_VALUE, baseX - 1), - (int) Math.max(Integer.MIN_VALUE, baseZ - 1), - (int) Math.min(Integer.MAX_VALUE, baseX + 16), - (int) Math.min(Integer.MAX_VALUE, baseZ + 16))); - sampleTile.set(tile); - } - return sample(tile.plans(), x, y, z); + return sample(candidates(Math.floorDiv(x, 16), Math.floorDiv(z, 16)), x, y, z); } public SubterrainCell sample(List candidates, int x, int y, int z) { @@ -127,10 +128,38 @@ public final class SubterrainPlanner { return selected; } + private List candidates(int chunkX, int chunkZ) { + int slot = (int) mix(((long) chunkX << 32) ^ (chunkZ & 0xffffffffL)) & (TILE_CACHE_SLOTS - 1); + CandidateTile tile = tileCache.getAcquire(slot); + if (tile != null && tile.chunkX() == chunkX && tile.chunkZ() == chunkZ) { + return tile.plans(); + } + long baseX = (long) chunkX * 16; + long baseZ = (long) chunkZ * 16; + List plans = plansForBounds( + (int) Math.max(Integer.MIN_VALUE, baseX - 1), + (int) Math.max(Integer.MIN_VALUE, baseZ - 1), + (int) Math.min(Integer.MAX_VALUE, baseX + 16), + (int) Math.min(Integer.MAX_VALUE, baseZ + 16)); + tileCache.setRelease(slot, new CandidateTile(chunkX, chunkZ, plans)); + return plans; + } + + private SubterrainPlan plan(int definitionIndex, Definition definition, long gridX, long gridZ) { + int slot = (int) mix(mix(gridX ^ definition.seedSalt()) ^ Long.rotateLeft(gridZ, 29)) & (PLAN_CACHE_SLOTS - 1); + CachedPlan cached = planCache.getAcquire(slot); + if (cached != null && cached.definition() == definitionIndex && cached.gridX() == gridX && cached.gridZ() == gridZ) { + return cached.plan(); + } + SubterrainPlan plan = create(definition, gridX, gridZ); + planCache.setRelease(slot, new CachedPlan(definitionIndex, gridX, gridZ, plan)); + return plan; + } + private SubterrainCell raw(List candidates, int x, int y, int z) { SubterrainCell selected = SubterrainCell.OUTSIDE; - for (SubterrainPlan plan : candidates) { - SubterrainCell cell = plan.sample(x, y, z); + for (int index = 0; index < candidates.size(); index++) { + SubterrainCell cell = candidates.get(index).sample(x, y, z); if (cell.solid() && cell.room().reservedSolid()) { return cell; } @@ -212,6 +241,9 @@ public final class SubterrainPlanner { private record CandidateTile(int chunkX, int chunkZ, List plans) { } + private record CachedPlan(int definition, long gridX, long gridZ, SubterrainPlan plan) { + } + record Definition(String id, IrisSubterrainFamily family, IrisSubterrainFluid fluid, String biome, int spacing, double probability, int length, int radius, int height, double shapeWarp, int fluidDepth, int chimneyHeight, int terraceCount, int pillarSpacing, double formationFraction, String solid, int priority, diff --git a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java index d7f2bd104..8188eb058 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java @@ -109,6 +109,7 @@ import java.util.concurrent.ConcurrentHashMap; @Data public class IrisData implements ExclusionStrategy, TypeAdapterFactory { + private static final int CUSTOM_BIOME_KEY_CACHE_LIMIT = 4096; private static final Map dataLoaders = new ConcurrentHashMap<>(); // Loaders (cached or detached) that currently have registered engines; see hasActiveEngines. // Identity-keyed on purpose: Lombok's @Data hashCode over this class's mutable loader state @@ -159,6 +160,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { @Setter(AccessLevel.NONE) @EqualsAndHashCode.Exclude private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + @EqualsAndHashCode.Exclude + private transient volatile CustomBiomeKeyCache customBiomeKeyCache; private transient volatile Engine soleEngine; @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @@ -234,6 +239,32 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver = customBiomeResourceResolver; + ContentGate gate = resolver == null ? getContentGate() : null; + Object owner = resolver == null ? gate : resolver; + CustomBiomeKeyCache cache = customBiomeKeyCache; + if (cache == null || cache.owner() != owner) { + cache = new CustomBiomeKeyCache(owner, new ConcurrentHashMap<>()); + customBiomeKeyCache = cache; + } + CustomBiomeKey key = new CustomBiomeKey(requiredDimension.getLoadKey(), requiredBiome); + String cached = cache.keys().get(key); + if (cached != null) { + return cached; + } + String resolved = resolveCustomBiomeResourceKey(requiredDimension, requiredBiome, resolver, gate); + if (cache.keys().size() >= CUSTOM_BIOME_KEY_CACHE_LIMIT) { + cache.keys().clear(); + } + cache.keys().put(key, resolved); + return resolved; + } + + private String resolveCustomBiomeResourceKey( + IrisDimension requiredDimension, + IrisBiomeCustom requiredBiome, + GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver, + ContentGate gate + ) { if (resolver != null) { try { return resolver.resolve( @@ -248,7 +279,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return GenerationRegistryContractFactory.customBiomeResourceKey( requiredDimension.getLoadKey(), requiredBiome, - getContentGate() + gate ); } @@ -1035,4 +1066,29 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return immutable; } } + + private record CustomBiomeKeyCache(Object owner, ConcurrentHashMap keys) { + } + + private static final class CustomBiomeKey { + private final String dimensionKey; + private final IrisBiomeCustom biome; + private final int hash; + + private CustomBiomeKey(String dimensionKey, IrisBiomeCustom biome) { + this.dimensionKey = dimensionKey; + this.biome = biome; + hash = 31 * Objects.hashCode(dimensionKey) + System.identityHashCode(biome); + } + + @Override + public boolean equals(Object other) { + return other instanceof CustomBiomeKey key && key.biome == biome && Objects.equals(key.dimensionKey, dimensionKey); + } + + @Override + public int hashCode() { + return hash; + } + } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index 2e351e5c1..9b0f9177b 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -8,7 +8,6 @@ import art.arcane.iris.generation.runtime.GenerationRuntime.BiomeMaxes; import art.arcane.iris.world.history.SavedBiomeRuntime; import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.terrain.IrisRegion; -import art.arcane.iris.testsupport.Await; import art.arcane.iris.testsupport.PlatformBinding; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -37,13 +36,11 @@ import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.file.Path; -import java.time.Duration; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; @@ -680,30 +677,19 @@ public class IrisEngineGenerationRuntimeScopeTest { } @Test - public void missingRouterReadWaitsForAttachmentPublication() throws Exception { + public void missingRouterReadsDoNotWaitForTheAttachmentMonitor() throws Exception { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); - GenerationHistoryRuntimeRouter router = historyRouter(engine); - GenerationHistoryRuntimeRouter.CoordinateScope scope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class); - when(router.openCoordinateScope(-17, 32)).thenReturn(scope); - CountDownLatch started = new CountDownLatch(1); - FutureTask read = new FutureTask<>(() -> { - started.countDown(); - return engine.openGenerationHistoryCoordinateScope(-17, 32); - }); - Thread reader = new Thread(read, "Pending history attachment"); - + ExecutorService reader = Executors.newSingleThreadExecutor(); try { synchronized (engine.generationHistoryRuntimeRouterLock) { - reader.start(); - assertTrue(started.await(5, TimeUnit.SECONDS)); - awaitBlocked(reader); - engine.attachGenerationHistoryRuntimeRouter(router); + assertNull(reader.submit(() -> engine.openGenerationHistoryCoordinateScope(-17, 32)) + .get(5, TimeUnit.SECONDS)); + assertTrue(reader.submit(engine::getGenerationHistoryRuntimeRouter).get(5, TimeUnit.SECONDS).isEmpty()); } - assertSame(scope, read.get(5, TimeUnit.SECONDS)); } finally { - reader.join(5_000L); - assertTrue("Pending router read did not finish", !reader.isAlive()); + reader.shutdownNow(); + assertTrue(reader.awaitTermination(5, TimeUnit.SECONDS)); } } @@ -713,27 +699,19 @@ public class IrisEngineGenerationRuntimeScopeTest { IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); - CountDownLatch started = new CountDownLatch(1); - FutureTask read = new FutureTask<>(() -> { - started.countDown(); - return engine.openGenerationHistoryCoordinateScope(-17, 32); - }); - Thread reader = new Thread(read, "Detached history read"); - + engine.detachGenerationHistoryRuntimeRouter(router); + ExecutorService reader = Executors.newSingleThreadExecutor(); try { synchronized (engine.generationHistoryRuntimeRouterLock) { - engine.detachGenerationHistoryRuntimeRouter(router); - reader.start(); - assertTrue(started.await(5, TimeUnit.SECONDS)); - awaitBlocked(reader); + ExecutionException failure = assertThrows(ExecutionException.class, () -> reader.submit( + () -> engine.openGenerationHistoryCoordinateScope(-17, 32)).get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals("Iris generation-history runtime router is detached.", failure.getCause().getMessage()); } - ExecutionException failure = assertThrows(ExecutionException.class, () -> read.get(5, TimeUnit.SECONDS)); - assertTrue(failure.getCause() instanceof IllegalStateException); - assertEquals("Iris generation-history runtime router is detached.", failure.getCause().getMessage()); assertTrue(engine.getGenerationHistoryRuntimeRouter().isEmpty()); } finally { - reader.join(5_000L); - assertTrue("Detached router read did not finish", !reader.isAlive()); + reader.shutdownNow(); + assertTrue(reader.awaitTermination(5, TimeUnit.SECONDS)); } verify(router, never()).openCoordinateScope(anyInt(), anyInt()); } @@ -909,12 +887,6 @@ public class IrisEngineGenerationRuntimeScopeTest { return router; } - private static void awaitBlocked(Thread thread) { - Await.reached("the reader to block on the engine lifecycle lock", Duration.ofSeconds(5L), - () -> thread.getState() == Thread.State.BLOCKED || !thread.isAlive()); - assertEquals(Thread.State.BLOCKED, thread.getState()); - } - private static IrisEngine engine( GenerationRuntime generationRuntime, EngineEffects effects, diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java index 3a12db6f7..d0ab77646 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBiomeBlendTest.java @@ -132,6 +132,9 @@ public class IrisCarveModifierBiomeBlendTest { Field field = IrisCarveModifier.class.getDeclaredField("rng"); field.setAccessible(true); field.set(modifier, rng); + Field seed = IrisCarveModifier.class.getDeclaredField("caveBlendSeed"); + seed.setAccessible(true); + seed.setLong(modifier, SEED); return modifier; } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java index e0bbe9760..11b96dbce 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java @@ -56,6 +56,9 @@ public class IrisCarveModifierBoundarySupportTest { Field rng = IrisCarveModifier.class.getDeclaredField("rng"); rng.setAccessible(true); rng.set(modifier, new RNG(71L)); + Field seed = IrisCarveModifier.class.getDeclaredField("caveBlendSeed"); + seed.setAccessible(true); + seed.setLong(modifier, 71L); IrisMatterSupport.ensureRegistered(); Matter floorMatter = new IrisMatter(16, 16, 16); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveBlendRollTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveBlendRollTest.java new file mode 100644 index 000000000..fc018ff8c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierCaveBlendRollTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.SplittableRandom; + +import static org.junit.Assert.assertEquals; + +public class IrisCarveModifierCaveBlendRollTest { + private static final int SAMPLES = 1_000_000; + + @Test + public void statelessBlendRandomMatchesParallelRngForOneMillionPoints() { + SplittableRandom random = new SplittableRandom(0x5eed_cafe_f00dL); + long[] seeds = {0L, 1L, -1L, 1337L, Long.MIN_VALUE, Long.MAX_VALUE, 0x5DEECE66DL, random.nextLong()}; + for (int sample = 0; sample < SAMPLES; sample++) { + long seed = sample < seeds.length * 64 ? seeds[sample % seeds.length] : random.nextLong(); + int x = coordinate(random, sample); + int y = sample % 7 == 0 ? random.nextInt() : random.nextInt(-2048, 2048); + int z = coordinate(random, sample + 3); + assertEquals(seed + " @ " + x + "," + y + "," + z, + new RNG(seed).nextParallelRNG(BlockPosition.toLong(x, y, z)).nextInt(), + IrisCarveModifier.caveBlendRandom(seed, x, y, z)); + } + } + + @Test + public void statelessBlendRandomMatchesParallelRngAtCoordinateExtremes() { + int[] edges = {Integer.MIN_VALUE, Integer.MIN_VALUE + 1, -33_554_433, -33_554_432, -1, 0, 1, + 33_554_431, 33_554_432, Integer.MAX_VALUE - 1, Integer.MAX_VALUE}; + long[] seeds = {0L, -1L, 1337L, Long.MIN_VALUE, Long.MAX_VALUE}; + for (long seed : seeds) { + for (int x : edges) { + for (int y : edges) { + for (int z : edges) { + assertEquals(new RNG(seed).nextParallelRNG(BlockPosition.toLong(x, y, z)).nextInt(), + IrisCarveModifier.caveBlendRandom(seed, x, y, z)); + } + } + } + } + } + + private static int coordinate(SplittableRandom random, int sample) { + return switch (sample % 4) { + case 0 -> random.nextInt(); + case 1 -> random.nextInt(-30_000_000, 30_000_000); + default -> random.nextInt(-65_536, 65_536); + }; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java index 43c8b2378..6cb0f2535 100644 --- a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.subterrain; import art.arcane.iris.pack.value.IrisRange; +import art.arcane.volmlib.util.collection.KList; import com.google.gson.Gson; import com.google.gson.JsonParseException; import org.junit.Test; @@ -10,6 +11,11 @@ import java.util.Collections; import java.util.List; import java.util.Set; import java.util.HashSet; +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.assertFalse; @@ -441,6 +447,74 @@ public class SubterrainPlannerTest { } } + @Test + public void alternatingChunkSamplingResolvesEachCandidateCellOnce() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setAllowedRegions(new KList<>("test-region")); + AtomicInteger regionQueries = new AtomicInteger(); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97, + (x, z) -> { + regionQueries.incrementAndGet(); + return "test-region"; + })); + for (int round = 0; round < 500; round++) { + planner.sample(7, 10, 7); + planner.sample(23, 10, 7); + planner.sample(7, 10, 23); + planner.sample(-9, 10, -9); + } + int firstRound = regionQueries.get(); + assertTrue("candidate cells resolved " + firstRound + " times", firstRound <= 16); + for (int round = 0; round < 500; round++) { + planner.plansForBounds(-1, -1, 16, 16); + planner.plansForBounds(15, 15, 32, 32); + } + assertEquals(firstRound, regionQueries.get()); + } + + @Test + public void concurrentCrossChunkSamplingMatchesAFreshPlanner() throws Exception { + List features = List.of(compactFeature(IrisSubterrainFamily.CENOTE).setSpacing(96), + compactFeature(IrisSubterrainFamily.TRAVERTINE_TERRACES).setSpacing(160)); + SubterrainPlanner shared = new SubterrainPlanner(new SubterrainPlanner.Options(features, 4242, -64, 97, (x, z) -> "test-region")); + SubterrainPlanner reference = new SubterrainPlanner(new SubterrainPlanner.Options(features, 4242, -64, 97, (x, z) -> "test-region")); + int threads = 8; + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + List> results = new ArrayList<>(); + for (int thread = 0; thread < threads; thread++) { + int offset = thread; + results.add(pool.submit(() -> { + int occupied = 0; + for (int step = 0; step < 6000; step++) { + int x = Math.floorMod(step * 37 + offset * 101, 2400) - 1200; + int z = Math.floorMod(step * 53 + offset * 211, 2400) - 1200; + int y = Math.floorMod(step * 11 + offset, 150) - 60; + int chunkX = Math.floorDiv(x, 16) * 16; + int chunkZ = Math.floorDiv(z, 16) * 16; + SubterrainCell expected; + synchronized (reference) { + expected = reference.sample(reference.plansForBounds(chunkX - 1, chunkZ - 1, chunkX + 16, chunkZ + 16), x, y, z); + } + SubterrainCell actual = shared.sample(x, y, z); + assertEquals(expected, actual); + if (actual.occupied()) { + occupied++; + } + } + return occupied; + })); + } + int occupied = 0; + for (Future result : results) { + occupied += result.get(60, TimeUnit.SECONDS); + } + assertTrue(occupied > 0); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + } + } + @Test public void immutableSnapshotsAndSeedsControlPlans() { IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE); diff --git a/core/src/test/java/art/arcane/iris/pack/loading/IrisDataCustomBiomeKeyTest.java b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataCustomBiomeKeyTest.java new file mode 100644 index 000000000..09924af9a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataCustomBiomeKeyTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.pack.loading; + +import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.testsupport.DurabilityMode; +import art.arcane.iris.world.history.GenerationRegistryContractFactory; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +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.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class IrisDataCustomBiomeKeyTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void repeatedConcurrentLookupsMatchTheContentAddressedKeyPerDimension() throws Exception { + Path pack = temporaryFolder.newFolder("custom-keys").toPath().resolve("iris/pack"); + writePack(pack); + IrisData data = IrisData.openDatapackCompiler(pack.toFile()); + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + IrisDimension overworld = data.getDimensionLoader().load("overworld"); + IrisDimension second = data.getDimensionLoader().load("second"); + List customs = data.getBiomeLoader().load("test").getCustomDerivitives(); + assertEquals(2, customs.size()); + List expected = new ArrayList<>(); + for (IrisDimension dimension : List.of(overworld, second)) { + for (IrisBiomeCustom custom : customs) { + expected.add(GenerationRegistryContractFactory.customBiomeResourceKey( + dimension.getLoadKey(), custom, data.getContentGate())); + } + } + assertEquals(4, expected.stream().distinct().count()); + List>> results = new ArrayList<>(); + for (int thread = 0; thread < 8; thread++) { + results.add(pool.submit(() -> { + List seen = new ArrayList<>(); + for (int round = 0; round < 200; round++) { + seen.clear(); + for (IrisDimension dimension : List.of(overworld, second)) { + for (IrisBiomeCustom custom : customs) { + seen.add(data.customBiomeResourceKey(dimension, custom)); + } + } + } + return List.copyOf(seen); + })); + } + for (Future> result : results) { + assertEquals(expected, result.get(60, TimeUnit.SECONDS)); + } + IrisBiomeCustom equalCopy = new IrisBiomeCustom().setId(customs.getFirst().getId()); + assertEquals(GenerationRegistryContractFactory.customBiomeResourceKey( + overworld.getLoadKey(), equalCopy, data.getContentGate()), + data.customBiomeResourceKey(overworld, equalCopy)); + assertNotEquals(data.customBiomeResourceKey(overworld, customs.getFirst()), + data.customBiomeResourceKey(second, customs.getFirst())); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS)); + data.close(); + } + } + + private static void writePack(Path root) throws Exception { + Files.createDirectories(root.resolve("dimensions")); + Files.createDirectories(root.resolve("biomes")); + for (String dimension : List.of("overworld", "second")) { + Files.writeString(root.resolve("dimensions/" + dimension + ".json"), """ + { + "name": "Test Dimension", + "environment": "NORMAL", + "logicalHeight": 256, + "dimensionHeight": { + "min": -64, + "max": 320 + } + } + """, StandardCharsets.UTF_8); + } + Files.writeString(root.resolve("biomes/test.json"), """ + { + "name": "Test Biome", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "customDerivitives": [ + { + "id": "first_custom" + }, + { + "id": "second_custom", + "temperature": 1.5 + } + ] + } + """, StandardCharsets.UTF_8); + } +}