Lock-free scopes and caches

- History coordinate scopes read the router and routing flag lock-free
- Subterrain planner shares bounded plan and chunk candidate caches
- Cave blend roll uses a stateless hash identical to the parallel RNG
- Custom biome resource keys are cached per dimension and biome identity
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-21 19:15:01 -04:00
1 parent e2430500cf
commit 7282a46a26
10 files changed
+391 -77

No files matched your search

@@ -177,7 +177,7 @@ public class IrisEngine implements Engine {
volatile GenerationHistoryRuntimeRouter generationHistoryRuntimeRouter; volatile GenerationHistoryRuntimeRouter generationHistoryRuntimeRouter;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
private boolean generationHistoryRoutingRequired; private volatile boolean generationHistoryRoutingRequired;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
volatile EngineTarget publishedTarget; volatile EngineTarget publishedTarget;
@@ -1011,14 +1011,8 @@ public class IrisEngine implements Engine {
} }
public Optional<GenerationHistoryRuntimeRouter> getGenerationHistoryRuntimeRouter() { public Optional<GenerationHistoryRuntimeRouter> 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( public GenerationHistoryRuntimeRouter.CoordinateScope openGenerationHistoryCoordinateScope(
int blockX, int blockX,
@@ -1028,13 +1022,14 @@ public class IrisEngine implements Engine {
if (router != null) { if (router != null) {
return router.openCoordinateScope(blockX, blockZ); return router.openCoordinateScope(blockX, blockZ);
} }
synchronized (generationHistoryRuntimeRouterLock) { if (!generationHistoryRoutingRequired) {
return null;
}
router = generationHistoryRuntimeRouter; router = generationHistoryRuntimeRouter;
if (router == null && generationHistoryRoutingRequired) { if (router == null) {
throw new IllegalStateException("Iris generation-history runtime router is detached."); throw new IllegalStateException("Iris generation-history runtime router is detached.");
} }
} return router.openCoordinateScope(blockX, blockZ);
return router == null ? null : router.openCoordinateScope(blockX, blockZ);
} }
public boolean hasGenerationRuntimeScope() { public boolean hasGenerationRuntimeScope() {
@@ -79,15 +79,20 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
private static final int CAVE_BIOME_BLEND_CENTER_WEIGHT = 4; private static final int CAVE_BIOME_BLEND_CENTER_WEIGHT = 4;
private static final int CAVE_BIOME_BLEND_TOTAL_WEIGHT = 8; private static final int CAVE_BIOME_BLEND_TOTAL_WEIGHT = 8;
private static final int SUBMERGED_FLOOR_SUBSTRATE_DEPTH = 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 static final MatterCavern BASIC_CAVERN = new MatterCavern(true, "", (byte) 0);
private final RNG rng; private final RNG rng;
private final long caveBlendSeed;
private final NativeBlockState AIR = B.getState("CAVE_AIR"); private final NativeBlockState AIR = B.getState("CAVE_AIR");
private final NativeBlockState LAVA = B.getState("LAVA"); private final NativeBlockState LAVA = B.getState("LAVA");
private final IrisDecorantActuator decorant; private final IrisDecorantActuator decorant;
public IrisCarveModifier(Engine engine) { public IrisCarveModifier(Engine engine) {
super(engine, "Carve"); super(engine, "Carve");
rng = new RNG(getEngine().getSeedManager().getCarve()); caveBlendSeed = getEngine().getSeedManager().getCarve();
rng = new RNG(caveBlendSeed);
decorant = new IrisDecorantActuator(engine); decorant = new IrisDecorantActuator(engine);
} }
@@ -1233,7 +1238,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
return null; 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) { if (roll < CAVE_BIOME_BLEND_CENTER_WEIGHT) {
return center; return center;
} }
@@ -1247,6 +1252,11 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
return neighbor != null ? neighbor : center; 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<IrisBiome> caveBiomeCache, int x, int y, int z, CaveInputs resolverState) { private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache, int x, int y, int z, CaveInputs resolverState) {
long key = BlockPosition.toLong(x, y, z); long key = BlockPosition.toLong(x, y, z);
IrisBiome cachedBiome = caveBiomeCache.get(key); IrisBiome cachedBiome = caveBiomeCache.get(key);
@@ -6,14 +6,21 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.function.Predicate; import java.util.function.Predicate;
public final class SubterrainPlanner { public final class SubterrainPlanner {
private static final int PLAN_CACHE_SLOTS = 4096;
private static final int TILE_CACHE_SLOTS = 1024;
private static final Comparator<SubterrainPlan> PLAN_ORDER =
Comparator.comparingInt(SubterrainPlan::priority).reversed().thenComparing(SubterrainPlan::id);
private final List<Definition> definitions; private final List<Definition> definitions;
private final long seed; private final long seed;
private final int minY; private final int minY;
private final int maxYExclusive; private final int maxYExclusive;
private final ThreadLocal<CandidateTile> sampleTile = new ThreadLocal<>(); private final AtomicReferenceArray<CachedPlan> planCache = new AtomicReferenceArray<>(PLAN_CACHE_SLOTS);
private final AtomicReferenceArray<CandidateTile> tileCache = new AtomicReferenceArray<>(TILE_CACHE_SLOTS);
private final Predicate<SubterrainBounds> allowedBounds; private final Predicate<SubterrainBounds> allowedBounds;
private final RegionResolver regionResolver; private final RegionResolver regionResolver;
@@ -70,8 +77,9 @@ public final class SubterrainPlanner {
if (minX > maxX || minZ > maxZ) { if (minX > maxX || minZ > maxZ) {
throw new IllegalArgumentException("Subterrain query bounds are inverted"); throw new IllegalArgumentException("Subterrain query bounds are inverted");
} }
List<SubterrainPlan> result = new ArrayList<>(); List<SubterrainPlan> result = null;
for (Definition definition : definitions) { for (int definitionIndex = 0; definitionIndex < definitions.size(); definitionIndex++) {
Definition definition = definitions.get(definitionIndex);
long firstX = Math.floorDiv(minX, definition.spacing()) - 1L; long firstX = Math.floorDiv(minX, definition.spacing()) - 1L;
long lastX = Math.floorDiv(maxX, definition.spacing()) + 1L; long lastX = Math.floorDiv(maxX, definition.spacing()) + 1L;
long firstZ = Math.floorDiv(minZ, 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 gridX = firstX; gridX <= lastX; gridX++) {
for (long gridZ = firstZ; gridZ <= lastZ; gridZ++) { 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 (plan != null && plan.bounds().intersects(minX, minZ, maxX, maxZ)) {
if (result == null) {
result = new ArrayList<>();
}
result.add(plan); 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); return List.copyOf(result);
} }
@@ -96,20 +110,7 @@ public final class SubterrainPlanner {
if (isEmpty() || y < minY || y >= maxYExclusive) { if (isEmpty() || y < minY || y >= maxYExclusive) {
return SubterrainCell.OUTSIDE; return SubterrainCell.OUTSIDE;
} }
int chunkX = Math.floorDiv(x, 16); return sample(candidates(Math.floorDiv(x, 16), Math.floorDiv(z, 16)), x, y, z);
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);
} }
public SubterrainCell sample(List<SubterrainPlan> candidates, int x, int y, int z) { public SubterrainCell sample(List<SubterrainPlan> candidates, int x, int y, int z) {
@@ -127,10 +128,38 @@ public final class SubterrainPlanner {
return selected; return selected;
} }
private List<SubterrainPlan> 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<SubterrainPlan> 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<SubterrainPlan> candidates, int x, int y, int z) { private SubterrainCell raw(List<SubterrainPlan> candidates, int x, int y, int z) {
SubterrainCell selected = SubterrainCell.OUTSIDE; SubterrainCell selected = SubterrainCell.OUTSIDE;
for (SubterrainPlan plan : candidates) { for (int index = 0; index < candidates.size(); index++) {
SubterrainCell cell = plan.sample(x, y, z); SubterrainCell cell = candidates.get(index).sample(x, y, z);
if (cell.solid() && cell.room().reservedSolid()) { if (cell.solid() && cell.room().reservedSolid()) {
return cell; return cell;
} }
@@ -212,6 +241,9 @@ public final class SubterrainPlanner {
private record CandidateTile(int chunkX, int chunkZ, List<SubterrainPlan> plans) { private record CandidateTile(int chunkX, int chunkZ, List<SubterrainPlan> 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, 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 length, int radius, int height, double shapeWarp, int fluidDepth, int chimneyHeight, int terraceCount,
int pillarSpacing, double formationFraction, String solid, int priority, int pillarSpacing, double formationFraction, String solid, int priority,
@@ -109,6 +109,7 @@ import java.util.concurrent.ConcurrentHashMap;
@Data @Data
public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private static final int CUSTOM_BIOME_KEY_CACHE_LIMIT = 4096;
private static final Map<File, IrisData> dataLoaders = new ConcurrentHashMap<>(); private static final Map<File, IrisData> dataLoaders = new ConcurrentHashMap<>();
// Loaders (cached or detached) that currently have registered engines; see hasActiveEngines. // 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 // 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) @Setter(AccessLevel.NONE)
@EqualsAndHashCode.Exclude @EqualsAndHashCode.Exclude
private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver; private transient volatile GenerationRegistryContractFactory.CustomBiomeResourceResolver customBiomeResourceResolver;
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
@EqualsAndHashCode.Exclude
private transient volatile CustomBiomeKeyCache customBiomeKeyCache;
private transient volatile Engine soleEngine; private transient volatile Engine soleEngine;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE) @Setter(AccessLevel.NONE)
@@ -234,6 +239,32 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome"); IrisBiomeCustom requiredBiome = Objects.requireNonNull(customBiome, "customBiome");
GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver = customBiomeResourceResolver; 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) { if (resolver != null) {
try { try {
return resolver.resolve( return resolver.resolve(
@@ -248,7 +279,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
return GenerationRegistryContractFactory.customBiomeResourceKey( return GenerationRegistryContractFactory.customBiomeResourceKey(
requiredDimension.getLoadKey(), requiredDimension.getLoadKey(),
requiredBiome, requiredBiome,
getContentGate() gate
); );
} }
@@ -1035,4 +1066,29 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
return immutable; return immutable;
} }
} }
private record CustomBiomeKeyCache(Object owner, ConcurrentHashMap<CustomBiomeKey, String> 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;
}
}
} }
@@ -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.SavedBiomeRuntime;
import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.testsupport.Await;
import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.iris.testsupport.PlatformBinding;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -37,13 +36,11 @@ import java.io.IOException;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.util.Collections; import java.util.Collections;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask; import java.util.concurrent.FutureTask;
@@ -680,30 +677,19 @@ public class IrisEngineGenerationRuntimeScopeTest {
} }
@Test @Test
public void missingRouterReadWaitsForAttachmentPublication() throws Exception { public void missingRouterReadsDoNotWaitForTheAttachmentMonitor() throws Exception {
RuntimeFixture active = runtime(1, 1D, 1D, 1D); RuntimeFixture active = runtime(1, 1D, 1D, 1D);
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
GenerationHistoryRuntimeRouter router = historyRouter(engine); ExecutorService reader = Executors.newSingleThreadExecutor();
GenerationHistoryRuntimeRouter.CoordinateScope scope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
when(router.openCoordinateScope(-17, 32)).thenReturn(scope);
CountDownLatch started = new CountDownLatch(1);
FutureTask<GenerationHistoryRuntimeRouter.CoordinateScope> read = new FutureTask<>(() -> {
started.countDown();
return engine.openGenerationHistoryCoordinateScope(-17, 32);
});
Thread reader = new Thread(read, "Pending history attachment");
try { try {
synchronized (engine.generationHistoryRuntimeRouterLock) { synchronized (engine.generationHistoryRuntimeRouterLock) {
reader.start(); assertNull(reader.submit(() -> engine.openGenerationHistoryCoordinateScope(-17, 32))
assertTrue(started.await(5, TimeUnit.SECONDS)); .get(5, TimeUnit.SECONDS));
awaitBlocked(reader); assertTrue(reader.submit(engine::getGenerationHistoryRuntimeRouter).get(5, TimeUnit.SECONDS).isEmpty());
engine.attachGenerationHistoryRuntimeRouter(router);
} }
assertSame(scope, read.get(5, TimeUnit.SECONDS));
} finally { } finally {
reader.join(5_000L); reader.shutdownNow();
assertTrue("Pending router read did not finish", !reader.isAlive()); assertTrue(reader.awaitTermination(5, TimeUnit.SECONDS));
} }
} }
@@ -713,27 +699,19 @@ public class IrisEngineGenerationRuntimeScopeTest {
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
GenerationHistoryRuntimeRouter router = historyRouter(engine); GenerationHistoryRuntimeRouter router = historyRouter(engine);
engine.attachGenerationHistoryRuntimeRouter(router); engine.attachGenerationHistoryRuntimeRouter(router);
CountDownLatch started = new CountDownLatch(1); engine.detachGenerationHistoryRuntimeRouter(router);
FutureTask<GenerationHistoryRuntimeRouter.CoordinateScope> read = new FutureTask<>(() -> { ExecutorService reader = Executors.newSingleThreadExecutor();
started.countDown();
return engine.openGenerationHistoryCoordinateScope(-17, 32);
});
Thread reader = new Thread(read, "Detached history read");
try { try {
synchronized (engine.generationHistoryRuntimeRouterLock) { synchronized (engine.generationHistoryRuntimeRouterLock) {
engine.detachGenerationHistoryRuntimeRouter(router); ExecutionException failure = assertThrows(ExecutionException.class, () -> reader.submit(
reader.start(); () -> engine.openGenerationHistoryCoordinateScope(-17, 32)).get(5, TimeUnit.SECONDS));
assertTrue(started.await(5, TimeUnit.SECONDS));
awaitBlocked(reader);
}
ExecutionException failure = assertThrows(ExecutionException.class, () -> read.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof IllegalStateException); assertTrue(failure.getCause() instanceof IllegalStateException);
assertEquals("Iris generation-history runtime router is detached.", failure.getCause().getMessage()); assertEquals("Iris generation-history runtime router is detached.", failure.getCause().getMessage());
}
assertTrue(engine.getGenerationHistoryRuntimeRouter().isEmpty()); assertTrue(engine.getGenerationHistoryRuntimeRouter().isEmpty());
} finally { } finally {
reader.join(5_000L); reader.shutdownNow();
assertTrue("Detached router read did not finish", !reader.isAlive()); assertTrue(reader.awaitTermination(5, TimeUnit.SECONDS));
} }
verify(router, never()).openCoordinateScope(anyInt(), anyInt()); verify(router, never()).openCoordinateScope(anyInt(), anyInt());
} }
@@ -909,12 +887,6 @@ public class IrisEngineGenerationRuntimeScopeTest {
return router; 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( private static IrisEngine engine(
GenerationRuntime generationRuntime, GenerationRuntime generationRuntime,
EngineEffects effects, EngineEffects effects,
@@ -132,6 +132,9 @@ public class IrisCarveModifierBiomeBlendTest {
Field field = IrisCarveModifier.class.getDeclaredField("rng"); Field field = IrisCarveModifier.class.getDeclaredField("rng");
field.setAccessible(true); field.setAccessible(true);
field.set(modifier, rng); field.set(modifier, rng);
Field seed = IrisCarveModifier.class.getDeclaredField("caveBlendSeed");
seed.setAccessible(true);
seed.setLong(modifier, SEED);
return modifier; return modifier;
} }
@@ -56,6 +56,9 @@ public class IrisCarveModifierBoundarySupportTest {
Field rng = IrisCarveModifier.class.getDeclaredField("rng"); Field rng = IrisCarveModifier.class.getDeclaredField("rng");
rng.setAccessible(true); rng.setAccessible(true);
rng.set(modifier, new RNG(71L)); rng.set(modifier, new RNG(71L));
Field seed = IrisCarveModifier.class.getDeclaredField("caveBlendSeed");
seed.setAccessible(true);
seed.setLong(modifier, 71L);
IrisMatterSupport.ensureRegistered(); IrisMatterSupport.ensureRegistered();
Matter floorMatter = new IrisMatter(16, 16, 16); Matter floorMatter = new IrisMatter(16, 16, 16);
@@ -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);
};
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.generation.subterrain; package art.arcane.iris.generation.subterrain;
import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.value.IrisRange;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonParseException; import com.google.gson.JsonParseException;
import org.junit.Test; import org.junit.Test;
@@ -10,6 +11,11 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.HashSet; 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.assertEquals;
import static org.junit.Assert.assertFalse; 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<IrisSubterrainFeature> 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<Future<Integer>> 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<Integer> result : results) {
occupied += result.get(60, TimeUnit.SECONDS);
}
assertTrue(occupied > 0);
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS));
}
}
@Test @Test
public void immutableSnapshotsAndSeedsControlPlans() { public void immutableSnapshotsAndSeedsControlPlans() {
IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE); IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE);
@@ -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<IrisBiomeCustom> customs = data.getBiomeLoader().load("test").getCustomDerivitives();
assertEquals(2, customs.size());
List<String> 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<Future<List<String>>> results = new ArrayList<>();
for (int thread = 0; thread < 8; thread++) {
results.add(pool.submit(() -> {
List<String> 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<List<String>> 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);
}
}