From 0dcd1900be817c1e8aa9f36bc8aca454feee779e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 7 Sep 2026 03:17:09 -0400 Subject: [PATCH] Improved world generation - Terrain and cave scans skip repeated work - River and structure lookups reuse cached results - Chunk history saves share disk writes - History lookups continue during disk writes - Failed saves retain their original errors - Deposit failures stop incomplete chunks - Tests check output and save safety --- .../NativeStructureVolumeIndexTest.java | 68 ++++ .../nativegen/NativeStructureVolumeIndex.java | 31 +- .../arcane/iris/engine/IrisEngineMantle.java | 53 +++ .../history/ChunkGenerationSemantics.java | 16 +- .../engine/history/GenerationHistory.java | 79 ++++- .../history/GenerationSemanticCapture.java | 14 +- .../history/GenerationSemanticIndex.java | 290 +++++++++++++--- .../engine/history/NativeTerrainReceipt.java | 54 ++- .../iris/engine/history/SavedBiomeStore.java | 173 +++++++++- .../hydrology/policy/RiverPolicyResolver.java | 46 ++- .../runtime/IrisHydrologyRuntime.java | 59 +++- .../iris/engine/mantle/EngineMantle.java | 4 + .../iris/engine/mantle/TerrainMatterView.java | 34 +- .../engine/modifier/IrisCarveModifier.java | 42 +-- .../engine/modifier/IrisDepositModifier.java | 75 ++++- .../iris/engine/object/CompatPools.java | 13 + .../object/IrisDimensionCarvingResolver.java | 37 ++- .../iris/engine/object/IrisRiverPolicy.java | 6 +- .../arcane/iris/util/project/noise/CNG.java | 2 - .../engine/IrisCarveWallPaletteScopeTest.java | 245 ++++++++++++++ .../IrisEngineGenerationRuntimeScopeTest.java | 55 +++ .../GenerationHistoryAdmissionTest.java | 218 +++++++++++- .../GenerationSemanticBatchWriteTest.java | 164 +++++++++ .../GenerationSemanticCaptureTest.java | 51 +++ .../GenerationSemanticJournalWriteTest.java | 276 +++++++++++++++ .../GenerationSemanticKeyValidationTest.java | 58 ++++ .../GenerationSemanticPointReadTest.java | 313 ++++++++++++++++++ .../history/NativeTerrainReceiptTest.java | 117 +++++++ .../history/SavedBiomeStoreBatchTest.java | 277 ++++++++++++++++ .../history/SavedBiomeStoreWriteTest.java | 8 +- .../runtime/IrisHydrologyPolicyCacheTest.java | 259 +++++++++++++++ .../mantle/EngineMantleCoverageTest.java | 227 +++++++++++++ .../mantle/TerrainComposedCavernTest.java | 151 +++++++++ .../engine/mantle/TerrainMatterViewTest.java | 116 +++++++ .../IrisCarveModifierBiomeBlendTest.java | 203 ++++++++++++ .../IrisCarveModifierBoundarySupportTest.java | 7 +- .../IrisDepositModifierContextTest.java | 72 +++- .../IrisDepositModifierParityTest.java | 223 +++++++++++++ .../modifier/IrisNaturalCaveScanTest.java | 239 +++++++++++++ ...risDimensionCarvingResolverParityTest.java | 38 ++- .../noise/CNGSignedStateMutationTest.java | 176 ++++++++++ 41 files changed, 4398 insertions(+), 191 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisCarveWallPaletteScopeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticBatchWriteTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticJournalWriteTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticKeyValidationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticPointReadTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreBatchTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyPolicyCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCoverageTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/TerrainComposedCavernTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierParityTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/project/noise/CNGSignedStateMutationTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java index 509b5b5f5..9625442ff 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeStructureVolume; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.volmlib.util.collection.KList; import net.minecraft.SharedConstants; import net.minecraft.core.HolderSet; @@ -24,6 +25,7 @@ import java.lang.reflect.Proxy; import java.lang.reflect.Field; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CopyOnWriteArraySet; @@ -39,8 +41,10 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; 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 NativeStructureVolumeIndexTest { private static final String STRUCTURE_KEY = "minecraft:swamp_hut"; @@ -114,6 +118,47 @@ public class NativeStructureVolumeIndexTest { assertEquals(867, resolver.resolutions()); } + @Test + public void pinnedRuntimeReusesCachedOriginsWithoutOpeningCoordinateScopes() throws Exception { + ScopedEngine scoped = scopedEngine(true, 1); + CountingResolver resolver = new CountingResolver(0, 0); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + + KList first = index.resolve(scoped.engine(), 0, 0, 15, 15); + assertEquals(289, scoped.scopes().get()); + index.resolve(scoped.engine(), 16, 0, 31, 15); + assertEquals(306, resolver.resolutions()); + assertEquals(306, scoped.scopes().get()); + assertEquals(first, index.resolve(scoped.engine(), 0, 0, 15, 15)); + assertEquals(306, scoped.scopes().get()); + } + + @Test + public void absentOrMismatchedRuntimeOwnershipKeepsCoordinateRouting() throws Exception { + ScopedEngine detached = scopedEngine(true, 1); + when(detached.engine().getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty()); + for (ScopedEngine scoped : List.of(scopedEngine(false, 1), scopedEngine(true, 0), + scopedEngine(true, 2), detached)) { + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(new CountingResolver(0, 0)); + index.resolve(scoped.engine(), 0, 0, 15, 15); + index.resolve(scoped.engine(), 16, 0, 31, 15); + assertEquals(578, scoped.scopes().get()); + } + } + + @Test + public void retiredPinnedOriginsRebuildThroughCoordinateScopes() throws Exception { + ScopedEngine scoped = scopedEngine(true, 1); + CountingResolver resolver = new CountingResolver(0, 0); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + + KList first = index.resolve(scoped.engine(), 0, 0, 15, 15); + index.evictRuntime(1); + assertEquals(first, index.resolve(scoped.engine(), 0, 0, 15, 15)); + assertEquals(578, resolver.resolutions()); + assertEquals(578, scoped.scopes().get()); + } + @Test public void invalidatedIndexReceivesRuntimeRetirementAndDetachesOnUninstall() throws Exception { IrisEngine engine = mock(IrisEngine.class); @@ -313,6 +358,27 @@ public class NativeStructureVolumeIndexTest { assertTrue(source.contains("queryCache.keySet().removeIf(key -> key.runtimeId() == runtimeId)")); } + private static ScopedEngine scopedEngine(boolean scoped, int ownerRuntimeId) throws Exception { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(engine.hasGenerationRuntimeScope()).thenReturn(scoped); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(engine.getCacheID()).thenReturn(1); + if (ownerRuntimeId != 0) { + IrisEngine.GenerationRuntimeBinding binding = mock(IrisEngine.GenerationRuntimeBinding.class); + when(binding.runtimeId()).thenReturn(ownerRuntimeId); + when(router.currentRuntimeOwnership()).thenReturn(Optional.of( + new GenerationHistoryRuntimeRouter.RuntimeOwnership(1L, binding))); + } + AtomicInteger scopes = new AtomicInteger(); + when(engine.openGenerationHistoryCoordinateScope(anyInt(), anyInt())).thenAnswer(invocation -> { + scopes.incrementAndGet(); + return null; + }); + return new ScopedEngine(engine, scopes); + } + + @SuppressWarnings("unchecked") private static Map installedIndexes() throws Exception { Field indexes = NativeStructureVolumeIndex.class.getDeclaredField("INDEXES"); @@ -439,4 +505,6 @@ public class NativeStructureVolumeIndexTest { 15, 255, 15)); } } + private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 0b7b8d2de..1ea867763 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -190,10 +190,11 @@ public final class NativeStructureVolumeIndex { int minZ = chunkZ << 4; int maxX = minX + 15; int maxZ = minZ + 15; + Integer pinnedRuntimeId = pinnedRuntimeId(engine); KList volumes = null; for (int originX = chunkX - ORIGIN_REACH_CHUNKS; originX <= chunkX + ORIGIN_REACH_CHUNKS; originX++) { for (int originZ = chunkZ - ORIGIN_REACH_CHUNKS; originZ <= chunkZ + ORIGIN_REACH_CHUNKS; originZ++) { - for (NativeStructureVolume volume : originVolumes(engine, originX, originZ)) { + for (NativeStructureVolume volume : scopedOriginVolumes(engine, originX, originZ, pinnedRuntimeId)) { if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { continue; } @@ -207,6 +208,34 @@ public final class NativeStructureVolumeIndex { return volumes == null ? NativeStructureVolume.NONE : volumes; } + private static Integer pinnedRuntimeId(Engine engine) { + if (!(engine instanceof IrisEngine irisEngine) || !irisEngine.hasGenerationRuntimeScope()) { + return null; + } + GenerationHistoryRuntimeRouter router = irisEngine.getGenerationHistoryRuntimeRouter().orElse(null); + if (router == null) { + return null; + } + GenerationHistoryRuntimeRouter.RuntimeOwnership ownership = router.currentRuntimeOwnership().orElse(null); + int runtimeId = runtimeId(engine); + return ownership != null && ownership.binding().runtimeId() == runtimeId ? runtimeId : null; + } + + private KList scopedOriginVolumes( + Engine engine, int chunkX, int chunkZ, Integer pinnedRuntimeId + ) { + if (pinnedRuntimeId != null) { + synchronized (originCache) { + KList cached = originCache.get( + new RuntimeChunkKey(pinnedRuntimeId, chunkKey(chunkX, chunkZ))); + if (cached != null) { + return cached; + } + } + } + return originVolumes(engine, chunkX, chunkZ); + } + static long originWindowStripeMask(int chunkX, int chunkZ) { int minCellX = Math.floorDiv(chunkX - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); int maxCellX = Math.floorDiv(chunkX + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index 37e2a55fe..10b5da58c 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -131,6 +131,26 @@ public class IrisEngineMantle implements EngineMantle { return getComponents().getLast().passChunkRadius(); } + @Override + public void cleanupChunksCoveredBy(int newRealX, int newRealZ, boolean force, ChunkCleanupCallback callback) { + Objects.requireNonNull(callback, "callback"); + int radius = Math.max(getRadius(), getRealRadius()); + MissingRealChunk missingChunk = radius > 0 ? new MissingRealChunk() : null; + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + int candidateX = newRealX + offsetX; + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + int candidateZ = newRealZ + offsetZ; + if (getMantle().hasFlag(candidateX, candidateZ, MantleFlag.CLEANED) + || !isCovered(candidateX, candidateZ, missingChunk)) { + continue; + } + if (cleanupCoveredChunk(candidateX, candidateZ, force)) { + callback.onChunkCleaned(candidateX, candidateZ); + } + } + } + } + @Override public List getComponents() { return componentsCache.aquire(() -> { @@ -228,6 +248,33 @@ public class IrisEngineMantle implements EngineMantle { return Objects.requireNonNull(storageDirectory, "mantle storage directory").toAbsolutePath().normalize(); } + private boolean isCovered(int x, int z, MissingRealChunk missingChunk) { + int radius = Math.max(getRadius(), getRealRadius()); + if (missingChunk != null && missingChunk.known + && Math.abs((long) missingChunk.x - x) <= radius + && Math.abs((long) missingChunk.z - z) <= radius) { + if (!getMantle().hasFlag(missingChunk.x, missingChunk.z, MantleFlag.REAL)) { + return false; + } + missingChunk.known = false; + } + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + int chunkX = x + offsetX; + int chunkZ = z + offsetZ; + if (!getMantle().hasFlag(chunkX, chunkZ, MantleFlag.REAL)) { + if (missingChunk != null) { + missingChunk.x = chunkX; + missingChunk.z = chunkZ; + missingChunk.known = true; + } + return false; + } + } + } + return true; + } + private static Mantle createMantle( Engine engine, Path storageDirectory, @@ -467,4 +514,10 @@ public class IrisEngineMantle implements EngineMantle { } }; } + + private static final class MissingRealChunk { + private int x; + private int z; + private boolean known; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java index 30c4393ed..62ad917b9 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java +++ b/core/src/main/java/art/arcane/iris/engine/history/ChunkGenerationSemantics.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.Comparator; import java.util.Objects; import java.util.Set; @@ -361,11 +362,11 @@ public final class ChunkGenerationSemantics { this.chunkX = chunkX; this.chunkZ = chunkZ; this.activationId = activationId; - surfaceBiomeKeys = new TreeSet<>(); - caveBiomeKeys = new TreeSet<>(); - regionKeys = new TreeSet<>(); - riverProfileKeys = new TreeSet<>(); - objectKeys = new TreeSet<>(); + surfaceBiomeKeys = new HashSet<>(); + caveBiomeKeys = new HashSet<>(); + regionKeys = new HashSet<>(); + riverProfileKeys = new HashSet<>(); + objectKeys = new HashSet<>(); riverFeatures = new TreeSet<>(RIVER_FEATURE_COMPARATOR); structures = new TreeSet<>(STRUCTURE_COMPARATOR); pointsOfInterest = new TreeSet<>(POI_COMPARATOR); @@ -519,8 +520,11 @@ public final class ChunkGenerationSemantics { } private static void addKey(Set target, String key, String kind) { + if (target.contains(Objects.requireNonNull(key, "key"))) { + return; + } String validated = requireResourceKey(key); - if (target.size() >= MAX_KEYS_PER_KIND && !target.contains(validated)) { + if (target.size() >= MAX_KEYS_PER_KIND) { throw new IllegalArgumentException( "A chunk cannot contain more than " + MAX_KEYS_PER_KIND + " " + kind + " keys" ); diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java index e5103748f..aabb960df 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashMap; @@ -23,11 +24,14 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; import java.util.stream.Stream; 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_SEMANTIC_BATCH = 32; + private static final int MAXIMUM_PENDING_SEMANTIC_CLAIMS = 128; private final GenerationHistoryPaths paths; private final GenerationPackRepository packs; @@ -41,6 +45,8 @@ public final class GenerationHistory { private final GenerationAdmission admission; private final Map boundaryCache; private final Map terrainSignatureCache; + private final ArrayBlockingQueue pendingSemanticClaims = + new ArrayBlockingQueue<>(MAXIMUM_PENDING_SEMANTIC_CLAIMS); private GenerationHistory( GenerationHistoryPaths paths, @@ -594,12 +600,64 @@ public final class GenerationHistory { } } - public synchronized boolean claimGeneratedSemantics( + public boolean claimGeneratedSemantics( GenerationStage stage, ChunkGenerationSemantics update ) throws IOException { - GenerationStage requiredStage = Objects.requireNonNull(stage, "generation stage"); - ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update"); + PendingSemanticClaim pending = new PendingSemanticClaim( + Objects.requireNonNull(stage, "generation stage"), + Objects.requireNonNull(update, "update")); + while (!pendingSemanticClaims.offer(pending)) { + synchronized (this) { + persistPendingSemanticClaims(); + } + } + synchronized (this) { + while (!pending.claim.completed) { + persistPendingSemanticClaims(); + } + return pending.claim.result(); + } + } + + private void persistPendingSemanticClaims() { + List batch = new ArrayList<>(MAXIMUM_SEMANTIC_BATCH); + List claims = new ArrayList<>(MAXIMUM_SEMANTIC_BATCH); + PendingSemanticClaim current = null; + try { + for (int count = 0; count < MAXIMUM_SEMANTIC_BATCH; count++) { + current = pendingSemanticClaims.poll(); + if (current == null) { + break; + } + batch.add(current); + try { + validateSemanticClaim(current.stage, current.claim.update); + claims.add(current.claim); + } catch (RuntimeException failure) { + current.claim.fail(failure); + } + current = null; + } + semantics.claimAndPersistBatch(claims); + } catch (RuntimeException | Error failure) { + boolean reported = false; + if (current != null) { + reported = current.claim.fail(failure); + } + for (PendingSemanticClaim pending : batch) { + reported |= pending.claim.fail(failure); + } + while ((current = pendingSemanticClaims.poll()) != null) { + reported |= current.claim.fail(failure); + } + if (!reported) { + throw failure; + } + } + } + + private void validateSemanticClaim(GenerationStage requiredStage, ChunkGenerationSemantics requiredUpdate) { requiredStage.requireOpen(this); if (!requiredUpdate.sealed()) { throw new IllegalArgumentException("Generated semantics must be sealed."); @@ -611,14 +669,13 @@ public final class GenerationHistory { if (requiredUpdate.activationId() != requiredStage.activation().activationId()) { throw new IllegalArgumentException("Generated semantics activation does not match the open stage."); } - return semantics.claimAndPersist(requiredUpdate); } public synchronized void forEachRecordedSemantic(GenerationSemanticIndex.RecordConsumer consumer) throws IOException { semantics.forEachRecord(consumer); } - public synchronized Optional semantics(int chunkX, int chunkZ) { + public Optional semantics(int chunkX, int chunkZ) { return semantics.get(chunkX, chunkZ); } @@ -663,7 +720,7 @@ public final class GenerationHistory { } } - public synchronized GenerationActivation resolveActivation(int chunkX, int chunkZ) { + public GenerationActivation resolveActivation(int chunkX, int chunkZ) { long activationId = ownership.resolve( chunkX, chunkZ, @@ -1108,6 +1165,16 @@ public final class GenerationHistory { }; } + private static final class PendingSemanticClaim { + private final GenerationStage stage; + private final GenerationSemanticIndex.Claim claim; + + private PendingSemanticClaim(GenerationStage stage, ChunkGenerationSemantics update) { + this.stage = stage; + this.claim = new GenerationSemanticIndex.Claim(update); + } + } + public static final class GenerationStage implements AutoCloseable { private final GenerationHistory owner; private final int chunkX; diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java index 388523e1e..ce179e1c8 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticCapture.java @@ -17,9 +17,8 @@ import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterStructurePOI; -import java.util.HashSet; +import java.util.BitSet; import java.util.Objects; -import java.util.Set; public final class GenerationSemanticCapture { private static final int CHUNK_SIZE = 16; @@ -137,7 +136,7 @@ public final class GenerationSemanticCapture { Mantle mantle = engine.getMantle().getMantle(); int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); - Set resolvedCaves = new HashSet<>(); + BitSet resolvedCaves = new BitSet(); mantle.iterateChunk(chunkX, chunkZ, MatterCavern.class, (localX, y, localZ, cavern) -> { if (!caveSpace.isOpen(localX, y, localZ)) { return; @@ -197,16 +196,17 @@ public final class GenerationSemanticCapture { private static void captureCave( Engine engine, ChunkGenerationSemantics.Builder semantics, - Set resolvedCaves, + BitSet resolvedCaves, int blockX, int y, int blockZ, String explicitBiomeKey ) { - CavePosition position = new CavePosition(blockX, y, blockZ); - if (!resolvedCaves.add(position)) { + int position = (y << 8) | ((blockX & 15) << 4) | (blockZ & 15); + if (resolvedCaves.get(position)) { return; } + resolvedCaves.set(position); if (explicitBiomeKey != null && !explicitBiomeKey.isBlank()) { semantics.addCaveBiome(explicitBiomeKey); return; @@ -259,6 +259,4 @@ public final class GenerationSemanticCapture { boolean isOpen(int localX, int internalY, int localZ); } - private record CavePosition(int x, int y, int z) { - } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java index 4f0a1aa8d..891e8f620 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java @@ -42,6 +42,7 @@ import java.util.Optional; import java.util.OptionalLong; import java.util.Set; import java.util.TreeSet; +import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Predicate; import java.util.regex.Matcher; @@ -85,10 +86,14 @@ public final class GenerationSemanticIndex { private final Long2IntOpenHashMap journalEntries; private final Long2IntOpenHashMap regionRecordCounts; private final ReentrantReadWriteLock lock; + private final ReentrantLock publicationLock; private final ShardPublisher publisher; private final PointerPublisher pointerPublisher; private final CatalogPublisher catalogPublisher; private long regionDecodeCount; + private volatile IOException journalFailure; + private long appendingRegionKey; + private RegionShard appendingBase; private GenerationSemanticIndex( Path dimensionRoot, @@ -104,6 +109,7 @@ public final class GenerationSemanticIndex { journalEntries = new Long2IntOpenHashMap(); regionRecordCounts = new Long2IntOpenHashMap(); lock = new ReentrantReadWriteLock(); + publicationLock = new ReentrantLock(); this.publisher = publisher; this.pointerPublisher = pointerPublisher; this.catalogPublisher = catalogPublisher; @@ -174,36 +180,102 @@ public final class GenerationSemanticIndex { if (!requiredClaim.sealed()) { throw new IllegalArgumentException("Generation semantic claims must be sealed."); } + Claim pending = new Claim(requiredClaim); + claimAndPersistBatch(List.of(pending)); + return pending.result(); + } + + void claimAndPersistBatch(List claims) { lock.writeLock().lock(); + publicationLock.lock(); try { - ChunkGenerationSemantics existing = getRecordLocked( - requiredClaim.chunkX(), - requiredClaim.chunkZ() - ); - ChunkGenerationSemantics merged = existing == null - ? requiredClaim - : existing.merge(requiredClaim); - if (merged == existing || merged.equals(existing)) { - return false; + requireJournalUsable(); + Map> batches = new LinkedHashMap<>(); + for (Claim pending : claims) { + if (!pending.update.sealed()) { + pending.fail(new IllegalArgumentException("Generation semantic claims must be sealed.")); + continue; + } + long regionKey = packRegion(pending.update.chunkX() >> 5, pending.update.chunkZ() >> 5); + batches.computeIfAbsent(regionKey, ignored -> new ArrayList<>()).add(pending); } - int regionX = requiredClaim.chunkX() >> 5; - int regionZ = requiredClaim.chunkZ() >> 5; - long regionKey = packRegion(regionX, regionZ); - RegionShard baseRegion = loadRegionLocked(regionKey, regionX, regionZ); - RegionShard nextRegion = baseRegion.withRecord(merged); - ensureStorageDirectory(); - SemanticJournal.append(directory, regionX, regionZ, merged); - cacheRegion(regionKey, nextRegion); - if (existing == null) { - regionRecordCounts.addTo(regionKey, 1); + for (Map.Entry> batch : batches.entrySet()) { + persistClaimBatch(batch.getKey(), batch.getValue()); } - journalEntries.addTo(regionKey, 1); - return true; + } catch (IOException failure) { + for (Claim pending : claims) { + pending.fail(failure); + } + } catch (RuntimeException | Error failure) { + journalFailure = new IOException("Generation semantic batch failed unexpectedly.", failure); + for (Claim pending : claims) { + pending.fail(failure); + } + throw failure; } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } + private void persistClaimBatch(long regionKey, List claims) { + try { + requireJournalUsable(); + int regionX = regionX(regionKey); + int regionZ = regionZ(regionKey); + RegionShard baseRegion = loadRegionLocked(regionKey, regionX, regionZ); + RegionShard nextRegion = baseRegion; + List updates = new ArrayList<>(claims.size()); + for (Claim pending : claims) { + ChunkGenerationSemantics update = pending.update; + ChunkGenerationSemantics existing = nextRegion.get(update.chunkX(), update.chunkZ()); + ChunkGenerationSemantics merged; + try { + merged = existing == null ? update : existing.merge(update); + } catch (IllegalArgumentException | IllegalStateException failure) { + pending.fail(failure); + continue; + } + if (merged == existing || merged.equals(existing)) { + pending.completed = existing == baseRegion.get(update.chunkX(), update.chunkZ()); + continue; + } + nextRegion = nextRegion.withRecord(merged); + updates.add(merged); + pending.persisted = true; + } + if (updates.isEmpty()) { + return; + } + ensureStorageDirectory(); + appendingRegionKey = regionKey; + appendingBase = baseRegion; + publicationLock.unlock(); + try { + SemanticJournal.append(directory, regionX, regionZ, updates); + } catch (JournalAppendFailure failure) { + journalFailure = failure; + throw failure; + } catch (RuntimeException | Error failure) { + journalFailure = new IOException("Generation semantic batch failed unexpectedly.", failure); + throw failure; + } finally { + publicationLock.lock(); + appendingBase = null; + } + cacheRegion(regionKey, nextRegion); + regionRecordCounts.put(regionKey, nextRegion.recordCount()); + journalEntries.addTo(regionKey, updates.size()); + for (Claim pending : claims) { + pending.completed = true; + } + } catch (IOException failure) { + for (Claim pending : claims) { + pending.fail(failure); + } + } + } + public int discardUnstoredClaims(WorldChunkInventory stored, Set activationIds) throws IOException { Objects.requireNonNull(stored, "stored chunks"); Set selected = Set.copyOf(activationIds); @@ -213,6 +285,7 @@ public final class GenerationSemanticIndex { } } lock.writeLock().lock(); + publicationLock.lock(); try { compactJournals(); int removed = 0; @@ -248,19 +321,23 @@ public final class GenerationSemanticIndex { } return removed; } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } public void compactJournals() throws IOException { lock.writeLock().lock(); + publicationLock.lock(); try { + requireJournalUsable(); ArrayList regionKeys = new ArrayList<>(journalEntries.keySet()); regionKeys.sort(GenerationSemanticIndex::compareRegionKeys); for (long regionKey : regionKeys) { compactJournal(regionKey); } } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } @@ -271,7 +348,9 @@ public final class GenerationSemanticIndex { } SealedClaimConsumer requiredConsumer = Objects.requireNonNull(consumer, "sealed claim consumer"); lock.writeLock().lock(); + publicationLock.lock(); try { + requireJournalUsable(); for (long regionKey : allRegionKeys()) { if (!loadSummaryLocked(regionKey).hasSealedActivation(activationId)) { continue; @@ -288,12 +367,13 @@ public final class GenerationSemanticIndex { } } } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } public boolean hasSealedClaim(int chunkX, int chunkZ, long activationId) { - lock.writeLock().lock(); + publicationLock.lock(); try { ChunkGenerationSemantics semantics = getRecordLocked(chunkX, chunkZ); return semantics != null @@ -302,14 +382,16 @@ public final class GenerationSemanticIndex { } catch (IOException error) { throw new IllegalStateException("Unable to resolve a sealed generation semantic claim.", error); } finally { - lock.writeLock().unlock(); + publicationLock.unlock(); } } public boolean recordAndPersist(ChunkGenerationSemantics update) throws IOException { ChunkGenerationSemantics requiredUpdate = Objects.requireNonNull(update, "update"); lock.writeLock().lock(); + publicationLock.lock(); try { + requireJournalUsable(); ChunkGenerationSemantics existing = getRecordLocked( requiredUpdate.chunkX(), requiredUpdate.chunkZ() @@ -345,34 +427,39 @@ public final class GenerationSemanticIndex { deleteUnreferencedShard(regionX, regionZ, previousHash, shardHash); return true; } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } public Optional get(int chunkX, int chunkZ) { - lock.writeLock().lock(); + publicationLock.lock(); try { return Optional.ofNullable(getRecordLocked(chunkX, chunkZ)); } catch (IOException error) { throw new IllegalStateException("Unable to load generation semantics for chunk " + chunkX + "," + chunkZ + ".", error); } finally { - lock.writeLock().unlock(); + publicationLock.unlock(); } } public int recordCount() { lock.readLock().lock(); + publicationLock.lock(); try { return totalRecordCountLocked(); } finally { + publicationLock.unlock(); lock.readLock().unlock(); } } public List recordsSnapshot() { lock.writeLock().lock(); + publicationLock.lock(); try { + requireJournalUsable(); List snapshot = new ArrayList<>(totalRecordCountLocked()); for (long regionKey : allRegionKeys()) { RegionShard region = loadRegionLocked( @@ -388,6 +475,7 @@ public final class GenerationSemanticIndex { } catch (IOException error) { throw new IllegalStateException("Unable to load generation semantic snapshot.", error); } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } @@ -395,7 +483,9 @@ public final class GenerationSemanticIndex { public void forEachRecord(RecordConsumer consumer) throws IOException { RecordConsumer requiredConsumer = Objects.requireNonNull(consumer, "consumer"); lock.writeLock().lock(); + publicationLock.lock(); try { + requireJournalUsable(); for (long regionKey : allRegionKeys()) { RegionShard region = loadRegionLocked( regionKey, @@ -407,33 +497,40 @@ public final class GenerationSemanticIndex { } } } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } int cachedRegionCount() { lock.readLock().lock(); + publicationLock.lock(); try { return regions.size(); } finally { + publicationLock.unlock(); lock.readLock().unlock(); } } long regionDecodeCount() { lock.readLock().lock(); + publicationLock.lock(); try { return regionDecodeCount; } finally { + publicationLock.unlock(); lock.readLock().unlock(); } } int cachedSummaryCount() { lock.readLock().lock(); + publicationLock.lock(); try { return summaries.size(); } finally { + publicationLock.unlock(); lock.readLock().unlock(); } } @@ -452,12 +549,14 @@ public final class GenerationSemanticIndex { "eligibility" ); lock.writeLock().lock(); + publicationLock.lock(); try { if (requiredQuery.kind() == SemanticKind.STRUCTURE) { return findNearestStructure(requiredQuery, requiredEligibility); } return findNearestChunk(requiredQuery, requiredEligibility); } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } @@ -472,6 +571,7 @@ public final class GenerationSemanticIndex { "eligibility" ); lock.writeLock().lock(); + publicationLock.lock(); try { RiverMatch best = null; BigInteger bestDistance = null; @@ -540,6 +640,7 @@ public final class GenerationSemanticIndex { } catch (IOException error) { throw new IllegalStateException("Unable to search recorded river semantics.", error); } finally { + publicationLock.unlock(); lock.writeLock().unlock(); } } @@ -874,6 +975,7 @@ public final class GenerationSemanticIndex { } private ChunkGenerationSemantics getRecordLocked(int chunkX, int chunkZ) throws IOException { + requireJournalUsable(); long regionKey = packRegion(chunkX >> 5, chunkZ >> 5); if (!regionRecordCounts.containsKey(regionKey)) { return null; @@ -882,6 +984,10 @@ public final class GenerationSemanticIndex { } private RegionShard loadRegionLocked(long regionKey, int regionX, int regionZ) throws IOException { + requireJournalUsable(); + if (appendingBase != null && appendingRegionKey == regionKey) { + return appendingBase; + } RegionShard cached = regions.get(regionKey); if (cached != null) { return cached; @@ -898,6 +1004,7 @@ public final class GenerationSemanticIndex { } private RegionSummary loadSummaryLocked(long regionKey) throws IOException { + requireJournalUsable(); RegionSummary cached = summaries.get(regionKey); if (cached != null) { return cached; @@ -959,6 +1066,12 @@ public final class GenerationSemanticIndex { } } + private void requireJournalUsable() throws IOException { + if (journalFailure != null) { + throw new IOException("Generation semantic journal requires recovery before further access.", journalFailure); + } + } + private List allRegionKeys() { ArrayList ordered = new ArrayList<>(regionRecordCounts.keySet()); ordered.sort(GenerationSemanticIndex::compareRegionKeys); @@ -1165,7 +1278,8 @@ public final class GenerationSemanticIndex { ChunkGenerationSemantics.BlockPosition origin, int maximumChunkRadius, boolean orderByBlockDistance - ) { + ) throws IOException { + requireJournalUsable(); int originChunkX = Math.floorDiv(origin.x(), 16); int originChunkZ = Math.floorDiv(origin.z(), 16); long minimumChunkX = (long) originChunkX - maximumChunkRadius; @@ -1760,6 +1874,40 @@ public final class GenerationSemanticIndex { void publish(Path directory, Set regionKeys) throws IOException; } + static final class Claim { + final ChunkGenerationSemantics update; + boolean persisted; + boolean completed; + private Throwable failure; + + Claim(ChunkGenerationSemantics update) { + this.update = update; + } + + boolean fail(Throwable failure) { + if (!completed) { + this.failure = failure; + persisted = false; + completed = true; + return true; + } + return this.failure == failure; + } + + boolean result() throws IOException { + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + return persisted; + } + } + private static final class SemanticJournal { private static final int ENTRY_MAGIC = 0x4953574C; private static final int HEADER_BYTES = Integer.BYTES * 2; @@ -1768,8 +1916,65 @@ public final class GenerationSemanticIndex { Path directory, int regionX, int regionZ, - ChunkGenerationSemantics claim + List claims ) throws IOException { + Path file = directory.resolve(journalFileName(regionX, regionZ)); + boolean created = !Files.exists(file, LinkOption.NOFOLLOW_LINKS); + if (!created && !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw invalid(file, "path is not a regular file"); + } + FileChannel opened = FileChannel.open( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE + ); + boolean uncertain = created; + boolean restored = false; + try (FileChannel channel = opened) { + long size = channel.size(); + if (size > MAX_JOURNAL_BYTES) { + throw invalid(file, "journal exceeds its size limit"); + } + uncertain = true; + try { + channel.position(size); + long written = size; + for (ChunkGenerationSemantics claim : claims) { + ByteBuffer entry = encodeEntry(regionX, regionZ, claim); + if (written > MAX_JOURNAL_BYTES - entry.remaining()) { + throw invalid(file, "journal exceeds its size limit"); + } + written += entry.remaining(); + while (entry.hasRemaining()) { + channel.write(entry); + } + } + channel.force(true); + if (created) { + RegionShard.forceDirectory(directory); + } + } catch (IOException failure) { + try { + channel.truncate(size); + channel.force(true); + if (created) { + RegionShard.forceDirectory(directory); + } + restored = true; + } catch (IOException rollback) { + failure.addSuppressed(rollback); + } + throw failure; + } + } catch (IOException failure) { + if (uncertain && !restored) { + throw new JournalAppendFailure(file, failure); + } + throw failure; + } + } + + private static ByteBuffer encodeEntry(int regionX, int regionZ, ChunkGenerationSemantics claim) throws IOException { RegionShard single = new RegionShard(regionX, regionZ).withRecord(claim); byte[] payload = single.encode(); CRC32 checksum = new CRC32(); @@ -1780,30 +1985,7 @@ public final class GenerationSemanticIndex { entry.put(payload); entry.putInt((int) checksum.getValue()); entry.flip(); - - Path file = directory.resolve(journalFileName(regionX, regionZ)); - boolean created = !Files.exists(file, LinkOption.NOFOLLOW_LINKS); - if (!created && !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { - throw invalid(file, "path is not a regular file"); - } - try (FileChannel channel = FileChannel.open( - file, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE - )) { - long size = channel.size(); - if (size > MAX_JOURNAL_BYTES - entry.remaining()) { - throw invalid(file, "journal exceeds its size limit"); - } - channel.position(size); - while (entry.hasRemaining()) { - channel.write(entry); - } - channel.force(true); - } - if (created) { - RegionShard.forceDirectory(directory); - } + return entry; } private static Replay replay(Path file, int expectedRegionX, int expectedRegionZ) throws IOException { @@ -1916,6 +2098,12 @@ public final class GenerationSemanticIndex { } } + private static final class JournalAppendFailure extends IOException { + private JournalAppendFailure(Path file, IOException cause) { + super("Generation semantic journal could not confirm a durable append or rollback: " + file, cause); + } + } + private static final class ShardPointer { private static final int MAGIC = 0x49535054; private static final int FORMAT_VERSION = 1; diff --git a/core/src/main/java/art/arcane/iris/engine/history/NativeTerrainReceipt.java b/core/src/main/java/art/arcane/iris/engine/history/NativeTerrainReceipt.java index 943b87299..d33a9cb78 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/NativeTerrainReceipt.java +++ b/core/src/main/java/art/arcane/iris/engine/history/NativeTerrainReceipt.java @@ -7,7 +7,9 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.OptionalInt; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -24,6 +26,7 @@ public final class NativeTerrainReceipt { public static byte[] encode(SavedTerrainChunk chunk, long activationId, String epochId) throws IOException { ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + UtfCache strings = new UtfCache(); try (DataOutputStream output = new DataOutputStream(new BufferedOutputStream(new GZIPOutputStream(bytes)))) { output.writeInt(MAGIC); output.writeInt(VERSION); @@ -37,7 +40,7 @@ public final class NativeTerrainReceipt { if (!chunk.hasColumn(x, z)) { continue; } - writeColumn(output, chunk.column((chunk.chunkX() << 4) + x, (chunk.chunkZ() << 4) + z)); + writeColumn(output, chunk.column((chunk.chunkX() << 4) + x, (chunk.chunkZ() << 4) + z), strings); } } } @@ -109,7 +112,7 @@ public final class NativeTerrainReceipt { } } - private static void writeColumn(DataOutputStream output, TerrainBoundarySignature signature) throws IOException { + private static void writeColumn(DataOutputStream output, TerrainBoundarySignature signature, UtfCache strings) throws IOException { output.writeInt(signature.surfaceHeight()); output.writeInt(signature.oceanFloorHeight()); output.writeInt(signature.fluidHeight().orElse(-1)); @@ -119,15 +122,15 @@ public final class NativeTerrainReceipt { output.writeInt(layout.sampleStep()); output.writeInt(layout.sampleCount()); for (int index = 0; index < layout.sampleCount(); index++) { - output.writeUTF(signature.biomeAtSample(index)); + strings.write(output, signature.biomeAtSample(index)); } BoundaryColumnGeometry geometry = signature.geometry(); output.writeInt(geometry.minimumY()); output.writeInt(geometry.palette().size()); for (BoundaryColumnGeometry.Voxel voxel : geometry.palette()) { - output.writeUTF(voxel.stateKey()); + strings.write(output, voxel.stateKey()); output.writeByte(voxel.phase().ordinal()); - output.writeUTF(voxel.fluidStateKey()); + strings.write(output, voxel.fluidStateKey()); output.writeBoolean(voxel.protectedContent()); } int[] ends = geometry.runEnds(); @@ -194,4 +197,45 @@ public final class NativeTerrainReceipt { public record Decoded(SavedTerrainChunk terrain, long activationId, String epochId) { } + + private static final class UtfCache { + private static final int MAXIMUM_ENTRIES = 256; + private static final int MAXIMUM_ENCODED_BYTES = 64 * 1024; + + private final Map encoded = new HashMap<>(); + private final ByteArrayOutputStream scratch = new ByteArrayOutputStream(); + private final DataOutputStream encoder = new DataOutputStream(scratch); + private int encodedBytes; + private boolean entryLimitReached; + + private void write(DataOutputStream output, String value) throws IOException { + if (entryLimitReached) { + output.writeUTF(value); + return; + } + byte[] cached = encoded.get(value); + if (cached != null) { + output.write(cached); + return; + } + if (encoded.size() >= MAXIMUM_ENTRIES) { + entryLimitReached = true; + encoded.clear(); + output.writeUTF(value); + return; + } + if (value.length() > MAXIMUM_ENCODED_BYTES - encodedBytes - Short.BYTES) { + output.writeUTF(value); + return; + } + scratch.reset(); + encoder.writeUTF(value); + if (scratch.size() <= MAXIMUM_ENCODED_BYTES - encodedBytes) { + byte[] bytes = scratch.toByteArray(); + encoded.put(value, bytes); + encodedBytes += bytes.length; + } + scratch.writeTo(output); + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java index 4abcd342d..d977145c6 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeStore.java @@ -23,6 +23,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.CRC32; @@ -42,20 +43,22 @@ public final class SavedBiomeStore { private static final int MAXIMUM_CACHED_REGIONS = 64; private static final int MAXIMUM_CACHED_CHUNKS = 128; private static final long MAXIMUM_CACHED_BYTES = 64L * 1024L * 1024L; + private static final int MAXIMUM_WRITE_BATCH = 64; private final Path dimensionRoot; private final Path directory; - private final Object[] regionLocks = new Object[64]; + private final WriteStripe[] regionLocks = new WriteStripe[64]; private final LinkedHashMap regions = new LinkedHashMap<>(64, 0.75F, true); private final LinkedHashMap> chunks = new LinkedHashMap<>(128, 0.75F, true); private long cachedBytes; + private volatile IOException writeFailure; private SavedBiomeStore(Path dimensionRoot) throws IOException { this.dimensionRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").toAbsolutePath().normalize(); directory = this.dimensionRoot.resolve("iris/generation/biomes"); requireSafeAncestors(); for (int index = 0; index < regionLocks.length; index++) { - regionLocks[index] = new Object(); + regionLocks[index] = new WriteStripe(); } } @@ -76,6 +79,7 @@ public final class SavedBiomeStore { } } synchronized (regionLock(chunkX, chunkZ)) { + requireWritable(); synchronized (chunks) { Optional cached = chunks.get(chunkKey); if (cached != null) { @@ -99,46 +103,153 @@ public final class SavedBiomeStore { public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException { SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk"); - Object lock = regionLock(required.chunkX(), required.chunkZ()); - synchronized (lock) { - if (alreadyClaimed(required)) { - return false; + WriteStripe lock = regionLock(required.chunkX(), required.chunkZ()); + if (!lock.writing) { + synchronized (lock) { + if (alreadyClaimed(required)) { + return false; + } } } byte[] body = encode(required); byte[] record = ByteBuffer.allocate(body.length + 8) .putInt(body.length).put(body).putInt(checksum(body)).array(); + PendingWrite pending = new PendingWrite(required, record); + lock.pending.add(pending); synchronized (lock) { - if (alreadyClaimed(required)) { - return false; + if (!pending.completed) { + lock.writing = true; + try { + while (!pending.completed) { + persistPending(lock); + } + } finally { + lock.writing = false; + } } - RegionIndex region = region(required.chunkX(), required.chunkZ()); + if (pending.failure != null) { + throw pending.failure; + } + return pending.persisted; + } + } + + private void persistPending(WriteStripe stripe) { + Map> batches = new LinkedHashMap<>(); + PendingWrite grouping = null; + try { + for (int count = 0; count < MAXIMUM_WRITE_BATCH; count++) { + grouping = stripe.pending.poll(); + if (grouping == null) { + break; + } + long regionKey = key(grouping.chunk.chunkX() >> 5, grouping.chunk.chunkZ() >> 5); + batches.computeIfAbsent(regionKey, ignored -> new ArrayList<>()).add(grouping); + grouping = null; + } + for (List batch : batches.values()) { + persistBatch(batch); + } + } catch (RuntimeException | Error failure) { + IOException unavailable = new IOException("Saved biome append failed unexpectedly", failure); + writeFailure = unavailable; + if (grouping != null) { + grouping.failure = unavailable; + grouping.completed = true; + } + for (List batch : batches.values()) { + for (PendingWrite pending : batch) { + if (!pending.completed) { + pending.failure = unavailable; + pending.completed = true; + } + } + } + throw failure; + } + } + + private void persistBatch(List batch) { + Map claimed = new LinkedHashMap<>(); + try { + for (PendingWrite pending : batch) { + try { + if (alreadyClaimed(pending.chunk)) { + pending.completed = true; + continue; + } + int slot = slot(pending.chunk.chunkX(), pending.chunk.chunkZ()); + PendingWrite previous = claimed.get(slot); + if (previous == null) { + claimed.put(slot, pending); + } else if (!previous.chunk.equals(pending.chunk)) { + throw new IOException("Conflicting saved biome claim at chunk " + + pending.chunk.chunkX() + ", " + pending.chunk.chunkZ()); + } + } catch (IOException failure) { + pending.failure = failure; + pending.completed = true; + } + } + if (claimed.isEmpty()) { + return; + } + requireWritable(); + SavedBiomeChunk first = batch.getFirst().chunk; + RegionIndex region = region(first.chunkX(), first.chunkZ()); ensureRegionFile(region); - long offset; + long offset = 0L; + boolean durable = false; try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) { offset = output.length(); - if (offset + record.length > MAXIMUM_REGION_BYTES) { + long length = 0L; + for (PendingWrite pending : claimed.values()) { + length += pending.record.length; + } + if (offset + length > MAXIMUM_REGION_BYTES) { throw new IOException("Saved biome region exceeds its storage limit: " + region.path); } try { output.seek(offset); - output.write(record); + for (PendingWrite pending : claimed.values()) { + output.write(pending.record); + } output.getChannel().force(true); + durable = true; } catch (IOException failure) { try { output.setLength(offset); output.getChannel().force(true); } catch (IOException rollback) { failure.addSuppressed(rollback); + writeFailure = failure; } throw failure; } + } catch (IOException failure) { + if (durable) { + writeFailure = failure; + } + throw failure; } - int slot = slot(required.chunkX(), required.chunkZ()); - region.offsets[slot] = offset; - region.lengths[slot] = body.length; - cacheChunk(key(required.chunkX(), required.chunkZ()), Optional.of(required)); - return true; + for (PendingWrite pending : claimed.values()) { + SavedBiomeChunk chunk = pending.chunk; + int slot = slot(chunk.chunkX(), chunk.chunkZ()); + region.offsets[slot] = offset; + region.lengths[slot] = pending.record.length - 8; + cacheChunk(key(chunk.chunkX(), chunk.chunkZ()), Optional.of(chunk)); + pending.persisted = true; + offset += pending.record.length; + } + } catch (IOException failure) { + for (PendingWrite pending : batch) { + if (!pending.completed) { + pending.failure = failure; + } + } + } + for (PendingWrite pending : batch) { + pending.completed = true; } } @@ -164,6 +275,7 @@ public final class SavedBiomeStore { int regionX = parseRegionCoordinate(matcher.group(1)); int regionZ = parseRegionCoordinate(matcher.group(2)); synchronized (regionLock(regionX << 5, regionZ << 5)) { + requireWritable(); RegionIndex region = region(regionX << 5, regionZ << 5); boolean[] discarded = new boolean[1024]; int count = 0; @@ -205,6 +317,7 @@ public final class SavedBiomeStore { } private boolean alreadyClaimed(SavedBiomeChunk chunk) throws IOException { + requireWritable(); Optional existing = get(chunk.chunkX(), chunk.chunkZ()); if (existing.isEmpty()) { return false; @@ -440,7 +553,7 @@ public final class SavedBiomeStore { } } - private Object regionLock(int chunkX, int chunkZ) { + private WriteStripe regionLock(int chunkX, int chunkZ) { long key = key(chunkX >> 5, chunkZ >> 5); return regionLocks[(int) (key ^ (key >>> 32)) & (regionLocks.length - 1)]; } @@ -656,6 +769,30 @@ public final class SavedBiomeStore { throw new IOException("Invalid saved biome variable integer"); } + private void requireWritable() throws IOException { + if (writeFailure != null) { + throw new IOException("Saved biome writes are unavailable after an uncertain append", writeFailure); + } + } + + private static final class WriteStripe { + private final ConcurrentLinkedQueue pending = new ConcurrentLinkedQueue<>(); + private volatile boolean writing; + } + + private static final class PendingWrite { + private final SavedBiomeChunk chunk; + private final byte[] record; + private boolean completed; + private boolean persisted; + private IOException failure; + + private PendingWrite(SavedBiomeChunk chunk, byte[] record) { + this.chunk = chunk; + this.record = record; + } + } + private static final class DecodeBudget { private long remaining = SavedBiomeChunk.MAXIMUM_ESTIMATED_BYTES - 8192L; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java index a03f5c928..cca4f0806 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -16,12 +16,16 @@ public final class RiverPolicyResolver { } public static EffectiveRiverPolicy resolve(IrisDimension dimension, IrisRegion region, IrisBiome biome) { - return resolve( - dimension == null ? null : dimension.getRiverPolicy(), + return resolve(dimension == null ? null : dimension.getRiverPolicy(), region == null ? null : region.getRiverPolicy(), - biome == null ? null : biome.getRiverPolicy(), - loaderOf(biome, region, dimension) - ); + biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension)); + } + + public static Resolution resolveWithStatus(IrisDimension dimension, IrisRegion region, IrisBiome biome) { + State state = resolveState(dimension == null ? null : dimension.getRiverPolicy(), + region == null ? null : region.getRiverPolicy(), + biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), true); + return new Resolution(state.build(), state.complete); } public static EffectiveRiverPolicy resolve( @@ -42,11 +46,16 @@ public final class RiverPolicyResolver { IrisRiverPolicy biomePolicy, IrisData data ) { - State state = new State(); + return resolveState(dimensionPolicy, regionPolicy, biomePolicy, data, false).build(); + } + + private static State resolveState(IrisRiverPolicy dimensionPolicy, IrisRiverPolicy regionPolicy, + IrisRiverPolicy biomePolicy, IrisData data, boolean trackCompleteness) { + State state = new State(trackCompleteness); state.apply(dimensionPolicy, RiverConfinement.REGION, data); state.apply(regionPolicy, RiverConfinement.REGION, data); state.apply(biomePolicy, RiverConfinement.BIOME, data); - return state.build(); + return state; } private static IrisData loaderOf(IrisRegistrant... registrants) { @@ -59,7 +68,12 @@ public final class RiverPolicyResolver { return null; } + public record Resolution(EffectiveRiverPolicy policy, boolean complete) { + } + private static final class State { + private final Runnable unresolvedReference; + private boolean complete = true; private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL; private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW; private boolean outletAdmission = true; @@ -80,6 +94,10 @@ public final class RiverPolicyResolver { private Double shoreWidth = null; private Boolean erosion = null; + private State(boolean trackCompleteness) { + unresolvedReference = trackCompleteness ? this::unresolvedReference : null; + } + /** * {@code scope} is the confinement a {@code confined: true} at this level binds courses to; {@code data} is the * pack whose version-content gate filters the biome selections (null skips filtering). @@ -113,19 +131,19 @@ public final class RiverPolicyResolver { profiles = List.copyOf(policy.getProfiles()); } if (policy.getSurfaceBiomes() != null) { - surfaceBiomes = List.copyOf(policy.compatBiomes(policy.getSurfaceBiomes(), data, "surfaceBiomes")); + surfaceBiomes = List.copyOf(policy.compatBiomes(policy.getSurfaceBiomes(), data, "surfaceBiomes", unresolvedReference)); } if (policy.getMouthBiomes() != null) { - mouthBiomes = List.copyOf(policy.compatBiomes(policy.getMouthBiomes(), data, "mouthBiomes")); + mouthBiomes = List.copyOf(policy.compatBiomes(policy.getMouthBiomes(), data, "mouthBiomes", unresolvedReference)); } if (policy.getShoreBiomes() != null) { - shoreBiomes = List.copyOf(policy.compatBiomes(policy.getShoreBiomes(), data, "shoreBiomes")); + shoreBiomes = List.copyOf(policy.compatBiomes(policy.getShoreBiomes(), data, "shoreBiomes", unresolvedReference)); } if (policy.getBankBiomes() != null) { - bankBiomes = List.copyOf(policy.compatBiomes(policy.getBankBiomes(), data, "bankBiomes")); + bankBiomes = List.copyOf(policy.compatBiomes(policy.getBankBiomes(), data, "bankBiomes", unresolvedReference)); } if (policy.getFloodedCaveBiomes() != null) { - floodedCaveBiomes = List.copyOf(policy.compatBiomes(policy.getFloodedCaveBiomes(), data, "floodedCaveBiomes")); + floodedCaveBiomes = List.copyOf(policy.compatBiomes(policy.getFloodedCaveBiomes(), data, "floodedCaveBiomes", unresolvedReference)); } if (policy.getSurfacePools() != null) { surfacePools = List.copyOf(policy.getSurfacePools()); @@ -147,6 +165,10 @@ public final class RiverPolicyResolver { } } + private void unresolvedReference() { + complete = false; + } + private EffectiveRiverPolicy build() { return new EffectiveRiverPolicy( placement, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index aeec7d360..ec30098e3 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology.runtime; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; @@ -78,6 +80,7 @@ import java.util.function.Predicate; public final class IrisHydrologyRuntime implements AutoCloseable { private static final int MAXIMUM_CACHE_TILES = 64; private static final int MAXIMUM_TERRAIN_SAMPLES = 65_536; + private static final int MAXIMUM_RESOLVED_POLICIES = 1024; private static final int MAXIMUM_FEATURE_SEARCH_TILES = 1_089; private static final int BIOME_PATCH_SCALE = 32; private static final long SURFACE_WIDTH_SALT = 0x5355524657494454L; @@ -100,6 +103,9 @@ public final class IrisHydrologyRuntime implements AutoCloseable { private final Cache unplannedQueries = Caffeine.newBuilder() .maximumSize(MAXIMUM_CACHE_TILES) .build(); + private final Cache resolvedPolicies = Caffeine.newBuilder() + .maximumSize(MAXIMUM_RESOLVED_POLICIES) + .build(); private final LinkedHashMap terrainSamples; public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) { @@ -483,6 +489,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { public void close() { cache.close(); unplannedQueries.invalidateAll(); + resolvedPolicies.invalidateAll(); routingTerrainSampler.close(); synchronized (terrainSampleLock) { terrainSamples.clear(); @@ -571,9 +578,10 @@ public final class IrisHydrologyRuntime implements AutoCloseable { boolean ocean = naturalSample.ocean(); IrisBiome biome = naturalSample.biome(); IrisRegion region = naturalSample.region(); - EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(context.dimension(), region, biome); + ResolvedPolicy resolvedPolicy = resolvePolicy(region, biome); + EffectiveRiverPolicy policy = resolvedPolicy.policy(); String parentBiomeKey = requireBiomeKey(biome); - List profiles = validProfiles(policy.profiles()); + List profiles = resolvedPolicy.profiles(); int configuredFluidY = settings.underground().minimumFluidY() + (settings.underground().maximumFluidY() - settings.underground().minimumFluidY()) / 2; if (!rivers.getUnderground().getFluidLevel().isFlat()) { @@ -651,6 +659,35 @@ public final class IrisHydrologyRuntime implements AutoCloseable { }; } + private ResolvedPolicy resolvePolicy(IrisRegion region, IrisBiome biome) { + IrisData data = policyData(region, biome); + ResourceLoader loader = data == null ? null : data.getBiomeLoader(); + PolicyKey key = new PolicyKey(region, biome, data, loader); + ResolvedPolicy cached = resolvedPolicies.getIfPresent(key); + if (cached != null) { + return cached; + } + RiverPolicyResolver.Resolution resolution = RiverPolicyResolver.resolveWithStatus(context.dimension(), region, biome); + EffectiveRiverPolicy policy = resolution.policy(); + ResolvedPolicy resolved = new ResolvedPolicy(policy, validProfiles(policy.profiles())); + if (resolution.complete() && data == policyData(region, biome) + && loader == (data == null ? null : data.getBiomeLoader())) { + resolvedPolicies.put(key, resolved); + } + return resolved; + } + + private IrisData policyData(IrisRegion region, IrisBiome biome) { + IrisData data = biome == null ? null : biome.getLoader(); + if (data == null && region != null) { + data = region.getLoader(); + } + if (data == null) { + data = context.dimension().getLoader(); + } + return data; + } + private List validProfiles(List requested) { if (requested == null || requested.isEmpty()) { return List.of(defaultProfileKey); @@ -1230,4 +1267,22 @@ public final class IrisHydrologyRuntime implements AutoCloseable { } return biome.getLoadKey(); } + + private record ResolvedPolicy(EffectiveRiverPolicy policy, List profiles) { + } + + private record PolicyKey(IrisRegion region, IrisBiome biome, IrisData data, ResourceLoader loader) { + @Override + public boolean equals(Object compared) { + return compared instanceof PolicyKey other + && region == other.region && biome == other.biome && data == other.data && loader == other.loader; + } + + @Override + public int hashCode() { + return ((31 * System.identityHashCode(region) + System.identityHashCode(biome)) * 31 + + System.identityHashCode(data)) * 31 + System.identityHashCode(loader); + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 91e5817a5..1a710a15c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -352,6 +352,10 @@ public interface EngineMantle extends MatterGenerator { if (getMantle().hasFlag(x, z, MantleFlag.CLEANED) || !isCovered(x, z)) { return false; } + return cleanupCoveredChunk(x, z, force); + } + + default boolean cleanupCoveredChunk(int x, int z, boolean force) { MantleChunk chunk = getMantle().getChunk(x, z).use(); try { synchronized (chunk) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/TerrainMatterView.java b/core/src/main/java/art/arcane/iris/engine/mantle/TerrainMatterView.java index d350ba442..32af69abc 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/TerrainMatterView.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/TerrainMatterView.java @@ -42,6 +42,30 @@ public final class TerrainMatterView { } } + public static MatterCavern getComposedCavern(MantleChunk chunk, int x, int y, int z) { + if (chunk == null || y < 0) { + return null; + } + synchronized (chunk) { + int section = y >> 4; + if (!chunk.exists(section)) { + return null; + } + Matter matter = chunk.get(section); + if (matter == null) { + return null; + } + PreObjectMatterCell cell = raw(matter, x, y, z, PreObjectMatterCell.class); + HydrologyCaveCell hydrology = cell != null && cell.hydrologyCaptured() + ? cell.hydrology() : raw(matter, x, y, z, HydrologyCaveCell.class); + if (hydrology != null) { + return hydrology.asCavern(); + } + return cell != null && cell.cavernCaptured() + ? cell.cavern() : raw(matter, x, y, z, MatterCavern.class); + } + } + public static void iterate(MantleChunk chunk, Class type, Consumer4 consumer) { Objects.requireNonNull(chunk, "Terrain mantle chunk"); @@ -54,7 +78,8 @@ public final class TerrainMatterView { List> entries = new ArrayList<>(); synchronized (chunk) { chunk.iterate(type, (x, y, z, value) -> { - T original = get(chunk, x, y, z, type); + PreObjectMatterCell cell = raw(chunk, x, y, z, PreObjectMatterCell.class); + T original = cell != null && cell.captures(type) ? cell.original(type) : value; if (original != null) { entries.add(new TerrainEntry<>(x, y, z, original)); } @@ -88,7 +113,10 @@ public final class TerrainMatterView { return null; } Matter matter = chunk.get(section); - return matter == null || !matter.hasSlice(type) ? null - : matter.getSlice(type).get(x & 15, y & 15, z & 15); + return matter == null ? null : raw(matter, x, y, z, type); + } + + private static T raw(Matter matter, int x, int y, int z, Class type) { + return !matter.hasSlice(type) ? null : matter.getSlice(type).get(x & 15, y & 15, z & 15); } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 444296d6b..27b987419 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -153,6 +153,7 @@ public class IrisCarveModifier extends EngineAssignedModifier { HydrologyCaveCell hydrology = dataIfPresent( mantleChunk, rx, yy, rz, HydrologyCaveCell.class); @@ -167,7 +168,7 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, int x, int y, int z) { - MatterCavern baseline = dataIfPresent(mantleChunk, x, y, z, MatterCavern.class); - HydrologyCaveCell hydrology = dataIfPresent(mantleChunk, x, y, z, HydrologyCaveCell.class); - return composeCavern(baseline, hydrology); + return TerrainMatterView.getComposedCavern(mantleChunk, x, y, z); } private static T dataIfPresent(MantleChunk mantleChunk, int x, int y, int z, Class type) { @@ -875,12 +874,15 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomes = new Long2ObjectOpenHashMap<>(256); Map customBiomes = new HashMap<>(); try { - for (int localX = 0; localX < output.getWidth(); localX++) { - for (int localZ = 0; localZ < output.getDepth(); localZ++) { + int width = output.getWidth(); + int depth = output.getDepth(); + int height = output.getHeight(); + for (int localX = 0; localX < width; localX++) { + for (int localZ = 0; localZ < depth; localZ++) { int worldX = blockX + localX; int worldZ = blockZ + localZ; int floor = -1; - for (int y = 1; y < output.getHeight(); y++) { + for (int y = 1; y < height; y++) { PlatformBlockState state = output.getRaw(localX, y, localZ); if (B.isSolid(state)) { if (floor >= 0) { @@ -1093,30 +1095,18 @@ public class IrisCarveModifier extends EngineAssignedModifier sampleCaveBiome(caveBiomeCache, x + CAVE_BIOME_BLEND_RADIUS, y, z, resolverState); + case 1 -> sampleCaveBiome(caveBiomeCache, x - CAVE_BIOME_BLEND_RADIUS, y, z, resolverState); + case 2 -> sampleCaveBiome(caveBiomeCache, x, y, z + CAVE_BIOME_BLEND_RADIUS, resolverState); + default -> sampleCaveBiome(caveBiomeCache, x, y, z - CAVE_BIOME_BLEND_RADIUS, resolverState); + }; + return neighbor != null ? neighbor : center; } private IrisBiome sampleCaveBiome(Long2ObjectOpenHashMap caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) { diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java index 5872cf1dc..2988c77f0 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisDepositModifier.java @@ -83,7 +83,12 @@ public class IrisDepositModifier extends EngineAssignedModifier prepared[index] = prepare(plan, firstAttempt, limit, x, z, null, context), context)); + () -> prepared[index] = prepare(plan, firstAttempt, limit, x, z, null, context), preparation, index)); if (pending == prepared.length) { - completeBatches(burst, prepared, chunk, terrain, x, z, context); + completeBatches(burst, prepared, chunk, terrain, x, z, preparation); pending = 0; } } } - completeBatches(burst, prepared, chunk, terrain, x, z, context); + completeBatches(burst, prepared, chunk, terrain, x, z, preparation); + } catch (Throwable failure) { + callerFailure = failure; + throw failure; } finally { // complete() must run before release() even when queueing throws — already // submitted burst tasks must never write into a released chunk. try { burst.complete(); + if (callerFailure != null) { + preparation.takeFailure(callerFailure); + } } finally { chunk.release(); } @@ -112,29 +123,37 @@ public class IrisDepositModifier extends EngineAssignedModifier terrain, int x, int z, ChunkContext context) { + Hunk terrain, int x, int z, PreparationContext preparation) { burst.complete(); - try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), context.getGenerationSessionId(), context)) { + Throwable failure = preparation.takeFailure(null); + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure != null) { + throw new IllegalStateException("Deposit preparation failed", failure); + } + try (IrisContext.Scope chunkScope = IrisContext.open(getEngine(), preparation.chunk().getGenerationSessionId(), preparation.chunk())) { for (int i = 0; i < prepared.length; i++) { PreparedDeposit deposit = prepared[i]; prepared[i] = null; if (deposit != null) { - place(deposit, chunk, terrain, x, z, null, context); + place(deposit, chunk, terrain, x, z, null, preparation.chunk()); } } } } - private Runnable scopedDepositTask(Runnable task, ChunkContext context) { - IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine - && irisEngine.hasGenerationRuntimeScope() ? irisEngine : null; - IrisEngine.GenerationRuntimeBinding binding = generationEngine == null - ? null : generationEngine.captureGenerationRuntimeBinding(); + private Runnable scopedDepositTask(Runnable task, PreparationContext preparation, int index) { return () -> { - try (IrisEngine.GenerationRuntimeScope runtimeScope = generationEngine == null - ? null : generationEngine.openGenerationRuntimeScope(binding); - IrisContext.Scope chunkScope = IrisContext.open(getEngine(), context.getGenerationSessionId(), context)) { + try (IrisEngine.GenerationRuntimeScope runtimeScope = preparation.engine() == null + ? null : preparation.engine().openGenerationRuntimeScope(preparation.binding()); + IrisContext.Scope chunkScope = IrisContext.open(getEngine(), preparation.chunk().getGenerationSessionId(), preparation.chunk())) { task.run(); + } catch (Throwable failure) { + preparation.failures()[index] = failure; } }; } @@ -498,6 +517,34 @@ public class IrisDepositModifier extends EngineAssignedModifier>> 31); } + private record PreparationContext(ChunkContext chunk, IrisEngine engine, IrisEngine.GenerationRuntimeBinding binding, + Throwable[] failures) { + private Throwable takeFailure(Throwable primary) { + for (int i = 0; i < failures.length; i++) { + Throwable failure = failures[i]; + failures[i] = null; + if (failure == null || failure == primary) { + continue; + } + if (primary == null) { + primary = failure; + } else if (!isSuppressed(primary, failure)) { + primary.addSuppressed(failure); + } + } + return primary; + } + + private boolean isSuppressed(Throwable primary, Throwable failure) { + for (Throwable suppressed : primary.getSuppressed()) { + if (suppressed == failure) { + return true; + } + } + return false; + } + } + private record DepositPlan(IrisDepositGenerator generator, long seed, boolean ore, int attempts) { } diff --git a/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java index 186f95436..4a0e65e46 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java +++ b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java @@ -90,6 +90,16 @@ final class CompatPools { String subjectType, String subjectKey, String field) { + return surviving(loader, keys, data, subjectType, subjectKey, field, null); + } + + static KList surviving(ResourceLoader loader, + List keys, + IrisData data, + String subjectType, + String subjectKey, + String field, + Runnable unresolvedReference) { KList kept = new KList<>(); if (keys == null) { @@ -104,6 +114,9 @@ final class CompatPools { for (int index = 0; index < keys.size(); index++) { String key = keys.get(index); T loaded = loader.load(key); + if (loaded == null && unresolvedReference != null) { + unresolvedReference.run(); + } if (loaded != null && loaded.isCompatExcluded()) { drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, null); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java index 65ecaa3d8..9590d8afa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java @@ -28,7 +28,7 @@ public final class IrisDimensionCarvingResolver { public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) { State resolvedState = state == null ? threadState() : state; - resolvedState.bind(engine); + IrisData data = resolvedState.bind(engine); if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) { return resolvedState.rootEntriesByWorldY.get(worldY); } @@ -42,7 +42,7 @@ public final class IrisDimensionCarvingResolver { IrisDimensionCarvingEntry resolved = null; for (IrisDimensionCarvingEntry entry : entries) { - if (!isRootCandidate(engine, entry, worldY, resolvedState)) { + if (!isRootCandidate(data, entry, worldY, resolvedState)) { continue; } @@ -59,12 +59,12 @@ public final class IrisDimensionCarvingResolver { public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) { State resolvedState = state == null ? threadState() : state; - resolvedState.bind(engine); + IrisData data = resolvedState.bind(engine); if (rootEntry == null) { return null; } - IrisBiome rootBiome = resolveEntryBiome(engine, rootEntry, resolvedState); + IrisBiome rootBiome = resolveBoundEntryBiome(data, rootEntry, resolvedState); if (rootBiome == null) { return null; } @@ -78,7 +78,7 @@ public final class IrisDimensionCarvingResolver { IrisDimensionCarvingEntry current = rootEntry; int depth = remainingDepth; while (depth > 0) { - IrisDimensionCarvingEntry selected = selectChild(engine, current, worldX, worldZ, entryIndex, resolvedState); + IrisDimensionCarvingEntry selected = selectChild(engine, data, current, worldX, worldZ, entryIndex, resolvedState); if (selected == null || selected == current) { break; } @@ -107,17 +107,20 @@ public final class IrisDimensionCarvingResolver { return entry.getRealBiome(engine.getData()); } - state.bind(engine); + return resolveBoundEntryBiome(state.bind(engine), entry, state); + } + + private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) { if (state.biomeCache.containsKey(entry)) { return state.biomeCache.get(entry); } - IrisBiome biome = entry.getRealBiome(engine.getData()); + IrisBiome biome = entry.getRealBiome(data); state.biomeCache.put(entry, biome); return biome; } - private static boolean isRootCandidate(Engine engine, IrisDimensionCarvingEntry entry, int worldY, State state) { + private static boolean isRootCandidate(IrisData data, IrisDimensionCarvingEntry entry, int worldY, State state) { if (entry == null || !entry.isEnabled()) { return false; } @@ -127,11 +130,12 @@ public final class IrisDimensionCarvingResolver { return false; } - return resolveEntryBiome(engine, entry, state) != null; + return resolveBoundEntryBiome(data, entry, state) != null; } private static IrisDimensionCarvingEntry selectChild( Engine engine, + IrisData data, IrisDimensionCarvingEntry parent, int worldX, int worldZ, @@ -143,14 +147,14 @@ public final class IrisDimensionCarvingResolver { return parent; } - IrisBiome parentBiome = resolveEntryBiome(engine, parent, state); + IrisBiome parentBiome = resolveBoundEntryBiome(data, parent, state); if (parentBiome == null) { return parent; } IrisRaritySelection selectionPlan = state.selectionPlans.get(parent); if (selectionPlan == null) { - selectionPlan = buildSelectionPlan(engine, parent, parentBiome, entryIndex, state); + selectionPlan = buildSelectionPlan(data, parent, parentBiome, entryIndex, state); state.selectionPlans.put(parent, selectionPlan); } @@ -159,7 +163,7 @@ public final class IrisDimensionCarvingResolver { } long seed = resolveChildSeed(engine, state); - CNG childGenerator = parent.getChildrenGenerator(seed, engine.getData()); + CNG childGenerator = parent.getChildrenGenerator(seed, data); double sample = childGenerator.noiseFast2D(worldX, worldZ); CarvingChoice selected = selectionPlan.select(sample); if (selected == null || selected.entry == null) { @@ -170,7 +174,7 @@ public final class IrisDimensionCarvingResolver { } private static IrisRaritySelection buildSelectionPlan( - Engine engine, + IrisData data, IrisDimensionCarvingEntry parent, IrisBiome parentBiome, Map entryIndex, @@ -189,7 +193,7 @@ public final class IrisDimensionCarvingResolver { continue; } - IrisBiome childBiome = resolveEntryBiome(engine, child, state); + IrisBiome childBiome = resolveBoundEntryBiome(data, child, state); if (childBiome == null) { continue; } @@ -256,13 +260,13 @@ public final class IrisDimensionCarvingResolver { private Map entryIndex; private Long childSeed; - private void bind(Engine engine) { + private IrisData bind(Engine engine) { IrisDimension dimension = engine.getDimension(); IrisData data = engine.getData(); if (references(engineIdentity, engine) && references(dimensionIdentity, dimension) && references(dataIdentity, data)) { - return; + return data; } engineIdentity = new WeakReference<>(engine); dimensionIdentity = new WeakReference<>(dimension); @@ -272,6 +276,7 @@ public final class IrisDimensionCarvingResolver { biomeCache.clear(); entryIndex = null; childSeed = null; + return data; } private static boolean references(WeakReference identity, Object value) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java index eb65881f3..ebacc571f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -105,12 +105,16 @@ public class IrisRiverPolicy { * selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null. */ public KList compatBiomes(KList declared, IrisData data, String field) { + return compatBiomes(declared, data, field, null); + } + + public KList compatBiomes(KList declared, IrisData data, String field, Runnable unresolvedReference) { if (declared == null) { return null; } return CompatPools.surviving(data == null ? null : data.getBiomeLoader(), declared, data, - "river policy", "", field); + "river policy", "", field, unresolvedReference); } public KSet getAllBiomeIds() { diff --git a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java index cdf427cf5..c9b7244d1 100644 --- a/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java +++ b/core/src/main/java/art/arcane/iris/util/project/noise/CNG.java @@ -1088,7 +1088,6 @@ public class CNG { } private double getSignedNoise(double x, double z) { - ensureFastPathState(); double scl = effectiveScale; NoiseGenerator localGenerator = generator; CNG localFracture = fracture; @@ -1103,7 +1102,6 @@ public class CNG { } private double getSignedNoise(double x, double y, double z) { - ensureFastPathState(); double scl = effectiveScale; NoiseGenerator localGenerator = generator; CNG localFracture = fracture; diff --git a/core/src/test/java/art/arcane/iris/engine/IrisCarveWallPaletteScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisCarveWallPaletteScopeTest.java new file mode 100644 index 000000000..84423ea8f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisCarveWallPaletteScopeTest.java @@ -0,0 +1,245 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.TerrainMatterView; +import art.arcane.iris.engine.modifier.IrisCarveModifier; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomePaletteLayer; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.function.Consumer4; +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.MatterCavern; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +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.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +public class IrisCarveWallPaletteScopeTest { + @Test + public void naturalWallsUseOneSelectedDataLookup() throws Exception { + verifyWalls(Mode.NATURAL); + } + + @Test + public void contentWallsRestoreDataAfterNestedSampling() throws Exception { + verifyWalls(Mode.CONTENT); + } + + @Test + public void detachedWallsUseDetachedData() throws Exception { + verifyWalls(Mode.DETACHED); + } + + @Test + public void assemblyWallsUseUnpublishedData() throws Exception { + verifyWalls(Mode.ASSEMBLY); + } + + private static void verifyWalls(Mode mode) throws Exception { + PlatformBlockState air = block("minecraft:cave_air", false); + PlatformBlockState stone = block("minecraft:stone", true); + PlatformBlockState wall = block("minecraft:calcite", true); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState(anyString())).thenReturn(air); + blocks.when(() -> B.isSolid(any())).thenAnswer(call -> + call.getArgument(0).isSolid()); + Fixture fixture = new Fixture(air, wall); + GenerationRuntime selected = mode == Mode.DETACHED ? fixture.detached : fixture.active; + IrisData expectedData = mode == Mode.ASSEMBLY ? fixture.assemblyData : selected.data(); + IrisComplex expectedComplex = mode == Mode.ASSEMBLY ? fixture.assemblyComplex : selected.complex(); + fixture.expectedData = expectedData; + doReturn(expectedComplex).when(fixture.context).getComplex(); + doReturn(mode == Mode.NATURAL).when(fixture.context).isNaturalTerrain(); + Hunk output = Hunk.newArrayHunk(16, 64, 16); + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 64; y++) { + for (int z = 0; z < 16; z++) { + output.setRaw(x, y, z, stone); + } + } + } + try (IrisEngine.GenerationRuntimeScope generation = fixture.engine.openGenerationRuntimeScope( + new IrisEngine.GenerationRuntimeBinding(fixture.engine, selected)); + IrisContext.Scope context = IrisContext.open(fixture.engine, 71L, fixture.context); + MockedStatic terrain = mockStatic(TerrainMatterView.class)) { + if (mode == Mode.ASSEMBLY) { + fixture.engine.runtimeAssembly.set(fixture.assembly); + } + terrain.when(() -> TerrainMatterView.iterate(eq(fixture.chunk), eq(MatterCavern.class), any())) + .thenAnswer(call -> { + Consumer4 consumer = call.getArgument(2); + for (int coordinate : new int[]{4, 8, 12}) { + consumer.accept(coordinate, 10, coordinate, new MatterCavern(true, "", (byte) 0)); + } + return null; + }); + try { + assertSame(expectedData, fixture.engine.getData()); + assertEquals(mode == Mode.NATURAL, expectedComplex.isNaturalTerrainContext()); + fixture.modifier.onModify(-2, 3, output, false, fixture.context); + assertSame(expectedData, fixture.engine.getData()); + assertEquals(12, fixture.paletteCalls.get()); + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 64; y++) { + for (int z = 0; z < 16; z++) { + PlatformBlockState expected = stone; + for (int coordinate : new int[]{4, 8, 12}) { + if (y == 10 && x == coordinate && z == coordinate) { + expected = air; + } else if (y == 10 && Math.abs(x - coordinate) + Math.abs(z - coordinate) == 1) { + expected = wall; + } + } + assertSame(expected, output.getRaw(x, y, z)); + } + } + } + verify(fixture.chunk).release(); + assertEquals(1, fixture.dataLookups.get()); + } finally { + fixture.engine.runtimeAssembly.remove(); + } + } + assertSame(fixture.active.data(), fixture.engine.getData()); + } + } + + private static PlatformBlockState block(String key, boolean solid) { + PlatformBlockState block = mock(PlatformBlockState.class); + doReturn(key).when(block).key(); + doReturn(solid).when(block).isSolid(); + return block; + } + + private static void field(Object owner, Class type, String name, Object value) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(owner, value); + } + + private static GenerationRuntime runtime(IrisData data, IrisComplex complex, EngineMantle mantle) { + GenerationRuntime runtime = mock(GenerationRuntime.class); + doReturn(data).when(runtime).data(); + doReturn(complex).when(runtime).complex(); + doReturn(mantle).when(runtime).mantle(); + doReturn(mock(IrisDimension.class)).when(runtime).dimension(); + return runtime; + } + + private enum Mode { + NATURAL, + CONTENT, + DETACHED, + ASSEMBLY + } + + private static final class Fixture { + private final IrisEngine engine = mock(IrisEngine.class, CALLS_REAL_METHODS); + private final IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + private final ChunkContext context = mock(ChunkContext.class); + private final MantleChunk chunk; + private final GenerationRuntime active; + private final GenerationRuntime detached; + private final IrisData assemblyData = mock(IrisData.class); + private final IrisComplex assemblyComplex = mock(IrisComplex.class); + private final EngineRuntimeBuilder.RuntimeAssembly assembly; + private final AtomicInteger dataLookups = new AtomicInteger(); + private final AtomicInteger paletteCalls = new AtomicInteger(); + private IrisData expectedData; + + @SuppressWarnings("unchecked") + private Fixture(PlatformBlockState air, PlatformBlockState wall) throws Exception { + Mantle mantle = mock(Mantle.class); + doReturn(new KMap<>()).when(mantle).getLoadedRegions(); + chunk = mock(MantleChunk.class); + EngineMantle engineMantle = mock(EngineMantle.class); + doReturn(mantle).when(engineMantle).getMantle(); + doReturn(chunk).when(mantle).getChunk(-2, 3); + doReturn(chunk).when(chunk).use(); + active = runtime(mock(IrisData.class), mock(IrisComplex.class), engineMantle); + detached = runtime(mock(IrisData.class), mock(IrisComplex.class), engineMantle); + field(engine, IrisEngine.class, "lifecycleLock", new Object()); + field(engine, IrisEngine.class, "runtimeAssembly", new ThreadLocal()); + field(engine, IrisEngine.class, "biomeEnvironmentScopes", new ThreadLocal<>()); + field(engine, IrisEngine.class, "generationRuntimeScopes", new GenerationRuntimeScopeState()); + field(engine, IrisEngine.class, "detachedGenerationRuntimes", Collections.newSetFromMap(new IdentityHashMap<>())); + field(engine, IrisEngine.class, "retiringGenerationRuntimes", Collections.newSetFromMap(new IdentityHashMap<>())); + engine.detachedGenerationRuntimes.add(detached); + EngineRuntime published = mock(EngineRuntime.class); + doReturn(active).when(published).generation(); + engine.runtime = published; + doReturn(IrisWorld.builder().minHeight(-64).maxHeight(0).build()).when(engine).getWorld(); + doReturn(64).when(engine).getHeight(); + doReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)).when(engine).getMetrics(); + doReturn(48).when(context).getRoundedHeight(anyInt(), anyInt()); + IrisComplex activeComplex = active.complex(); + IrisComplex detachedComplex = detached.complex(); + doCallRealMethod().when(activeComplex).isNaturalTerrainContext(); + doCallRealMethod().when(detachedComplex).isNaturalTerrainContext(); + doCallRealMethod().when(assemblyComplex).isNaturalTerrainContext(); + EngineTarget assemblyTarget = mock(EngineTarget.class); + doReturn(engine.getWorld()).when(assemblyTarget).getWorld(); + doReturn(assemblyData).when(assemblyTarget).getData(); + doReturn(mock(IrisDimension.class)).when(assemblyTarget).getDimension(); + assembly = new EngineRuntimeBuilder.RuntimeAssembly(19, assemblyTarget); + assembly.complex = assemblyComplex; + assembly.mantle = engineMantle; + IrisBiome biome = mock(IrisBiome.class); + IrisBiomePaletteLayer palette = mock(IrisBiomePaletteLayer.class); + doReturn(palette).when(biome).getWall(); + doAnswer(call -> { + assertSame(expectedData, call.getArgument(4)); + paletteCalls.incrementAndGet(); + return wall; + }).when(palette).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class)); + doAnswer(call -> { + try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope( + new IrisEngine.GenerationRuntimeBinding(engine, active))) { + assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData()); + } + return biome; + }).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + doReturn(engine).when(modifier).getEngine(); + doAnswer(call -> { + dataLookups.incrementAndGet(); + return call.callRealMethod(); + }).when(modifier).getData(); + field(modifier, IrisCarveModifier.class, "rng", new RNG(319L)); + field(modifier, IrisCarveModifier.class, "AIR", air); + field(modifier, IrisCarveModifier.class, "LAVA", air); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java index 06012c995..af5ffa731 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -23,7 +23,9 @@ import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimensionCarvingEntry; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; +import art.arcane.iris.engine.object.IrisRange; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -32,7 +34,9 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; import org.junit.Test; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -90,6 +94,51 @@ public class IrisEngineGenerationRuntimeScopeTest { IrisPlatforms.unbind(); } + @Test + public void carvingResolverRestoresNestedRuntimeDefinitions() throws Exception { + RuntimeFixture active = runtime(1, 1D, 1D, 1D); + RuntimeFixture first = runtime(2, 2D, 2D, 2D); + RuntimeFixture second = runtime(3, 3D, 3D, 3D); + IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); + IrisDimensionCarvingEntry root = mock(IrisDimensionCarvingEntry.class); + IrisDimensionCarvingEntry child = mock(IrisDimensionCarvingEntry.class); + when(root.isEnabled()).thenReturn(true); + when(root.getWorldYRange()).thenReturn(new IrisRange(-64, 320)); + when(root.getChildRecursionDepth()).thenReturn(1); + when(root.getChildren()).thenReturn(new KList<>("child")); + when(child.isEnabled()).thenReturn(true); + CNG generator = mock(CNG.class); + when(generator.noiseFast2D(19D, -3D)).thenReturn(1D); + IrisBiome[] expected = new IrisBiome[3]; + RuntimeFixture[] fixtures = {active, first, second}; + for (int index = 0; index < fixtures.length; index++) { + RuntimeFixture fixture = fixtures[index]; + IrisBiome parentBiome = mock(IrisBiome.class); + IrisBiome childBiome = mock(IrisBiome.class); + when(parentBiome.getRarity()).thenReturn(1); + when(childBiome.getRarity()).thenReturn(1); + when(root.getRealBiome(fixture.data)).thenReturn(parentBiome); + when(child.getRealBiome(fixture.data)).thenReturn(childBiome); + when(root.getChildrenGenerator(fixture.runtime.seedManager().getCarve() ^ 0x9E3779B97F4A7C15L, + fixture.data)).thenReturn(generator); + when(fixture.dimension.getCarving()).thenReturn(new KList<>(root)); + when(fixture.dimension.getCarvingEntryIndex()).thenReturn(Map.of("child", child)); + expected[index] = childBiome; + } + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + assertSame(expected[0], carvingBiome(engine, state)); + try (IrisEngine.GenerationRuntimeScope outer = engine.openGenerationRuntimeScope( + detachedBinding(engine, first.runtime))) { + assertSame(expected[1], carvingBiome(engine, state)); + try (IrisEngine.GenerationRuntimeScope inner = engine.openGenerationRuntimeScope( + detachedBinding(engine, second.runtime))) { + assertSame(expected[2], carvingBiome(engine, state)); + } + assertSame(expected[1], carvingBiome(engine, state)); + } + assertSame(expected[0], carvingBiome(engine, state)); + } + @Test @SuppressWarnings("unchecked") public void naturalCaveSamplesKeepSelectedRuntimeAcrossChunkEdges() throws Exception { @@ -813,6 +862,12 @@ public class IrisEngineGenerationRuntimeScopeTest { assertSame(active.dimension, engine.getDimension()); } + private static IrisBiome carvingBiome(IrisEngine engine, IrisDimensionCarvingResolver.State state) { + IrisDimensionCarvingEntry root = IrisDimensionCarvingResolver.resolveRootEntry(engine, 80, state); + IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, -3, state); + return IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state); + } + private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) { GenerationHistory history = mock(GenerationHistory.class); when(history.retainRuntime()).thenReturn(mock(GenerationAdmission.RuntimeLease.class)); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryAdmissionTest.java index 5058dc85d..fb75e2fac 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryAdmissionTest.java @@ -7,9 +7,19 @@ import org.mockito.MockedStatic; import java.io.IOException; import java.lang.reflect.Field; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; import java.nio.channels.FileChannel; +import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -23,6 +33,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -55,10 +66,9 @@ public class GenerationHistoryAdmissionTest { }); admitted.get(1, TimeUnit.SECONDS); assertFalse(persisted.isDone()); - Future observed = executor.submit(() -> history.semantics(1, 2)); - assertThrows(TimeoutException.class, () -> observed.get(100, TimeUnit.MILLISECONDS)); + Future> observed = executor.submit(() -> history.semantics(1, 2)); + assertTrue(observed.get(1, TimeUnit.SECONDS).isEmpty()); release.countDown(); - observed.get(5, TimeUnit.SECONDS); assertTrue(persisted.get(5, TimeUnit.SECONDS)); assertEquals(claim, history.semantics(1, 2).orElseThrow()); assertEquals(claim, GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()).get(1, 2).orElseThrow()); @@ -165,12 +175,212 @@ public class GenerationHistoryAdmissionTest { } } + @Test + public void queuedClaimsShareOneForceAndRetainIndividualValidation() throws Exception { + GenerationHistory history = history(); + GenerationAdmission.RuntimeLease runtime = history.retainRuntime(); + List stages = new ArrayList<>(); + for (int x : new int[]{1, 2, 3, 1, 1, 4, 32, 33}) { + stages.add(history.openStage(x, 0)); + } + history.claimGeneratedSemantics(stages.getFirst(), claim(stages.getFirst())); + List workers = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(stages.size(), task -> { + Thread thread = new Thread(task); + workers.add(thread); + return thread; + }); + AtomicInteger forces = new AtomicInteger(); + List> results = new ArrayList<>(); + try { + synchronized (history) { + for (int index = 0; index < stages.size(); index++) { + GenerationHistory.GenerationStage stage = stages.get(index); + ChunkGenerationSemantics update = index == 4 + ? ChunkGenerationSemantics.builder(1, 0, stage.activation().activationId()) + .addSurfaceBiome("iris:conflict").seal().build() + : claim(stage); + results.add(executor.submit(() -> persistCountingForces(history, stage, update, forces))); + awaitBlockedOnHistory(history, workers, index + 1); + } + stages.get(5).close(); + } + assertFalse(results.get(0).get(5, TimeUnit.SECONDS)); + assertTrue(results.get(1).get(5, TimeUnit.SECONDS)); + assertTrue(results.get(2).get(5, TimeUnit.SECONDS)); + assertFalse(results.get(3).get(5, TimeUnit.SECONDS)); + assertTrue(assertThrows(ExecutionException.class, () -> results.get(4).get(5, TimeUnit.SECONDS)) + .getCause() instanceof IllegalStateException); + assertTrue(assertThrows(ExecutionException.class, () -> results.get(5).get(5, TimeUnit.SECONDS)) + .getCause() instanceof IllegalStateException); + assertTrue(results.get(6).get(5, TimeUnit.SECONDS)); + assertTrue(results.get(7).get(5, TimeUnit.SECONDS)); + assertEquals(2, forces.get()); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()); + assertEquals(5, loaded.recordCount()); + assertEquals(claim(stages.getFirst()), loaded.get(1, 0).orElseThrow()); + assertTrue(loaded.get(4, 0).isEmpty()); + } finally { + drain(executor); + for (GenerationHistory.GenerationStage stage : stages) { + stage.close(); + } + runtime.close(); + } + } + + @Test + public void fullClaimQueueRetainsEveryAdmittedRequest() throws Exception { + GenerationHistory history = history(); + GenerationAdmission.RuntimeLease runtime = history.retainRuntime(); + List stages = new ArrayList<>(); + List> results = new ArrayList<>(); + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + CountDownLatch started = new CountDownLatch(132); + try { + synchronized (history) { + for (int x = 0; x < 132; x++) { + GenerationHistory.GenerationStage stage = history.openStage(x, 0); + stages.add(stage); + results.add(executor.submit(() -> { + started.countDown(); + return history.claimGeneratedSemantics(stage, claim(stage)); + })); + } + assertTrue(started.await(5, TimeUnit.SECONDS)); + Field queueField = GenerationHistory.class.getDeclaredField("pendingSemanticClaims"); + queueField.setAccessible(true); + ArrayBlockingQueue queue = (ArrayBlockingQueue) queueField.get(history); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (queue.remainingCapacity() != 0 && System.nanoTime() < deadline) { + Thread.sleep(1L); + } + assertEquals(0, queue.remainingCapacity()); + assertEquals(128, queue.size()); + } + for (Future result : results) { + assertTrue(result.get(10, TimeUnit.SECONDS)); + } + assertEquals(132, GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()).recordCount()); + } finally { + drain(executor); + for (GenerationHistory.GenerationStage stage : stages) { + stage.close(); + } + runtime.close(); + } + } + + @Test + public void unexpectedLaterBatchFailureReachesWaitersAndKeepsEarlierSuccess() throws Exception { + GenerationHistory history = history(); + GenerationAdmission.RuntimeLease runtime = history.retainRuntime(); + List stages = new ArrayList<>(); + for (int x : new int[]{0, 32, 32, 64}) { + stages.add(history.openStage(x, 0)); + } + List workers = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(stages.size(), task -> { + Thread thread = new Thread(task); + workers.add(thread); + return thread; + }); + IllegalStateException failure = new IllegalStateException("Unexpected second journal failure"); + List> results = new ArrayList<>(); + try { + synchronized (history) { + for (int index = 0; index < stages.size(); index++) { + GenerationHistory.GenerationStage stage = stages.get(index); + results.add(executor.submit(() -> persistFailingRegion(history, stage, failure))); + awaitBlockedOnHistory(history, workers, index + 1); + } + } + assertTrue(results.getFirst().get(5, TimeUnit.SECONDS)); + for (int index = 1; index < results.size(); index++) { + Future result = results.get(index); + assertSame(failure, assertThrows(ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)).getCause()); + } + assertThrows(IllegalStateException.class, () -> history.semantics(0, 0)); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()); + assertEquals(1, loaded.recordCount()); + assertEquals(claim(stages.getFirst()), loaded.get(0, 0).orElseThrow()); + } finally { + drain(executor); + for (GenerationHistory.GenerationStage stage : stages) { + stage.close(); + } + runtime.close(); + } + } + + private static boolean persistFailingRegion(GenerationHistory history, GenerationHistory.GenerationStage stage, + IllegalStateException failure) throws Exception { + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().equals("r.1.0.iswal")) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + doAnswer(write -> { + throw failure; + }).when(intercepted).write(any(ByteBuffer.class)); + return intercepted; + })) { + return history.claimGeneratedSemantics(stage, claim(stage)); + } + } + + private static boolean persistCountingForces(GenerationHistory history, GenerationHistory.GenerationStage stage, + ChunkGenerationSemantics claim, AtomicInteger forces) throws Exception { + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().endsWith(".iswal")) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + doAnswer(force -> { + forces.incrementAndGet(); + source.force(true); + return null; + }).when(intercepted).force(true); + return intercepted; + })) { + return history.claimGeneratedSemantics(stage, claim); + } + } + + private static void awaitBlockedOnHistory(GenerationHistory history, List workers, int count) + throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (System.nanoTime() < deadline) { + int blocked = 0; + for (Thread worker : workers) { + ThreadInfo info = ManagementFactory.getThreadMXBean().getThreadInfo(worker.threadId()); + if (info != null && info.getThreadState() == Thread.State.BLOCKED + && info.getLockInfo().getIdentityHashCode() == System.identityHashCode(history)) { + blocked++; + } + } + if (blocked == count) { + return; + } + Thread.sleep(1L); + } + throw new AssertionError("Semantic claim callers did not queue behind the History monitor"); + } + private static boolean persistPaused(GenerationHistory history, GenerationHistory.GenerationStage stage, ChunkGenerationSemantics claim, CountDownLatch entered, CountDownLatch release) throws Exception { try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { FileChannel channel = (FileChannel) invocation.callRealMethod(); Path path = invocation.getArgument(0); - if (!path.getFileName().toString().endsWith(".iswal")) { + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().endsWith(".iswal")) { return channel; } FileChannel intercepted = mock(FileChannel.class, delegatesTo(channel)); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticBatchWriteTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticBatchWriteTest.java new file mode 100644 index 000000000..443c043d3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticBatchWriteTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +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.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class GenerationSemanticBatchWriteTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void batchedJournalRetainsSerialBytesAndPerClaimResults() throws Exception { + Path serialRoot = temporaryFolder.newFolder().toPath(); + Path batchedRoot = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex serial = GenerationSemanticIndex.initialize(serialRoot); + GenerationSemanticIndex batched = GenerationSemanticIndex.initialize(batchedRoot); + List claims = List.of( + pending(1, "first"), pending(2, "second"), pending(1, "first"), pending(1, "conflict")); + serial.claimAndPersist(claims.get(0).update); + serial.claimAndPersist(claims.get(1).update); + + batched.claimAndPersistBatch(claims); + + assertTrue(claims.get(0).result()); + assertTrue(claims.get(1).result()); + assertFalse(claims.get(2).result()); + assertThrows(IllegalStateException.class, claims.get(3)::result); + assertArrayEquals(Files.readAllBytes(serial.storageDirectory().resolve("r.0.0.iswal")), + Files.readAllBytes(batched.storageDirectory().resolve("r.0.0.iswal"))); + assertEquals(serial.recordsSnapshot(), GenerationSemanticIndex.loadRequired(batchedRoot).recordsSnapshot()); + } + + @Test + public void partialBatchRollbackFailsDependentDuplicatesAndKeepsDurableDuplicates() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.claimAndPersist(pending(0, "durable").update); + Path journal = index.storageDirectory().resolve("r.0.0.iswal"); + byte[] original = Files.readAllBytes(journal); + List claims = List.of( + pending(1, "first"), pending(1, "first"), pending(2, "second"), pending(0, "durable")); + IOException failure = new IOException("Second frame write failed"); + AtomicInteger writes = new AtomicInteger(); + try (MockedStatic ignored = channels((path, source, intercepted) -> + doAnswer(invocation -> { + ByteBuffer bytes = invocation.getArgument(0); + if (writes.incrementAndGet() == 2) { + int limit = bytes.limit(); + bytes.limit(bytes.position() + 7); + source.write(bytes); + bytes.limit(limit); + throw failure; + } + return source.write(bytes); + }).when(intercepted).write(any(ByteBuffer.class)))) { + index.claimAndPersistBatch(claims); + } + for (int indexInBatch = 0; indexInBatch < 3; indexInBatch++) { + assertSame(failure, assertThrows(IOException.class, claims.get(indexInBatch)::result)); + } + assertFalse(claims.get(3).result()); + assertArrayEquals(original, Files.readAllBytes(journal)); + assertEquals(1, GenerationSemanticIndex.loadRequired(root).recordCount()); + List retry = List.of(pending(1, "first"), pending(2, "second")); + index.claimAndPersistBatch(retry); + assertTrue(retry.get(0).result()); + assertTrue(retry.get(1).result()); + assertEquals(3, GenerationSemanticIndex.loadRequired(root).recordCount()); + } + + @Test + public void failedRegionDoesNotUndoOtherDurableRegionResults() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + List claims = List.of( + pending(0, "first"), pending(32, "failed"), pending(64, "last")); + IOException failure = new IOException("Middle region force failed"); + AtomicInteger forces = new AtomicInteger(); + try (MockedStatic ignored = channels((path, source, intercepted) -> { + if (!path.getFileName().toString().equals("r.1.0.iswal")) { + return; + } + doAnswer(invocation -> { + if (forces.incrementAndGet() == 1) { + throw failure; + } + source.force(true); + return null; + }).when(intercepted).force(true); + })) { + index.claimAndPersistBatch(claims); + } + assertTrue(claims.get(0).result()); + assertSame(failure, assertThrows(IOException.class, claims.get(1)::result)); + assertTrue(claims.get(2).result()); + GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(root); + assertEquals(2, loaded.recordCount()); + assertTrue(loaded.get(32, 0).isEmpty()); + } + + @Test + public void unexpectedBatchFailureCompletesEveryClaimAndPoisonsFurtherReads() throws Exception { + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(temporaryFolder.newFolder().toPath()); + List claims = List.of( + pending(0, "first"), pending(0, "first"), pending(32, "other-region")); + IllegalStateException failure = new IllegalStateException("Unexpected channel failure"); + try (MockedStatic ignored = channels((path, source, intercepted) -> + doAnswer(invocation -> { + throw failure; + }).when(intercepted).write(any(ByteBuffer.class)))) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> index.claimAndPersistBatch(claims))); + } + for (GenerationSemanticIndex.Claim claim : claims) { + assertTrue(claim.completed); + assertSame(failure, assertThrows(IllegalStateException.class, claim::result)); + } + assertThrows(IllegalStateException.class, () -> index.get(0, 0)); + assertThrows(IOException.class, () -> index.claimAndPersist(pending(1, "later").update)); + } + + private static GenerationSemanticIndex.Claim pending(int chunkX, String biome) { + return new GenerationSemanticIndex.Claim(ChunkGenerationSemantics.builder(chunkX, 0, 1L) + .addSurfaceBiome("iris:" + biome).seal().build()); + } + + private static MockedStatic channels(ChannelMutation mutation) { + return mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().endsWith(".iswal")) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + mutation.apply(path, source, intercepted); + return intercepted; + }); + } + + private interface ChannelMutation { + void apply(Path path, FileChannel source, FileChannel intercepted) throws IOException; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticCaptureTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticCaptureTest.java index a0dd420eb..44f919139 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticCaptureTest.java @@ -8,6 +8,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.StructurePlacementMarker; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.function.Consumer4; @@ -20,6 +21,7 @@ import org.junit.Test; import org.mockito.MockedStatic; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -106,6 +108,55 @@ public final class GenerationSemanticCaptureTest { verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt()); } + @Test + public void cavePositionsRemainDistinctAcrossColumnsAndSectionBoundaries() { + Fixture fixture = new Fixture(); + List heights = List.of(0, 15, 16, 255, 256, 767, 4063); + Set expected = new HashSet<>(); + List resolved = new ArrayList<>(); + when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + resolved.add(new ChunkGenerationSemantics.BlockPosition(x, y, z)); + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(x + ":" + y + ":" + z); + return biome; + }); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y : heights) { + consumer.accept(x, y, z, new MatterCavern(true, null, (byte) 0)); + expected.add((x - 32) + ":" + y + ":" + (z + 48)); + } + } + } + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any()); + HydrologyCaveCell duplicate = mock(HydrologyCaveCell.class); + when(duplicate.floodedBiomeKey()).thenReturn("iris:duplicate"); + when(duplicate.fluidProfileKey()).thenReturn("iris:river"); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y : heights) { + consumer.accept(x, y, z, duplicate); + } + } + } + return null; + }).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(HydrologyCaveCell.class), any()); + + ChunkGenerationSemantics captured = GenerationSemanticCapture.capture(fixture.engine, -2, 3, 5L); + + assertEquals(expected, captured.caveBiomeKeys()); + assertEquals(expected.size(), resolved.size()); + assertEquals(Set.of("iris:river"), captured.riverProfileKeys()); + } + @Test @SuppressWarnings("unchecked") public void unsealedPoiQueryUsesMantleWithWorldHorizontalCoordinates() { diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticJournalWriteTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticJournalWriteTest.java new file mode 100644 index 000000000..8da4e1de2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticJournalWriteTest.java @@ -0,0 +1,276 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +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.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class GenerationSemanticJournalWriteTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void partialAppendRollsBackBeforeAConcurrentClaimCanPublish() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.claimAndPersist(claim(0, "first")); + Path journal = index.storageDirectory().resolve("r.0.0.iswal"); + byte[] original = Files.readAllBytes(journal); + IOException failure = new IOException("Partial journal write"); + CountDownLatch written = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future rejected = executor.submit(() -> { + try (MockedStatic ignored = journalChannels((source, intercepted) -> + doAnswer(invocation -> { + ByteBuffer bytes = invocation.getArgument(0); + int limit = bytes.limit(); + bytes.limit(bytes.position() + 7); + source.write(bytes); + bytes.limit(limit); + written.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + throw failure; + }).when(intercepted).write(any(ByteBuffer.class)))) { + return index.claimAndPersist(claim(1, "failed")); + } + }); + assertTrue(written.await(5L, TimeUnit.SECONDS)); + CountDownLatch attempted = new CountDownLatch(1); + Future accepted = executor.submit(() -> { + attempted.countDown(); + return index.claimAndPersist(claim(2, "second")); + }); + assertTrue(attempted.await(5L, TimeUnit.SECONDS)); + release.countDown(); + assertSame(failure, assertThrows(ExecutionException.class, + () -> rejected.get(5L, TimeUnit.SECONDS)).getCause()); + assertTrue(accepted.get(5L, TimeUnit.SECONDS)); + assertFalse(index.get(1, 0).isPresent()); + GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root); + assertEquals(2, replayed.recordCount()); + assertEquals(Optional.of(claim(0, "first")), replayed.get(0, 0)); + assertEquals(Optional.of(claim(2, "second")), replayed.get(2, 0)); + assertTrue(replayed.get(1, 0).isEmpty()); + assertArrayEquals(original, Arrays.copyOf(Files.readAllBytes(journal), original.length)); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void failedForceRestoresBytesAndLeavesTheClaimRetryable() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.claimAndPersist(claim(0, "first")); + Path journal = index.storageDirectory().resolve("r.0.0.iswal"); + byte[] original = Files.readAllBytes(journal); + IOException failure = new IOException("Journal force failed"); + AtomicInteger forces = new AtomicInteger(); + try (MockedStatic ignored = journalChannels((source, intercepted) -> + doAnswer(invocation -> { + if (forces.incrementAndGet() == 1) { + throw failure; + } + source.force(true); + return null; + }).when(intercepted).force(true))) { + assertSame(failure, assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "second")))); + } + assertEquals(2, forces.get()); + assertArrayEquals(original, Files.readAllBytes(journal)); + assertTrue(index.get(1, 0).isEmpty()); + assertTrue(index.claimAndPersist(claim(1, "second"))); + assertEquals(Optional.of(claim(1, "second")), GenerationSemanticIndex.loadRequired(root).get(1, 0)); + } + + @Test + public void rollbackFailurePreservesCausesAndRejectsFurtherMutation() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.claimAndPersist(claim(0, "first")); + IOException failure = new IOException("Journal force failed"); + IOException rollback = new IOException("Rollback force failed"); + AtomicInteger forces = new AtomicInteger(); + IOException reported; + try (MockedStatic ignored = journalChannels((source, intercepted) -> + doAnswer(invocation -> { + throw forces.incrementAndGet() == 1 ? failure : rollback; + }).when(intercepted).force(true))) { + reported = assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "failed"))); + } + assertSame(failure, reported.getCause()); + assertArrayEquals(new Throwable[]{rollback}, failure.getSuppressed()); + Path journal = index.storageDirectory().resolve("r.0.0.iswal"); + byte[] afterFailure = Files.readAllBytes(journal); + assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, "rejected"))); + assertThrows(IOException.class, () -> index.recordAndPersist(claim(2, "rejected"))); + assertThrows(IOException.class, index::compactJournals); + assertThrows(IllegalStateException.class, () -> index.get(0, 0)); + assertThrows(IllegalStateException.class, () -> match(index, "first")); + assertThrows(IllegalStateException.class, () -> index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, "iris:first", + new ChunkGenerationSemantics.BlockPosition(1_000_000, 64, 1_000_000), 32))); + assertArrayEquals(afterFailure, Files.readAllBytes(journal)); + assertEquals(1, index.recordCount()); + } + + @Test + public void closeFailureDoesNotAllowFurtherWritesOverAnUnpublishedClaim() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + IOException failure = new IOException("Journal close failed"); + try (MockedStatic ignored = journalChannels((source, intercepted) -> + doAnswer(invocation -> { + source.close(); + throw failure; + }).when(intercepted).close())) { + IOException reported = assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "second"))); + assertSame(failure, reported.getCause()); + } + assertEquals(0, index.recordCount()); + assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, "rejected"))); + assertThrows(IllegalStateException.class, index::recordsSnapshot); + assertThrows(IOException.class, () -> index.forEachRecord(semantics -> { + throw new AssertionError("Failed journal reached record consumer"); + })); + assertThrows(IOException.class, () -> index.forEachSealedClaim(1L, (chunkX, chunkZ) -> { + throw new AssertionError("Failed journal reached sealed claim consumer"); + })); + GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root); + assertEquals(Optional.of(claim(1, "second")), replayed.get(1, 0)); + assertTrue(replayed.get(2, 0).isEmpty()); + } + + @Test + public void newJournalDirectoryFailureRollsBackBeforeRetry() throws Exception { + assumeTrue(File.separatorChar != '\\'); + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + IOException failure = new IOException("Journal directory force failed"); + AtomicInteger directoryForces = new AtomicInteger(); + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 || !path.equals(index.storageDirectory())) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + doAnswer(force -> { + if (directoryForces.incrementAndGet() == 1) { + throw failure; + } + source.force(true); + return null; + }).when(intercepted).force(true); + return intercepted; + })) { + assertSame(failure, assertThrows(IOException.class, () -> index.claimAndPersist(claim(0, "first")))); + } + assertEquals(2, directoryForces.get()); + assertEquals(0L, Files.size(index.storageDirectory().resolve("r.0.0.iswal"))); + assertTrue(index.get(0, 0).isEmpty()); + assertTrue(index.claimAndPersist(claim(0, "first"))); + assertEquals(Optional.of(claim(0, "first")), GenerationSemanticIndex.loadRequired(root).get(0, 0)); + } + + @Test + public void queriedSummariesIncludeNewClaimsAfterMutationAndCompaction() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.claimAndPersist(claim(0, "first")); + assertEquals(0, match(index, "first").chunk().chunkX()); + index.claimAndPersist(claim(1, "second")); + assertEquals(1, match(index, "second").chunk().chunkX()); + assertEquals(0, match(index, "first").chunk().chunkX()); + index.compactJournals(); + GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root); + assertEquals(0, match(replayed, "first").chunk().chunkX()); + assertEquals(1, match(replayed, "second").chunk().chunkX()); + } + + @Test + public void queriedSummariesTrackRecordedUpdatesAndPrunedClaims() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root); + index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addRegion("iris:first").build()); + assertEquals(0, match(index, "first").chunk().chunkX()); + index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addRegion("iris:updated").build()); + assertEquals(0, match(index, "updated").chunk().chunkX()); + index.claimAndPersist(claim(1, "second")); + assertEquals(1, match(index, "second").chunk().chunkX()); + + WorldChunkInventory stored = WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(0, 0)); + assertEquals(1, index.discardUnstoredClaims(stored, Set.of(1L))); + assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, "iris:second", + new ChunkGenerationSemantics.BlockPosition(0, 64, 0), 32)).isEmpty()); + assertEquals(0, match(index, "first").chunk().chunkX()); + assertEquals(0, match(index, "updated").chunk().chunkX()); + GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root); + assertEquals(0, match(replayed, "updated").chunk().chunkX()); + assertTrue(replayed.get(1, 0).isEmpty()); + } + + private static GenerationSemanticIndex.Match match(GenerationSemanticIndex index, String region) { + return index.findNearest(GenerationSemanticIndex.Query.acrossActivations( + GenerationSemanticIndex.SemanticKind.REGION, "iris:" + region, + new ChunkGenerationSemantics.BlockPosition(0, 64, 0), 32)).orElseThrow(); + } + + private static ChunkGenerationSemantics claim(int chunkX, String region) { + return ChunkGenerationSemantics.builder(chunkX, 0, 1L).addRegion("iris:" + region).seal().build(); + } + + private static MockedStatic journalChannels(ChannelMutation mutation) { + return mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().endsWith(".iswal")) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + mutation.apply(source, intercepted); + return intercepted; + }); + } + + @FunctionalInterface + private interface ChannelMutation { + void apply(FileChannel source, FileChannel intercepted) throws IOException; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticKeyValidationTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticKeyValidationTest.java new file mode 100644 index 000000000..95059f44e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticKeyValidationTest.java @@ -0,0 +1,58 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public final class GenerationSemanticKeyValidationTest { + @Test + public void duplicateKeysRetainValidationAndCanonicalOrdering() { + ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(-2, 3, 5L); + List keys = List.of("iris:deep", "Caves/Crystal Gallery", "Région/Highlands", "cave/\ud801\udc00"); + for (int repetition = 0; repetition < 8; repetition++) { + for (String key : keys) { + builder.addSurfaceBiome(new String(key)).addCaveBiome(new String(key)) + .addRegion(new String(key)).addRiverProfile(new String(key)).addObject(new String(key)); + } + } + for (String invalid : List.of("", " iris:deep", "iris:deep ", "iris:\\deep", "iris:\ndeep", + "é".repeat(ChunkGenerationSemantics.MAX_KEY_BYTES))) { + assertThrows(IllegalArgumentException.class, () -> builder.addSurfaceBiome(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.addRegion(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.addRiverProfile(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.addObject(invalid)); + } + assertEquals("key", assertThrows(NullPointerException.class, () -> builder.addCaveBiome(null)).getMessage()); + ChunkGenerationSemantics result = builder.seal().build(); + assertEquals(Set.copyOf(keys), result.surfaceBiomeKeys()); + assertEquals(Set.copyOf(keys), result.caveBiomeKeys()); + assertEquals(Set.copyOf(keys), result.regionKeys()); + assertEquals(Set.copyOf(keys), result.riverProfileKeys()); + assertEquals(Set.copyOf(keys), result.objectKeys()); + List sorted = new ArrayList<>(keys); + Collections.sort(sorted); + for (Set recorded : List.of(result.surfaceBiomeKeys(), result.caveBiomeKeys(), result.regionKeys(), + result.riverProfileKeys(), result.objectKeys())) { + assertEquals(sorted, List.copyOf(recorded)); + } + } + + @Test + public void fullKeySetAcceptsDuplicatesAndRejectsNewKeysWithoutMutation() { + ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(0, 0, 5L); + for (int index = 0; index < ChunkGenerationSemantics.MAX_KEYS_PER_KIND; index++) { + builder.addCaveBiome("iris:cave_" + index); + } + builder.addCaveBiome(new String("iris:cave_0")); + assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome("iris:overflow")); + assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome(" iris:cave_0")); + assertEquals(ChunkGenerationSemantics.MAX_KEYS_PER_KIND, builder.build().caveBiomeKeys().size()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticPointReadTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticPointReadTest.java new file mode 100644 index 000000000..eb4dc251e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationSemanticPointReadTest.java @@ -0,0 +1,313 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; +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.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +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.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class GenerationSemanticPointReadTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void readsRetainDurableShardAfterEvictionDuringUnforcedAppend() throws Exception { + GenerationSemanticIndex index = index(); + for (int region = 0; region <= 70; region++) { + index.claimAndPersist(claim(region * 32, 1)); + } + ExecutorService reader = Executors.newSingleThreadExecutor(); + try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.NONE)) { + Future inspected = reader.submit(() -> { + assertSameRecord(claim(0, 1), index.get(0, 0)); + assertTrue(index.get(1, 0).isEmpty()); + assertTrue(index.hasSealedClaim(0, 0, 1)); + assertFalse(index.hasSealedClaim(1, 0, 1)); + for (int region = 1; region <= 70; region++) { + assertSameRecord(claim(region * 32, 1), index.get(region * 32, 0)); + } + assertSameRecord(claim(0, 1), index.get(0, 0)); + assertTrue(index.get(1, 0).isEmpty()); + assertTrue(index.get(-1, -1).isEmpty()); + }); + inspected.get(2, TimeUnit.SECONDS); + gate.finish(); + assertNull(gate.failure.get()); + assertSameRecord(claim(1, 1), index.get(1, 0)); + assertTrue(index.cachedRegionCount() <= 64); + assertEquals(72, index.recordCount()); + } finally { + drain(reader); + } + } + + @Test + public void newRegionRemainsAbsentUntilItsFirstForceCompletes() throws Exception { + GenerationSemanticIndex index = index(); + ExecutorService reader = Executors.newSingleThreadExecutor(); + try (AppendGate gate = append(() -> index.claimAndPersist(claim(-32, 1)), Failure.NONE)) { + assertTrue(reader.submit(() -> index.get(-32, 0).isEmpty()).get(2, TimeUnit.SECONDS)); + gate.finish(); + assertNull(gate.failure.get()); + assertSameRecord(claim(-32, 1), index.get(-32, 0)); + } finally { + drain(reader); + } + } + + @Test + public void confirmedRollbackKeepsPublishedReadsAndPermitsRetry() throws Exception { + GenerationSemanticIndex index = index(); + index.claimAndPersist(claim(0, 1)); + try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.ROLLBACK_CONFIRMED)) { + gate.finish(); + assertSame(gate.originalFailure, gate.failure.get()); + assertSameRecord(claim(0, 1), index.get(0, 0)); + assertTrue(index.get(1, 0).isEmpty()); + assertTrue(index.claimAndPersist(claim(1, 1))); + assertEquals(index.recordsSnapshot(), + GenerationSemanticIndex.loadRequired(index.storageDirectory().getParent().getParent().getParent()).recordsSnapshot()); + } + } + + @Test + public void uncertainRollbackAndClosePoisonHitsAndMisses() throws Exception { + for (Failure mode : new Failure[]{Failure.ROLLBACK_UNCERTAIN, Failure.CLOSE_UNCERTAIN}) { + GenerationSemanticIndex index = index(); + index.claimAndPersist(claim(0, 1)); + try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), mode)) { + gate.finish(); + assertTrue(gate.failure.get() instanceof IOException); + assertSame(gate.originalFailure, gate.failure.get().getCause()); + assertThrows(IllegalStateException.class, () -> index.get(0, 0)); + assertThrows(IllegalStateException.class, () -> index.get(1, 0)); + assertThrows(IllegalStateException.class, () -> index.get(999, 999)); + assertThrows(IllegalStateException.class, () -> index.hasSealedClaim(0, 0, 1)); + assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, 1))); + } + } + } + + @Test + public void maintenanceWaitsForAppendAndCallbacksRemainReentrant() throws Exception { + GenerationSemanticIndex index = index(); + index.claimAndPersist(claim(0, 1)); + ExecutorService maintenance = Executors.newFixedThreadPool(2); + Future compact; + Future prune; + try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.NONE)) { + CountDownLatch started = new CountDownLatch(2); + compact = maintenance.submit(() -> { + started.countDown(); + index.compactJournals(); + return null; + }); + prune = maintenance.submit(() -> { + started.countDown(); + return index.discardUnstoredClaims(WorldChunkInventory.ofPackedChunks( + ChunkGenerationOwnership.packChunk(0, 0)), Set.of(1L)); + }); + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> compact.get(100, TimeUnit.MILLISECONDS)); + assertThrows(TimeoutException.class, () -> prune.get(100, TimeUnit.MILLISECONDS)); + gate.finish(); + assertNull(gate.failure.get()); + compact.get(5, TimeUnit.SECONDS); + assertEquals(1, prune.get(5, TimeUnit.SECONDS).intValue()); + AtomicBoolean recorded = new AtomicBoolean(); + maintenance.submit(() -> { + index.forEachRecord(record -> { + assertEquals(record, index.get(record.chunkX(), record.chunkZ()).orElseThrow()); + if (recorded.compareAndSet(false, true)) { + index.claimAndPersist(claim(2, 1)); + } + }); + return null; + }).get(5, TimeUnit.SECONDS); + assertEquals(2, index.recordCount()); + assertTrue(index.get(1, 0).isEmpty()); + assertSameRecord(claim(2, 1), index.get(2, 0)); + } finally { + drain(maintenance); + } + } + + @Test + public void historyPointReadsProceedWhileClaimStillFencesPromotion() throws Exception { + GenerationHistory history = history(); + Path replacement = pack("replacement", "beta"); + GenerationActivation pending = history.stageUpdate(replacement, fingerprint(replacement), + history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 256); + ExecutorService tasks = Executors.newFixedThreadPool(2); + try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime(); + GenerationHistory.GenerationStage stage = history.openStage(1, 0); + AppendGate gate = append(() -> history.claimGeneratedSemantics(stage, claim(1, 1)), Failure.NONE)) { + tasks.submit(() -> { + assertTrue(history.semantics(1, 0).isEmpty()); + assertEquals(stage.activation(), history.resolveActivation(1, 0)); + assertEquals(stage.activation(), history.resolveActivation(-32, -32)); + }).get(2, TimeUnit.SECONDS); + stage.close(); + CountDownLatch cutoverEntered = new CountDownLatch(1); + Future promoted = tasks.submit(() -> { + try (GenerationHistory.LiveCutover cutover = history.beginLiveCutover()) { + cutoverEntered.countDown(); + return cutover.promote(boundary -> (x, z) -> null); + } + }); + assertTrue(cutoverEntered.await(2, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> promoted.get(100, TimeUnit.MILLISECONDS)); + assertTrue(tasks.submit(() -> history.semantics(1, 0).isEmpty()).get(2, TimeUnit.SECONDS)); + gate.finish(); + assertNull(gate.failure.get()); + assertEquals(pending.activationId(), promoted.get(5, TimeUnit.SECONDS).activationId()); + assertEquals(pending.activationId(), history.resolveActivation(1, 0).activationId()); + assertEquals(pending.activationId(), history.resolveActivation(-32, -32).activationId()); + assertTrue(history.semantics(1, 0).isEmpty()); + } finally { + drain(tasks); + } + } + + private GenerationSemanticIndex index() throws IOException { + return GenerationSemanticIndex.initialize(temporaryFolder.newFolder().toPath()); + } + + private GenerationHistory history() throws Exception { + Path world = temporaryFolder.newFolder().toPath(); + Path pack = pack("initial", "alpha"); + GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract( + "overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127, -64, 384, 384, 1D, + false, "none", 0, "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64)); + return GenerationHistory.create(world, pack, fingerprint(pack), 42L, contract, GenerationRegistryContract.empty()); + } + + private Path pack(String name, String content) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), content); + return pack; + } + + private static String fingerprint(Path pack) throws IOException { + return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); + } + + private static ChunkGenerationSemantics claim(int chunkX, long activation) { + return ChunkGenerationSemantics.builder(chunkX, 0, activation) + .addSurfaceBiome("iris:forest").seal().build(); + } + + private static void assertSameRecord(ChunkGenerationSemantics expected, Optional actual) { + assertEquals(expected, actual.orElseThrow()); + } + + private static AppendGate append(Callable operation, Failure mode) throws InterruptedException { + AppendGate gate = new AppendGate(operation, mode); + gate.worker.start(); + assertTrue(gate.forcing.await(5, TimeUnit.SECONDS)); + return gate; + } + + private static void drain(ExecutorService executor) throws InterruptedException { + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + + private enum Failure { + NONE, + ROLLBACK_CONFIRMED, + ROLLBACK_UNCERTAIN, + CLOSE_UNCERTAIN + } + + private static final class AppendGate implements AutoCloseable { + private final CountDownLatch forcing = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicReference failure = new AtomicReference<>(); + private final IOException originalFailure = new IOException("Semantic journal failed"); + private final Thread worker; + + private AppendGate(Callable operation, Failure mode) { + worker = new Thread(() -> { + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + FileChannel source = (FileChannel) invocation.callRealMethod(); + Path path = invocation.getArgument(0); + if (invocation.getMethod().getParameterCount() != 2 + || !path.getFileName().toString().endsWith(".iswal")) { + return source; + } + FileChannel intercepted = mock(FileChannel.class, delegatesTo(source)); + AtomicInteger forces = new AtomicInteger(); + doAnswer(call -> { + int count = forces.incrementAndGet(); + if (count == 1) { + forcing.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to resume semantic force"); + } + if (mode == Failure.ROLLBACK_CONFIRMED || mode == Failure.ROLLBACK_UNCERTAIN) { + throw originalFailure; + } + } + if (count == 2 && mode == Failure.ROLLBACK_UNCERTAIN) { + throw new IOException("Semantic rollback failed"); + } + source.force(true); + return null; + }).when(intercepted).force(true); + if (mode == Failure.CLOSE_UNCERTAIN) { + doAnswer(call -> { + source.close(); + throw originalFailure; + }).when(intercepted).close(); + } + return intercepted; + })) { + operation.call(); + } catch (Throwable caught) { + failure.set(caught); + } + }, "semantic-point-read-test"); + } + + private void finish() throws InterruptedException { + release.countDown(); + worker.join(5_000L); + assertFalse("Semantic append did not finish", worker.isAlive()); + } + + @Override + public void close() throws InterruptedException { + finish(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/NativeTerrainReceiptTest.java b/core/src/test/java/art/arcane/iris/engine/history/NativeTerrainReceiptTest.java index 2794cfdc0..881afb3b1 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/NativeTerrainReceiptTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/NativeTerrainReceiptTest.java @@ -2,15 +2,23 @@ package art.arcane.iris.engine.history; import org.junit.Test; +import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UTFDataFormatException; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HexFormat; import java.util.List; import java.util.OptionalInt; import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; public class NativeTerrainReceiptTest { @Test @@ -19,6 +27,7 @@ public class NativeTerrainReceiptTest { SavedTerrainChunk chunk = chunk(boundary); String epoch = "epoch-\u0000-é-水-\ud83c\udf32"; byte[] encoded = NativeTerrainReceipt.encode(chunk, 7, epoch); + assertArrayEquals(legacyEncode(chunk, 7, epoch), encoded); byte[] payload; try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(encoded))) { payload = input.readAllBytes(); @@ -51,6 +60,114 @@ public class NativeTerrainReceiptTest { } } + @Test + public void saturatedStringCachesPreserveModifiedUtfBytes() throws Exception { + for (int padding : new int[]{0, 300}) { + List keys = new ArrayList<>(300); + for (int index = 0; index < 300; index++) { + keys.add("iris:" + index + "-é-水-\u0000-\ud83c\udf32-\ud800-\udc00-" + "x".repeat(padding)); + } + for (int index = 0; index < 300; index++) { + keys.add(keys.getFirst()); + } + SavedTerrainChunk chunk = biomeChunk(keys); + byte[] encoded = NativeTerrainReceipt.encode(chunk, 9, "epoch"); + assertArrayEquals(legacyEncode(chunk, 9, "epoch"), encoded); + TerrainBoundarySignature restored = NativeTerrainReceipt.decode(encoded, "minecraft:noise") + .terrain().column(0, 0); + for (int index = 0; index < keys.size() * 2; index++) { + assertEquals(keys.get(index % keys.size()), restored.biomeAtSample(index)); + } + } + } + + @Test + public void maximumModifiedUtfLengthAndOversizedFailuresRemainUnchanged() throws Exception { + for (String key : List.of("a".repeat(65_535), "é".repeat(32_767) + "a")) { + SavedTerrainChunk chunk = biomeChunk(List.of(key)); + byte[] encoded = NativeTerrainReceipt.encode(chunk, 9, "epoch"); + assertArrayEquals(legacyEncode(chunk, 9, "epoch"), encoded); + assertEquals(key, NativeTerrainReceipt.decode(encoded, "minecraft:noise") + .terrain().column(0, 0).biomeAtSample(0)); + } + for (String key : List.of("a".repeat(65_536), "é".repeat(32_768), "\u0000".repeat(32_768))) { + SavedTerrainChunk chunk = biomeChunk(List.of(key)); + UTFDataFormatException expected = assertThrows(UTFDataFormatException.class, + () -> legacyEncode(chunk, 9, "epoch")); + UTFDataFormatException actual = assertThrows(UTFDataFormatException.class, + () -> NativeTerrainReceipt.encode(chunk, 9, "epoch")); + assertEquals(expected.getMessage(), actual.getMessage()); + } + } + + private static SavedTerrainChunk biomeChunk(List keys) { + List columns = new ArrayList<>(256); + short[] samples = new short[keys.size() * 2]; + for (int index = 0; index < samples.length; index++) { + samples[index] = (short) (index % keys.size()); + } + BoundaryColumnGeometry geometry = new BoundaryColumnGeometry(0, + List.of(new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false)), + new int[]{samples.length * 4}, new short[]{0}); + columns.add(new TerrainBoundarySignature(new TerrainBoundarySignature.Column(0, 0, 1, 1, + OptionalInt.empty(), OptionalInt.empty()), + new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(0, 4, samples.length), + new TerrainBoundarySignature.BiomeEncoding(keys, samples)), geometry)); + for (int index = 1; index < 256; index++) { + columns.add(null); + } + return new SavedTerrainChunk(0, 0, "minecraft:noise", columns); + } + + private static byte[] legacyEncode(SavedTerrainChunk chunk, long activation, String epoch) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(new BufferedOutputStream(new GZIPOutputStream(bytes)))) { + output.writeInt(0x4952544E); + output.writeInt(1); + output.writeInt(chunk.chunkX()); + output.writeInt(chunk.chunkZ()); + output.writeLong(activation); + output.writeUTF(epoch); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + output.writeBoolean(chunk.hasColumn(x, z)); + if (!chunk.hasColumn(x, z)) { + continue; + } + TerrainBoundarySignature signature = chunk.column((chunk.chunkX() << 4) + x, (chunk.chunkZ() << 4) + z); + output.writeInt(signature.surfaceHeight()); + output.writeInt(signature.oceanFloorHeight()); + output.writeInt(signature.fluidHeight().orElse(-1)); + output.writeInt(signature.upperCeilingDepth().orElse(-1)); + TerrainBoundarySignature.VerticalLayout layout = signature.samples().layout(); + output.writeInt(layout.minimumY()); + output.writeInt(layout.sampleStep()); + output.writeInt(layout.sampleCount()); + for (int index = 0; index < layout.sampleCount(); index++) { + output.writeUTF(signature.biomeAtSample(index)); + } + BoundaryColumnGeometry geometry = signature.geometry(); + output.writeInt(geometry.minimumY()); + output.writeInt(geometry.palette().size()); + for (BoundaryColumnGeometry.Voxel voxel : geometry.palette()) { + output.writeUTF(voxel.stateKey()); + output.writeByte(voxel.phase().ordinal()); + output.writeUTF(voxel.fluidStateKey()); + output.writeBoolean(voxel.protectedContent()); + } + int[] ends = geometry.runEnds(); + short[] indices = geometry.paletteIndices(); + output.writeInt(ends.length); + for (int index = 0; index < ends.length; index++) { + output.writeInt(ends[index]); + output.writeShort(indices[index]); + } + } + } + } + return bytes.toByteArray(); + } + private static SavedTerrainChunk chunk(boolean boundary) { List columns = new ArrayList<>(256); List palette = List.of( diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreBatchTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreBatchTest.java new file mode 100644 index 000000000..99da9c38a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreBatchTest.java @@ -0,0 +1,277 @@ +package art.arcane.iris.engine.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.reflect.Field; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +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.assertArrayEquals; +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.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.withSettings; + +public class SavedBiomeStoreBatchTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void queuedClaimsShareOneForceAndPublishAfterItCompletes() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + BatchControl control = new BatchControl(); + ExecutorService workers = Executors.newFixedThreadPool(8); + try { + List> results = enqueue(store, root, control, workers, + List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L), + chunk(5, 3L), chunk(6, 3L), chunk(7, 3L), chunk(8, 3L))); + assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + for (int index = 1; index <= 8; index++) { + assertTrue(store.cached(index, 0).isEmpty()); + assertFalse(results.get(index - 1).isDone()); + } + control.release.countDown(); + for (Future result : results) { + ClaimResult claim = result.get(5, TimeUnit.SECONDS); + assertNull(claim.failure); + assertTrue(claim.persisted); + } + assertEquals(1, control.forces.get()); + SavedBiomeStore reopened = SavedBiomeStore.open(root); + for (int index = 1; index <= 8; index++) { + assertEquals(chunk(index, 3L), reopened.get(index, 0).orElseThrow()); + } + } finally { + control.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + public void batchForceFailureRollsBackEveryNewClaimButPreservesExistingDuplicates() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + Path region = root.resolve("iris/generation/biomes/r.0.0.ibio"); + byte[] original = Files.readAllBytes(region); + BatchControl control = new BatchControl(); + control.failure = new IOException("Batch force failed"); + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + List> results = enqueue(store, root, control, workers, + List.of(chunk(0, 3L), chunk(1, 3L), chunk(1, 3L), chunk(2, 3L))); + assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + control.release.countDown(); + ClaimResult existing = results.getFirst().get(5, TimeUnit.SECONDS); + assertFalse(existing.persisted); + assertNull(existing.failure); + for (int index = 1; index < results.size(); index++) { + ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS); + assertFalse(result.persisted); + assertSame(control.failure, result.failure); + } + assertEquals(2, control.forces.get()); + assertArrayEquals(original, Files.readAllBytes(region)); + assertTrue(store.cached(1, 0).isEmpty()); + assertTrue(store.claimAndPersist(chunk(1, 3L))); + assertTrue(SavedBiomeStore.open(root).get(2, 0).isEmpty()); + } finally { + control.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + public void queuedDuplicateAndConflictingClaimsHaveOneWinner() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + BatchControl control = new BatchControl(); + ExecutorService workers = Executors.newFixedThreadPool(3); + try { + List> results = enqueue(store, root, control, workers, + List.of(chunk(1, 3L), chunk(1, 3L), chunk(1, 4L))); + assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + control.release.countDown(); + int winners = 0; + SavedBiomeChunk actual = null; + for (int index = 0; index < results.size(); index++) { + ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS); + if (result.persisted) { + winners++; + actual = chunk(1, index == 2 ? 4L : 3L); + } + } + assertEquals(1, winners); + assertEquals(1, control.forces.get()); + assertEquals(actual, SavedBiomeStore.open(root).get(1, 0).orElseThrow()); + for (int index = 0; index < results.size(); index++) { + ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS); + assertEquals(index == 2 ? actual.activationId() != 4L : actual.activationId() != 3L, + result.failure != null); + } + } finally { + control.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + public void uncheckedBatchFailureSettlesClaimsAndRejectsEvictedRetries() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + store.claimAndPersist(chunk(0, 3L)); + BatchControl control = new BatchControl(); + control.uncheckedFailure = new IllegalStateException("Batch force failed unexpectedly"); + ExecutorService workers = Executors.newFixedThreadPool(4); + try { + List> results = enqueue(store, root, control, workers, + List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L))); + assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + for (int region = 1; region <= 132; region++) { + if ((region & 63) != 0) { + assertTrue(store.get(region << 5, 0).isEmpty()); + } + } + control.release.countDown(); + for (Future future : results) { + ClaimResult result = future.get(5, TimeUnit.SECONDS); + assertFalse(result.persisted); + assertSame(control.uncheckedFailure, rootCause(result.failure)); + } + assertEquals(1, control.forces.get()); + IOException retry = assertThrows(IOException.class, () -> store.claimAndPersist(chunk(1, 3L))); + assertSame(control.uncheckedFailure, rootCause(retry)); + IOException read = assertThrows(IOException.class, () -> store.get(1, 0)); + assertSame(control.uncheckedFailure, rootCause(read)); + } finally { + control.release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + private static Throwable rootCause(Throwable failure) { + if (failure == null) { + return null; + } + Throwable cause = failure; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + private static List> enqueue(SavedBiomeStore store, Path root, BatchControl control, + ExecutorService workers, List chunks) throws Exception { + Field locksField = SavedBiomeStore.class.getDeclaredField("regionLocks"); + locksField.setAccessible(true); + Object stripe = ((Object[]) locksField.get(store))[0]; + Field writingField = stripe.getClass().getDeclaredField("writing"); + Field pendingField = stripe.getClass().getDeclaredField("pending"); + writingField.setAccessible(true); + pendingField.setAccessible(true); + Queue pending = (Queue) pendingField.get(stripe); + ArrayList> results = new ArrayList<>(chunks.size()); + synchronized (stripe) { + writingField.setBoolean(stripe, true); + try { + for (SavedBiomeChunk chunk : chunks) { + results.add(workers.submit(() -> claim(store, root, control, chunk))); + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (pending.size() != chunks.size() && System.nanoTime() < deadline) { + Thread.sleep(1L); + } + assertEquals(chunks.size(), pending.size()); + } finally { + writingField.setBoolean(stripe, false); + } + } + return results; + } + + private static ClaimResult claim(SavedBiomeStore store, Path root, BatchControl control, + SavedBiomeChunk chunk) throws Exception { + try (RandomAccessFile actual = new RandomAccessFile( + root.resolve("iris/generation/biomes/r.0.0.ibio").toFile(), "rw")) { + FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel())); + doAnswer(invocation -> { + int force = control.forces.incrementAndGet(); + if (force == 1) { + control.forcing.countDown(); + assertTrue(control.release.await(5, TimeUnit.SECONDS)); + if (control.failure != null) { + throw control.failure; + } + if (control.uncheckedFailure != null) { + throw control.uncheckedFailure; + } + } + actual.getChannel().force(true); + return null; + }).when(channel).force(true); + try (MockedConstruction ignored = mockConstruction(RandomAccessFile.class, + withSettings().defaultAnswer(delegatesTo(actual)), + (file, context) -> doReturn(channel).when(file).getChannel())) { + try { + return new ClaimResult(store.claimAndPersist(chunk), null); + } catch (IOException | RuntimeException failure) { + return new ClaimResult(false, failure); + } + } + } + } + + private static SavedBiomeChunk chunk(int x, long activation) { + SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation, "biome", "region"); + SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, + List.of(new SavedBiomeChunk.Span(-64, 320, cell))); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, activation, -64, 384)); + for (int z = 0; z < 16; z++) { + for (int localX = 0; localX < 16; localX++) { + builder.column(localX, z, column); + } + } + return builder.build(); + } + + private static final class BatchControl { + private final CountDownLatch forcing = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger forces = new AtomicInteger(); + private IOException failure; + private RuntimeException uncheckedFailure; + } + + private record ClaimResult(boolean persisted, Throwable failure) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreWriteTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreWriteTest.java index af00fbd76..42788c532 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreWriteTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeStoreWriteTest.java @@ -157,7 +157,13 @@ public class SavedBiomeStoreWriteTest { assertArrayEquals(original, Files.readAllBytes(regionPath(root))); assertTrue(store.get(1, 0).isEmpty()); assertTrue(SavedBiomeStore.open(root).get(1, 0).isEmpty()); - assertTrue(store.claimAndPersist(chunk(1, 3L))); + if (failRollback) { + assertSame(failure, assertThrows(IOException.class, + () -> store.claimAndPersist(chunk(1, 3L))).getCause()); + assertTrue(SavedBiomeStore.open(root).claimAndPersist(chunk(1, 3L))); + } else { + assertTrue(store.claimAndPersist(chunk(1, 3L))); + } } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyPolicyCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyPolicyCacheTest.java new file mode 100644 index 000000000..5982b9afe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyPolicyCacheTest.java @@ -0,0 +1,259 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverPolicy; +import art.arcane.iris.engine.object.IrisRiverProfile; +import art.arcane.volmlib.util.collection.KList; +import com.github.benmanes.caffeine.cache.Cache; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +public class IrisHydrologyPolicyCacheTest { + private static IrisSettings previousSettings; + + @BeforeClass + public static void useDefaultSettings() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + } + + @AfterClass + public static void restoreSettings() { + IrisSettings.settings = previousSettings; + } + + @Test + public void repeatedOwnersReuseFilteredPolicyAndProfilesAcrossCoordinates() throws Exception { + Fixture fixture = new Fixture(); + fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river")) + .setProfiles(new KList<>("water", "unknown", "water", "lava"))); + fixture.dimension.getHydrology().getRivers().setProfiles(new KList<>( + new IrisRiverProfile().setId("water"), new IrisRiverProfile().setId("lava"))); + try (IrisHydrologyRuntime runtime = fixture.runtime()) { + for (int index = 0; index < 128; index++) { + HydrologyTerrainSample sample = sample(runtime, index * 17, -index * 13); + assertEquals("river", sample.surfaceBiomeKey()); + assertEquals(List.of("lava", "water"), sample.preferredProfileKeys()); + } + verify(fixture.loader, times(1)).load("river"); + } + } + + @Test + public void ownerIdentityAndExplicitEmptyOverridesRemainDistinct() throws Exception { + Fixture fixture = new Fixture(); + fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river")) + .setWidthMultiplier(2D)); + IrisRegion first = new IdentityRegion(); + first.setLoadKey("same-region"); + first.setRiverPolicy(new IrisRiverPolicy().setWidthMultiplier(3D)); + IrisBiome empty = new IdentityBiome(); + empty.setLoadKey("same-biome"); + empty.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>())); + fixture.region.set(first); + fixture.biome.set(empty); + try (IrisHydrologyRuntime runtime = fixture.runtime()) { + assertEquals("same-biome", sample(runtime, 0, 0).surfaceBiomeKey()); + assertEquals(3D, sample(runtime, 1, 0).widthMultiplier(), 0D); + IrisRegion second = new IdentityRegion(); + second.setLoadKey("same-region"); + second.setRiverPolicy(new IrisRiverPolicy().setWidthMultiplier(4D)); + fixture.region.set(second); + IrisBiome inherited = new IdentityBiome(); + inherited.setLoadKey("same-biome"); + fixture.biome.set(inherited); + assertEquals("river", sample(runtime, 2, 0).surfaceBiomeKey()); + assertEquals(4D, sample(runtime, 3, 0).widthMultiplier(), 0D); + } + } + + @Test + public void selectedLoaderReplacementAndRuntimeReloadCannotReuseOldFiltering() throws Exception { + Fixture fixture = new Fixture(); + fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river"))); + IrisData regionData = mock(IrisData.class); + IrisData biomeData = mock(IrisData.class); + ResourceLoader excludedLoader = loader(true); + ResourceLoader replacementLoader = loader(false); + when(regionData.getBiomeLoader()).thenReturn(excludedLoader); + when(biomeData.getBiomeLoader()).thenReturn(excludedLoader); + fixture.region.get().setLoader(regionData); + fixture.biome.get().setLoader(biomeData); + try (IrisHydrologyRuntime runtime = fixture.runtime()) { + assertEquals("parent", sample(runtime, 0, 0).surfaceBiomeKey()); + when(biomeData.getBiomeLoader()).thenReturn(replacementLoader); + assertEquals("river", sample(runtime, 1, 0).surfaceBiomeKey()); + fixture.biome.get().setLoader(null); + assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey()); + verify(excludedLoader, times(2)).load("river"); + fixture.region.get().setLoader(null); + assertEquals("river", sample(runtime, 3, 0).surfaceBiomeKey()); + } + fixture.dimension.getRiverPolicy().setWidthMultiplier(7D); + try (IrisHydrologyRuntime replacement = fixture.runtime()) { + assertEquals(7D, sample(replacement, 0, 0).widthMultiplier(), 0D); + } + fixture.dimension.getRiverPolicy().setWidthMultiplier(8D); + assertEquals(8D, RiverPolicyResolver.resolve(fixture.dimension, fixture.region.get(), fixture.biome.get()) + .widthMultiplier(), 0D); + } + + @Test + public void unresolvedReferencesAreRecheckedInsteadOfFreezingAFirstLoadFailure() throws Exception { + Fixture fixture = new Fixture(); + fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river"))); + when(fixture.loader.load("river")).thenReturn(null); + when(fixture.loader.isLoaded("river")).thenReturn(false); + try (IrisHydrologyRuntime runtime = fixture.runtime()) { + assertEquals("river", sample(runtime, 0, 0).surfaceBiomeKey()); + IrisBiome excluded = new IrisBiome(); + excluded.setCompat(CompatStatus.excludedBy(List.of())); + when(fixture.loader.load("river")).thenReturn(excluded); + when(fixture.loader.isLoaded("river")).thenReturn(true); + assertEquals("parent", sample(runtime, 1, 0).surfaceBiomeKey()); + assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey()); + verify(fixture.loader, times(2)).load("river"); + } + } + + @Test + public void aConcurrentLoadAfterTheResolutionMissCannotMakeThatResultCacheable() throws Exception { + Fixture fixture = new Fixture(); + fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river"))); + IrisBiome excluded = new IrisBiome(); + excluded.setCompat(CompatStatus.excludedBy(List.of())); + when(fixture.loader.load("river")).thenReturn(null, excluded); + when(fixture.loader.isLoaded("river")).thenReturn(true); + try (IrisHydrologyRuntime runtime = fixture.runtime()) { + assertEquals("river", sample(runtime, 0, 0).surfaceBiomeKey()); + assertEquals("parent", sample(runtime, 1, 0).surfaceBiomeKey()); + assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey()); + verify(fixture.loader, times(2)).load("river"); + } + } + + @Test + public void policyEntriesRemainBoundedAndCloseClearsThem() throws Exception { + Fixture fixture = new Fixture(); + IrisHydrologyRuntime runtime = fixture.runtime(); + Cache policies = policies(runtime); + try { + for (int index = 0; index < 2048; index++) { + IrisBiome biome = new IdentityBiome(); + biome.setLoadKey("parent-" + index); + fixture.biome.set(biome); + assertEquals("parent-" + index, sample(runtime, index, 0).parentBiomeKey()); + } + policies.cleanUp(); + assertTrue(policies.estimatedSize() <= 1024); + assertTrue(policies.estimatedSize() > 0); + } finally { + runtime.close(); + } + assertEquals(0L, policies.estimatedSize()); + } + + private static HydrologyTerrainSample sample(IrisHydrologyRuntime runtime, int x, int z) throws Exception { + Method method = IrisHydrologyRuntime.class.getDeclaredMethod("createTerrainBasis", int.class, int.class, double.class); + method.setAccessible(true); + try { + return ((IrisHydrologyRoutingTerrainSampler.TerrainBasis) method.invoke(runtime, x, z, 180D)).terrain(); + } catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Error error) { + throw error; + } + if (failure.getCause() instanceof Exception exception) { + throw exception; + } + throw failure; + } + } + + private static Cache policies(IrisHydrologyRuntime runtime) throws ReflectiveOperationException { + Field field = IrisHydrologyRuntime.class.getDeclaredField("resolvedPolicies"); + field.setAccessible(true); + return (Cache) field.get(runtime); + } + + private static ResourceLoader loader(boolean excluded) { + ResourceLoader loader = mock(ResourceLoader.class); + IrisBiome river = new IrisBiome(); + river.setLoadKey("river"); + river.setCompat(excluded ? CompatStatus.excludedBy(List.of()) : CompatStatus.OK); + when(loader.load("river")).thenReturn(river); + when(loader.isLoaded("river")).thenReturn(true); + when(loader.getPossibleKeys()).thenReturn(new String[0]); + when(loader.loadAll(any(String[].class))).thenReturn(new KList<>()); + return loader; + } + + private static final class Fixture { + private final IrisDimension dimension = new IrisDimension().setRegions(new KList<>()); + private final IrisData data = mock(IrisData.class); + private final ResourceLoader loader = loader(false); + private final AtomicReference region = new AtomicReference<>(new IrisRegion()); + private final AtomicReference biome = new AtomicReference<>(new IrisBiome()); + + private Fixture() { + dimension.setLoadKey("dimension"); + dimension.setLoader(data); + region.get().setLoadKey("region"); + biome.get().setLoadKey("parent"); + when(data.getBiomeLoader()).thenReturn(loader); + } + + private IrisHydrologyRuntime runtime() { + return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data, + (x, z, height) -> new IrisHydrologyNaturalSample(height, false, biome.get(), region.get()), + (x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false)); + } + } + + private static final class IdentityRegion extends IrisRegion { + @Override + public boolean equals(Object compared) { + throw new AssertionError("Policy cache must use region identity"); + } + + @Override + public int hashCode() { + throw new AssertionError("Policy cache must use region identity"); + } + } + + private static final class IdentityBiome extends IrisBiome { + @Override + public boolean equals(Object compared) { + throw new AssertionError("Policy cache must use biome identity"); + } + + @Override + public int hashCode() { + throw new AssertionError("Policy cache must use biome identity"); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCoverageTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCoverageTest.java new file mode 100644 index 000000000..efe25041d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCoverageTest.java @@ -0,0 +1,227 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.engine.IrisEngineMantle; +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 org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class EngineMantleCoverageTest { + @Test + public void zeroRadiusCleansOnlyItsRealChunkOnce() { + CoverageFixture fixture = fixture(0); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + assertTrue(actual.isEmpty()); + fixture.real().add(new Chunk(0, 0)); + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + fixture.engine().cleanupChunksCoveredBy(0, 0, true, + (x, z) -> actual.add(new Chunk(x, z))); + + assertEquals(List.of(new Chunk(0, 0)), actual); + } + + @Test + public void realRadiusDefinesTheLargerCoverageHalo() { + CoverageFixture fixture = fixture(0); + doReturn(2).when(fixture.engine()).getRealRadius(); + fill(fixture, 2); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, false, + (x, z) -> actual.add(new Chunk(x, z))); + + assertEquals(List.of(new Chunk(0, 0)), actual); + } + + @Test + public void cleanupMatchesFullScanAcrossCoverageShapes() { + for (int radius : new int[]{0, 1, 2, 4}) { + for (int shape = 0; shape < 4; shape++) { + CoverageFixture fixture = fixture(radius); + for (int x = -2 * radius; x <= 2 * radius; x++) { + for (int z = -2 * radius; z <= 2 * radius; z++) { + if (shape == 0 || shape == 1 && x <= 0 || shape == 2 && z <= x) { + fixture.real().add(new Chunk(x - 7, z - 4)); + } + } + } + fixture.cleaned().add(new Chunk(-7, -4)); + List expected = fullScan(fixture, -7, -4, radius); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(-7, -4, false, + (x, z) -> actual.add(new Chunk(x, z))); + + assertEquals("radius=" + radius + ", shape=" + shape, expected, actual); + } + } + } + + @Test + public void cleanupRechecksMissingNeighborThatFinishesGeneration() { + CoverageFixture fixture = fixture(1); + fill(fixture, 2); + Chunk neighbor = new Chunk(-2, 0); + fixture.real().remove(neighbor); + AtomicInteger reads = new AtomicInteger(); + when(fixture.mantle().hasFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> { + if (reads.incrementAndGet() > 1) { + fixture.real().add(neighbor); + return true; + } + return false; + }); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, false, + (x, z) -> actual.add(new Chunk(x, z))); + + assertEquals(List.of(new Chunk(-1, 0), new Chunk(-1, 1), + new Chunk(0, -1), new Chunk(0, 0), new Chunk(0, 1), + new Chunk(1, -1), new Chunk(1, 0), new Chunk(1, 1)), actual); + } + + @Test + public void cleanupCallbackCanInvalidateLaterCoverage() { + CoverageFixture fixture = fixture(1); + fill(fixture, 2); + List actual = new ArrayList<>(); + + fixture.engine().cleanupChunksCoveredBy(0, 0, true, (x, z) -> { + actual.add(new Chunk(x, z)); + fixture.real().remove(new Chunk(0, 0)); + }); + + assertEquals(List.of(new Chunk(-1, -1)), actual); + } + + @Test + public void neighboringFrontierTargetsReuseARecheckedMissingNeighbor() { + CoverageFixture fixture = fixture(4); + for (int x = -8; x <= 8; x++) { + for (int z = -8; z <= 0; z++) { + fixture.real().add(new Chunk(x, z)); + } + } + List expected = fullScan(fixture, 0, 0, 4); + List actual = new ArrayList<>(); + int baselineReads = fullScanReads(fixture, 4); + + fixture.engine().cleanupChunksCoveredBy(0, 0, false, + (x, z) -> actual.add(new Chunk(x, z))); + + assertEquals(expected, actual); + assertTrue("baseline=" + baselineReads + ", actual=" + fixture.reads().get(), + fixture.reads().get() < baselineReads * 4 / 5); + } + + private static void fill(CoverageFixture fixture, int radius) { + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + fixture.real().add(new Chunk(x, z)); + } + } + } + + private static List fullScan(CoverageFixture fixture, int x, int z, int radius) { + List result = new ArrayList<>(); + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + Chunk target = new Chunk(x + offsetX, z + offsetZ); + if (fixture.cleaned().contains(target)) { + continue; + } + boolean covered = true; + for (int dx = -radius; dx <= radius && covered; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + if (!fixture.real().contains(new Chunk(target.x() + dx, target.z() + dz))) { + covered = false; + break; + } + } + } + if (covered) { + result.add(target); + } + } + } + return result; + } + + private static int fullScanReads(CoverageFixture fixture, int radius) { + int reads = 0; + for (int x = -radius; x <= radius; x++) { + for (int z = -radius; z <= radius; z++) { + boolean covered = true; + for (int dx = -radius; dx <= radius && covered; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + reads++; + if (!fixture.real().contains(new Chunk(x + dx, z + dz))) { + covered = false; + break; + } + } + } + } + } + return reads; + } + + @SuppressWarnings("unchecked") + private static CoverageFixture fixture(int radius) { + EngineMantle engine = mock(IrisEngineMantle.class, CALLS_REAL_METHODS); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + Set real = new HashSet<>(); + Set cleaned = new HashSet<>(); + AtomicInteger reads = new AtomicInteger(); + doReturn(mantle).when(engine).getMantle(); + doReturn(radius).when(engine).getRadius(); + doReturn(0).when(engine).getRealRadius(); + when(mantle.hasFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> { + Chunk position = new Chunk(invocation.getArgument(0), invocation.getArgument(1)); + MantleFlag flag = invocation.getArgument(2); + if (flag == MantleFlag.REAL) { + reads.incrementAndGet(); + return real.contains(position); + } + return cleaned.contains(position); + }); + when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(invocation -> { + cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1))); + return chunk; + }); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> null).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); + return new CoverageFixture(engine, mantle, real, cleaned, reads); + } + + private record Chunk(int x, int z) { + } + + private record CoverageFixture(EngineMantle engine, Mantle mantle, + Set real, Set cleaned, AtomicInteger reads) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/TerrainComposedCavernTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/TerrainComposedCavernTest.java new file mode 100644 index 000000000..0c731791f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/TerrainComposedCavernTest.java @@ -0,0 +1,151 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; +import art.arcane.iris.util.project.matter.slices.HydrologyCaveMatter; +import art.arcane.iris.util.project.matter.slices.PreObjectMatter; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class TerrainComposedCavernTest { + @BeforeClass + public static void registerMatter() { + IrisMatter.registerSliceType(new PreObjectMatter()); + IrisMatter.registerSliceType(new HydrologyCaveMatter()); + } + + @Test + public void allHydrologyActionsAndPartialJournalCapturesMatchTheExistingComposition() { + MatterCavern natural = new MatterCavern(true, "iris:natural", (byte) 0); + MatterCavern tagged = new MatterCavern(false, "iris:tagged", (byte) 2); + List caverns = Arrays.asList(null, natural, tagged); + List hydrology = new ArrayList<>(); + hydrology.add(null); + for (HydrologyCaveAction action : HydrologyCaveAction.values()) { + hydrology.add(new HydrologyCaveCell(action, "river", "iris:flooded")); + } + List journals = new ArrayList<>(); + journals.add(null); + journals.add(PreObjectMatterCell.string(null)); + journals.add(PreObjectMatterCell.cavern(null)); + journals.add(PreObjectMatterCell.cavern(natural)); + for (HydrologyCaveCell cell : hydrology) { + journals.add(PreObjectMatterCell.hydrology(cell)); + journals.add(PreObjectMatterCell.cavern(null).captureHydrology(cell)); + journals.add(PreObjectMatterCell.cavern(natural).captureHydrology(cell)); + } + Matter matter = new IrisMatter(16, 16, 16); + MantleChunk chunk = chunk(matter); + for (MatterCavern rawCavern : caverns) { + for (HydrologyCaveCell rawHydrology : hydrology) { + for (PreObjectMatterCell journal : journals) { + matter.slice(MatterCavern.class).set(15, 3, 2, rawCavern); + matter.slice(HydrologyCaveCell.class).set(15, 3, 2, rawHydrology); + matter.slice(PreObjectMatterCell.class).set(15, 3, 2, journal); + MatterCavern effectiveCavern = journal != null && journal.cavernCaptured() + ? journal.cavern() : rawCavern; + HydrologyCaveCell effectiveHydrology = journal != null && journal.hydrologyCaptured() + ? journal.hydrology() : rawHydrology; + MatterCavern expected = effectiveHydrology == null + ? effectiveCavern : effectiveHydrology.asCavern(); + assertSame(expected, legacy(chunk, -1, 35, 18)); + assertSame(expected, TerrainMatterView.getComposedCavern(chunk, -1, 35, 18)); + } + } + } + } + + @Test + public void absentChunksSectionsAndVerticalBoundsRemainEmpty() { + MantleChunk chunk = chunk(null); + assertNull(TerrainMatterView.getComposedCavern(null, 1, 2, 3)); + assertNull(TerrainMatterView.getComposedCavern(chunk, 1, -1, 3)); + assertNull(TerrainMatterView.getComposedCavern(chunk, 1, Integer.MIN_VALUE, 3)); + verifyNoInteractions(chunk); + assertNull(TerrainMatterView.getComposedCavern(chunk, 1, 0, 3)); + assertNull(TerrainMatterView.getComposedCavern(chunk, 1, Integer.MAX_VALUE, 3)); + assertNull(TerrainMatterView.getComposedCavern(chunk, 1, 35, 3)); + assertNull(legacy(chunk, 1, 35, 3)); + } + + @Test + public void composedReadUsesOneLockedSectionAndJournalLookup() { + Matter matter = spy(new IrisMatter(16, 16, 16)); + MatterCavern natural = new MatterCavern(true, "iris:natural", (byte) 0); + HydrologyCaveCell seal = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); + matter.slice(MatterCavern.class).set(15, 3, 2, natural); + matter.slice(HydrologyCaveCell.class).set(15, 3, 2, seal); + matter.slice(PreObjectMatterCell.class).set(15, 3, 2, + PreObjectMatterCell.string("iris:owner")); + MatterSlice journal = spy(matter.getSlice(PreObjectMatterCell.class)); + MatterSlice hydrology = spy(matter.getSlice(HydrologyCaveCell.class)); + matter.getSliceMap().put(PreObjectMatterCell.class, journal); + matter.getSliceMap().put(HydrologyCaveCell.class, hydrology); + MantleChunk chunk = chunk(matter); + clearInvocations(matter); + + assertNull(TerrainMatterView.getComposedCavern(chunk, -1, 35, 18)); + + verify(chunk, times(1)).exists(2); + verify(chunk, times(1)).get(2); + verify(journal, times(1)).get(15, 3, 2); + verify(hydrology, times(1)).get(15, 3, 2); + verify(matter, never()).hasSlice(MatterCavern.class); + verify(matter, never()).getSlice(MatterCavern.class); + } + + @Test + public void lookupFailuresRetainTheirOriginalCause() { + MantleChunk chunk = chunk(null); + IllegalStateException failure = new IllegalStateException("section unavailable"); + doThrow(failure).when(chunk).get(2); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> TerrainMatterView.getComposedCavern(chunk, 1, 35, 3))); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> legacy(chunk, 1, 35, 3))); + } + + private static MatterCavern legacy(MantleChunk chunk, int x, int y, int z) { + MatterCavern cavern = TerrainMatterView.get(chunk, x, y, z, MatterCavern.class); + HydrologyCaveCell hydrology = TerrainMatterView.get(chunk, x, y, z, HydrologyCaveCell.class); + return hydrology == null ? cavern : hydrology.asCavern(); + } + + @SuppressWarnings("unchecked") + private static MantleChunk chunk(Matter matter) { + MantleChunk chunk = mock(MantleChunk.class); + doAnswer(invocation -> { + assertTrue(Thread.holdsLock(chunk)); + return true; + }).when(chunk).exists(2); + doAnswer(invocation -> { + assertTrue(Thread.holdsLock(chunk)); + return matter; + }).when(chunk).get(2); + return chunk; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/TerrainMatterViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/TerrainMatterViewTest.java index c7741337c..f5c986b55 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/TerrainMatterViewTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/TerrainMatterViewTest.java @@ -1,5 +1,8 @@ package art.arcane.iris.engine.mantle; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.matter.IrisMatterSupport; import art.arcane.iris.util.project.matter.PreObjectMatterCell; import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest; @@ -14,14 +17,24 @@ import org.junit.Test; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.function.Function; 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.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; public class TerrainMatterViewTest { @@ -59,12 +72,115 @@ public class TerrainMatterViewTest { assertEquals(natural, TerrainMatterView.get(chunk, 3, 3, 2, MatterCavern.class)); } + @Test + public void journaledTypesReuseIteratedValuesAndKeepOriginalIdentityAndOrder() { + assertJournalIteration(MatterCavern.class, + new MatterCavern(true, "iris:natural", (byte) 0), + new MatterCavern(true, "iris:object", (byte) 0), PreObjectMatterCell::cavern); + assertJournalIteration(String.class, "iris:natural", "iris:object", PreObjectMatterCell::string); + assertJournalIteration(HydrologyCaveCell.class, + HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), + HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), PreObjectMatterCell::hydrology); + assertJournalIteration(PlatformBlockState.class, + mock(PlatformBlockState.class), mock(PlatformBlockState.class), PreObjectMatterCell::block); + } + + @Test + public void callbackFailuresEscapeOutsideTheChunkLock() { + Matter matter = new IrisMatter(16, 16, 16); + MatterCavern original = new MatterCavern(true, "iris:natural", (byte) 0); + matter.slice(MatterCavern.class).set(1, 3, 2, original); + MantleChunk chunk = chunk(matter); + IllegalStateException failure = new IllegalStateException("terrain callback"); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> { + assertFalse(Thread.holdsLock(chunk)); + assertSame(original, value); + throw failure; + }))); + List recovered = new ArrayList<>(); + TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> recovered.add(value)); + assertEquals(List.of(original), recovered); + } + + @Test + public void firstPassDoesNotReadTheSuppliedRawValuesAgain() { + Matter matter = new IrisMatter(16, 16, 16); + MatterCavern original = new MatterCavern(true, "iris:natural", (byte) 0); + matter.slice(MatterCavern.class).set(1, 3, 2, original); + matter.slice(MatterCavern.class).set(4, 3, 2, original); + MantleChunk chunk = chunk(matter); + List values = new ArrayList<>(); + + TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> values.add(value)); + + assertEquals(List.of(original, original), values); + verify(chunk, times(2)).get(0); + verify(chunk, times(2)).exists(0); + } + + @Test + @SuppressWarnings("unchecked") + public void unjournaledTypesKeepTheDirectIteratorPath() { + MantleChunk chunk = mock(MantleChunk.class); + Consumer4 consumer = (x, y, z, value) -> { + assertFalse(Thread.holdsLock(chunk)); + assertEquals(7, value.intValue()); + }; + doAnswer(invocation -> { + Consumer4 supplied = invocation.getArgument(1); + assertSame(consumer, supplied); + supplied.accept(1, 3, 2, 7); + return null; + }).when(chunk).iterate(Integer.class, consumer); + + TerrainMatterView.iterate(chunk, Integer.class, consumer); + + verify(chunk).iterate(Integer.class, consumer); + verifyNoMoreInteractions(chunk); + } + + private static void assertJournalIteration(Class type, T original, T content, + Function capture) { + Matter matter = new IrisMatter(16, 16, 16); + matter.slice(type).set(1, 3, 2, content); + matter.slice(type).set(2, 3, 2, content); + matter.slice(type).set(4, 3, 2, original); + matter.slice(type).set(5, 3, 2, content); + matter.slice(PreObjectMatterCell.class).set(1, 3, 2, capture.apply(null)); + matter.slice(PreObjectMatterCell.class).set(2, 3, 2, capture.apply(original)); + matter.slice(PreObjectMatterCell.class).set(3, 3, 2, capture.apply(original)); + matter.slice(PreObjectMatterCell.class).set(5, 3, 2, + type == String.class ? PreObjectMatterCell.cavern(null) : PreObjectMatterCell.string(null)); + matter.slice(PreObjectMatterCell.class).set(6, 3, 2, capture.apply(null)); + MantleChunk chunk = chunk(matter); + List positions = new ArrayList<>(); + List values = new ArrayList<>(); + + TerrainMatterView.iterate(chunk, type, (x, y, z, value) -> { + assertFalse(Thread.holdsLock(chunk)); + assertEquals(3, y.intValue()); + assertEquals(2, z.intValue()); + positions.add(x); + values.add(value); + matter.slice(type).set(4, 3, 2, content); + }); + + assertEquals(List.of(2, 4, 5, 3), positions); + assertSame(original, values.get(0)); + assertSame(original, values.get(1)); + assertSame(content, values.get(2)); + assertSame(original, values.get(3)); + } + @SuppressWarnings("unchecked") private static MantleChunk chunk(Matter matter) { MantleChunk chunk = mock(MantleChunk.class); when(chunk.exists(0)).thenReturn(true); when(chunk.get(0)).thenReturn(matter); doAnswer(invocation -> { + assertTrue(Thread.holdsLock(chunk)); Class type = invocation.getArgument(0); Consumer4 consumer = invocation.getArgument(1); if (!matter.hasSlice(type)) { diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java new file mode 100644 index 000000000..74b0e8221 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBiomeBlendTest.java @@ -0,0 +1,203 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; +import art.arcane.volmlib.util.math.BlockPosition; +import art.arcane.volmlib.util.math.RNG; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class IrisCarveModifierBiomeBlendTest { + private static final long SEED = 371924L; + + @Test + public void everyRollPreservesAllNeighborIdentityAndNullCombinations() throws Exception { + IrisBiome center = new IrisBiome(); + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + IrisBiome[] choices = {null, center, first, second}; + AtomicReference samples = new AtomicReference<>(); + int[] position = new int[3]; + Engine engine = mock(Engine.class); + doAnswer(call -> { + int x = call.getArgument(0); + int z = call.getArgument(2); + return samples.get()[sampleIndex(x - position[0], z - position[2])]; + }).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + RNG parent = new RNG(SEED); + RNG control = new RNG(SEED); + assertEquals(control.nextInt(), parent.nextInt()); + IrisCarveModifier modifier = modifier(engine, parent); + for (int sign : new int[]{1, -1}) { + for (int roll = 0; roll < 8; roll++) { + int[] coordinate = coordinate(roll, sign); + System.arraycopy(coordinate, 0, position, 0, position.length); + for (int pattern = 0; pattern < 256; pattern++) { + IrisBiome[] values = {center, choices[pattern & 3], choices[(pattern >> 2) & 3], + choices[(pattern >> 4) & 3], choices[(pattern >> 6) & 3]}; + samples.set(values); + IrisBiome selected = roll < 4 ? center : values[roll - 3]; + assertSame(selected == null ? center : selected, + resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>())); + } + samples.set(new IrisBiome[]{null, first, second, first, second}); + assertNull(resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>())); + } + } + assertEquals(control.nextLong(), parent.nextLong()); + } + + @Test + public void onlyCenterAndChosenNeighborAreQueriedInThatOrder() throws Exception { + List queries = new ArrayList<>(); + IrisBiome center = new IrisBiome(); + Engine engine = mock(Engine.class); + doAnswer(call -> { + queries.add(BlockPosition.toLong(call.getArgument(0), call.getArgument(1), call.getArgument(2))); + return center; + }).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + IrisCarveModifier modifier = modifier(engine, new RNG(SEED)); + for (int roll = 0; roll < 8; roll++) { + int[] coordinate = coordinate(roll, -1); + queries.clear(); + assertSame(center, resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>())); + List expected = new ArrayList<>(); + expected.add(BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2])); + if (roll >= 4) { + int[] neighbor = neighbor(coordinate, roll - 4); + expected.add(BlockPosition.toLong(neighbor[0], neighbor[1], neighbor[2])); + } + assertEquals(expected, queries); + } + } + + @Test + public void sharedCacheTraversalMatchesEagerSampling() throws Exception { + IrisBiome[] biomes = {new IrisBiome(), new IrisBiome(), null, new IrisBiome()}; + Engine engine = mock(Engine.class); + doAnswer(call -> biomeAt(biomes, call.getArgument(0), call.getArgument(1), call.getArgument(2))) + .when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + IrisCarveModifier modifier = modifier(engine, new RNG(SEED)); + Long2ObjectOpenHashMap cache = new Long2ObjectOpenHashMap<>(); + Long2ObjectOpenHashMap eagerCache = new Long2ObjectOpenHashMap<>(); + for (int x = -16; x < 0; x++) { + for (int z = -32; z < -16; z++) { + for (int y : new int[]{-12, 3, 17, 40}) { + int[] coordinate = {x, y, z}; + assertSame(eager(biomes, coordinate, eagerCache), resolve(modifier, coordinate, cache)); + } + } + } + } + + @Test + public void selectedNeighborFailureKeepsOriginalCause() throws Exception { + int[] coordinate = coordinate(4, -1); + IllegalStateException failure = new IllegalStateException("selected cave query failed"); + Engine engine = mock(Engine.class); + doAnswer(call -> { + if ((int) call.getArgument(0) != coordinate[0]) { + throw failure; + } + return new IrisBiome(); + }).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)); + IrisCarveModifier modifier = modifier(engine, new RNG(SEED)); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>()))); + } + + private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException { + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + Field field = IrisCarveModifier.class.getDeclaredField("rng"); + field.setAccessible(true); + field.set(modifier, rng); + return modifier; + } + + private static IrisBiome resolve(IrisCarveModifier modifier, int[] coordinate, + Long2ObjectOpenHashMap cache) { + return modifier.resolveCaveBoundaryBiome(null, coordinate[0], coordinate[1], coordinate[2], + new IrisDimensionCarvingResolver.State(), cache, new HashMap<>()); + } + + private static int[] coordinate(int roll, int sign) { + for (int y = 1; y < 10_000; y++) { + int[] coordinate = {sign * 31, sign * y, sign * 23}; + if (roll(coordinate) == roll) { + return coordinate; + } + } + throw new AssertionError("Missing cave blend roll " + roll); + } + + private static int roll(int[] coordinate) { + return Math.floorMod(new RNG(SEED).nextParallelRNG( + BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2])).nextInt(), 8); + } + + private static int[] neighbor(int[] coordinate, int index) { + return new int[]{coordinate[0] + (index == 0 ? 3 : index == 1 ? -3 : 0), coordinate[1], + coordinate[2] + (index == 2 ? 3 : index == 3 ? -3 : 0)}; + } + + private static int sampleIndex(int x, int z) { + if (x == 3) { + return 1; + } + if (x == -3) { + return 2; + } + return z == 3 ? 3 : z == -3 ? 4 : 0; + } + + private static IrisBiome biomeAt(IrisBiome[] biomes, int x, int y, int z) { + return biomes[Math.floorMod(x * 13 + y * 7 + z * 5, biomes.length)]; + } + + private static IrisBiome eager(IrisBiome[] biomes, int[] coordinate, Long2ObjectOpenHashMap cache) { + IrisBiome center = cached(biomes, coordinate, cache); + if (center == null) { + return null; + } + IrisBiome[] neighbors = new IrisBiome[4]; + boolean identical = true; + for (int index = 0; index < neighbors.length; index++) { + neighbors[index] = cached(biomes, neighbor(coordinate, index), cache); + identical &= neighbors[index] == center; + } + int roll = roll(coordinate); + IrisBiome selected = identical || roll < 4 ? center : neighbors[roll - 4]; + return selected == null ? center : selected; + } + + private static IrisBiome cached(IrisBiome[] biomes, int[] coordinate, Long2ObjectOpenHashMap cache) { + long key = BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2]); + IrisBiome value = cache.get(key); + if (value == null) { + value = biomeAt(biomes, coordinate[0], coordinate[1], coordinate[2]); + if (value != null) { + cache.put(key, value); + } + } + return value; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java index be7084159..28e9180db 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java @@ -13,9 +13,11 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.math.RNG; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import org.junit.Test; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; @@ -32,7 +34,7 @@ import static org.mockito.Mockito.mock; public class IrisCarveModifierBoundarySupportTest { @Test @SuppressWarnings("unchecked") - public void boundaryBiomeUsesCustomMatterAtItsOwnY() { + public void boundaryBiomeUsesCustomMatterAtItsOwnY() throws ReflectiveOperationException { IrisBiome customFloor = mock(IrisBiome.class); IrisBiome resolvedCeiling = mock(IrisBiome.class); IrisData data = mock(IrisData.class); @@ -46,6 +48,9 @@ public class IrisCarveModifierBoundarySupportTest { IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); doReturn(engine).when(modifier).getEngine(); + Field rng = IrisCarveModifier.class.getDeclaredField("rng"); + rng.setAccessible(true); + rng.set(modifier, new RNG(71L)); MantleChunk mantleChunk = mock(MantleChunk.class); Matter floorMatter = mock(Matter.class); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java index 29ce34f42..ce01209d2 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierContextTest.java @@ -35,11 +35,13 @@ import static org.junit.Assert.assertNotSame; 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.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -68,9 +70,9 @@ public class IrisDepositModifierContextTest { IrisEngine engine = mock(IrisEngine.class, RETURNS_DEEP_STUBS); try (Fixture fixture = new Fixture(engine, true)) { fixture.generateAndCheckCleanup(); - verify(engine, times(3)).captureGenerationRuntimeBinding(); - verify(engine, times(3)).openGenerationRuntimeScope(fixture.binding); - assertEquals(3, fixture.closedScopes.get()); + verify(engine, times(1)).captureGenerationRuntimeBinding(); + verify(engine, times(4)).openGenerationRuntimeScope(fixture.binding); + assertEquals(4, fixture.closedScopes.get()); } } @@ -91,6 +93,57 @@ public class IrisDepositModifierContextTest { } } + @Test + public void workerPreparationFailurePropagatesAfterAllScopesClose() throws Exception { + try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) { + IllegalStateException failure = new IllegalStateException("worker clump preparation failed"); + doThrow(failure).when(fixture.dimensionDeposit).getClump(any(), any(), any()); + assertSame(failure, assertThrows(IllegalStateException.class, () -> + fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context))); + verify(fixture.chunk).release(); + assertEquals(4, fixture.closedScopes.get()); + assertNull(IrisContext.get()); + } + } + + @Test + public void callerPlanFailureStillDrainsWorkersAndRestoresContext() throws Exception { + try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) { + IllegalStateException failure = new IllegalStateException("caller deposit plan failed"); + doThrow(failure).when(fixture.biomeDeposit).getSpawnChance(); + ChunkContext callerContext = mock(ChunkContext.class); + try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 19L, callerContext)) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> + fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context))); + assertSame(callerContext, IrisContext.require().getChunkContext()); + assertEquals(19L, IrisContext.require().getGenerationSessionId()); + } + verify(fixture.chunk).release(); + assertEquals(3, fixture.closedScopes.get()); + assertEquals(10, fixture.completed.get()); + assertNull(IrisContext.get()); + } + } + + @Test + public void callerFailureRetainsDistinctWorkerFailuresAfterDraining() throws Exception { + try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) { + IllegalStateException caller = new IllegalStateException("caller failure"); + IllegalArgumentException firstWorker = new IllegalArgumentException("first worker failure"); + UnsupportedOperationException secondWorker = new UnsupportedOperationException("second worker failure"); + doThrow(firstWorker, secondWorker).when(fixture.dimensionDeposit).getClump(any(), any(), any()); + doThrow(caller).when(fixture.biomeDeposit).getSpawnChance(); + assertSame(caller, assertThrows(IllegalStateException.class, () -> + fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context))); + assertEquals(2, caller.getSuppressed().length); + assertSame(firstWorker, caller.getSuppressed()[0]); + assertSame(secondWorker, caller.getSuppressed()[1]); + verify(fixture.chunk).release(); + assertEquals(3, fixture.closedScopes.get()); + assertNull(IrisContext.get()); + } + } + @Test public void failedClumpChanceDoesNotResampleTheConfiguredAttemptCount() { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); @@ -119,6 +172,8 @@ public class IrisDepositModifierContextTest { private final AtomicInteger completed = new AtomicInteger(); private final AtomicInteger closedScopes = new AtomicInteger(); private final IrisDepositModifier modifier; + private final IrisDepositGenerator dimensionDeposit = mock(IrisDepositGenerator.class); + private final IrisDepositGenerator biomeDeposit = mock(IrisDepositGenerator.class); private final MantleChunk chunk; @SuppressWarnings("unchecked") @@ -131,9 +186,7 @@ public class IrisDepositModifierContextTest { burst.setMulticore(true); when(engine.burst()).thenReturn(pool); when(pool.burst(true)).thenReturn(burst); - IrisDepositGenerator dimensionDeposit = mock(IrisDepositGenerator.class); IrisDepositGenerator regionDeposit = mock(IrisDepositGenerator.class); - IrisDepositGenerator biomeDeposit = mock(IrisDepositGenerator.class); when(engine.getDimension().getDeposits()).thenReturn(new KList<>(dimensionDeposit)); when(context.getRegion().get(7, 7).getDeposits()) .thenReturn(new KList<>(regionDeposit)); @@ -178,12 +231,9 @@ public class IrisDepositModifierContextTest { return clump; }).when(generator).getClump(any(), any(), any()); } + when(dimensionDeposit.getMinPerChunk()).thenReturn(9); + when(dimensionDeposit.getMaxPerChunk()).thenReturn(9); modifier = new IrisDepositModifier(engine); - doAnswer(invocation -> { - assertEquals(3, completed.get()); - assertEquals(scoped ? 3 : 0, closedScopes.get()); - return null; - }).when(chunk).release(); } private void generateAndCheckCleanup() throws Exception { @@ -194,7 +244,7 @@ public class IrisDepositModifierContextTest { assertSame(callerContext, IrisContext.require().getChunkContext()); assertEquals(19L, IrisContext.require().getGenerationSessionId()); } - assertEquals(3, completed.get()); + assertEquals(11, completed.get()); verify(chunk).release(); executor.submit(() -> { assertNull(IrisContext.get()); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierParityTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierParityTest.java new file mode 100644 index 000000000..7a2fa83b6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisDepositModifierParityTest.java @@ -0,0 +1,223 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDepositGenerator; +import art.arcane.iris.engine.object.IrisDepositHeightDistribution; +import art.arcane.iris.engine.object.IrisDepositShape; +import art.arcane.iris.engine.object.IrisDepositVariant; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.collection.KList; +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.MatterCavern; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisDepositModifierParityTest { + private final Map states = new HashMap<>(); + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void configuredShapesKeepRecordedBlocksAcrossScopesAndCavePolicies() { + long[] seeds = {101L, -73L, 90181L}; + long[][] expected = { + {-4609692219192155345L, 784L, 2118L, 53L, 67L, 11L}, + {-331296667371868787L, 818L, 2413L, 94L, 49L, 20L}, + {-3365014205342608518L, 641L, 2340L, 58L, 45L, 27L} + }; + for (int i = 0; i < seeds.length; i++) { + for (boolean multicore : new boolean[]{false, true}) { + assertArrayEquals("seed=" + seeds[i] + ", multicore=" + multicore, + expected[i], generateConfigured(seeds[i], multicore)); + } + } + } + + @SuppressWarnings("unchecked") + private long[] generateConfigured(long seed, boolean multicore) { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + ChunkContext context = mock(ChunkContext.class, RETURNS_DEEP_STUBS); + IrisDimension dimension = new IrisDimension(); + IrisRegion region = new IrisRegion(); + IrisBiome surface = new IrisBiome(); + IrisBiome cave = new IrisBiome(); + cave.setLoadKey("allowed"); + cave.setOreDepositFrequencyMultiplier(0.65D); + cave.setOreDepositSizeMultiplier(1.5D); + IrisBiome denied = new IrisBiome(); + denied.setLoadKey("denied"); + IrisDepositVariant variant = new IrisDepositVariant().setMinHeight(-64).setMaxHeight(64); + variant.getRemap().put("minecraft:gold_ore", "minecraft:redstone_ore"); + cave.getDepositVariants().add(variant); + dimension.getDeposits().add(generator("granite", IrisDepositShape.IRIS, 25, 70, + IrisDepositHeightDistribution.CLIPPED_UNIFORM, "stone", "deepslate")); + region.getDeposits().add(generator("iron_ore", IrisDepositShape.VANILLA_ELLIPSOID, 20, 110, + IrisDepositHeightDistribution.TRIANGLE, "stone", "granite", "deepslate")); + IrisDepositGenerator diamond = generator("diamond_ore", IrisDepositShape.VANILLA_SCATTERED, 12, 100, + IrisDepositHeightDistribution.UNIFORM, "iron_ore", "deepslate_iron_ore", "granite"); + diamond.setIncludedBiomes(new KList<>("allowed")); + diamond.setDiscardChanceOnAirExposure(0.5D); + surface.getDeposits().add(diamond); + IrisDepositGenerator gold = generator("gold_ore", IrisDepositShape.VANILLA_ELLIPSOID, 9, 70, + IrisDepositHeightDistribution.TRIANGLE, "stone", "granite"); + gold.setExcludedBiomes(new KList<>("allowed")); + gold.setDiscardChanceOnAirExposure(0.3D); + surface.getDeposits().add(gold); + when(engine.getHeight()).thenReturn(48); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getSeedManager().getDeposit()).thenReturn(seed); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + return ((Math.floorDiv(x, 8) + Math.floorDiv(z, 8) + Math.floorDiv(y, 8)) & 1) == 0 ? cave : denied; + }); + when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(40); + when(context.getBiome().get(anyInt(), anyInt())).thenReturn(surface); + when(context.getRegion().get(anyInt(), anyInt())).thenReturn(region); + MantleChunk chunk = mock(MantleChunk.class); + Mantle mantle = engine.getMantle().getMantle(); + doReturn(chunk).when(mantle).getChunk(-3, 5); + when(chunk.use()).thenReturn(chunk); + MatterCavern cavern = new MatterCavern(true, "allowed", (byte) 0); + when(chunk.get(anyInt(), anyInt(), anyInt(), any())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + return x == 4 && y % 7 == 0 ? cavern : null; + }); + Hunk terrain = Hunk.newArrayHunk(16, 48, 16); + for (int y = 0; y < 48; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + terrain.set(x, y, z, state(y >= 40 ? "air" : y == 0 ? "bedrock" + : x == 7 && z % 4 == 0 && y % 9 == 0 ? "cave_air" + : y < 12 ? "deepslate" : "stone")); + } + } + } + MultiBurst burst = new MultiBurst("Deposit parity", () -> 4); + when(engine.burst()).thenReturn(burst); + try (MockedStatic blocks = mockStatic(B.class, CALLS_REAL_METHODS)) { + blocks.when(() -> B.getStateOrNull(anyString(), anyBoolean())) + .thenAnswer(invocation -> state(invocation.getArgument(0))); + blocks.when(() -> B.toDeepSlateOre(any(), any())).thenAnswer(invocation -> { + PlatformBlockState host = invocation.getArgument(0); + PlatformBlockState ore = invocation.getArgument(1); + return host.isDeepSlate() && ore.isOre() + ? state("deepslate_" + ore.key().substring(10)) : ore; + }); + new IrisDepositModifier(engine).generateDeposits(terrain, -3, 5, multicore, context); + } finally { + burst.close(); + } + long[] result = {0xcbf29ce484222325L, 0L, 0L, 0L, 0L, 0L}; + for (int y = 0; y < 48; y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + PlatformBlockState block = terrain.get(x, y, z); + result[0] = (result[0] ^ block.key().hashCode()) * 0x100000001b3L; + if (block.key().endsWith("granite")) { + result[1]++; + } + if (block.key().endsWith("iron_ore")) { + result[2]++; + } + if (block.key().endsWith("diamond_ore")) { + result[3]++; + } + if (block.key().endsWith("gold_ore")) { + result[4]++; + } + if (block.key().endsWith("redstone_ore")) { + result[5]++; + } + if (x == 4 && y % 7 == 0 && y > 0 && y < 40) { + assertEquals(y < 12 ? "minecraft:deepslate" : "minecraft:stone", block.key()); + } + } + } + } + return result; + } + + private IrisDepositGenerator generator(String block, IrisDepositShape shape, int size, int attempts, + IrisDepositHeightDistribution distribution, String... hosts) { + IrisDepositGenerator generator = new PaletteGenerator(state(block)); + return generator.setShape(shape).setMinSize(size).setMaxSize(size) + .setMinPerChunk(attempts).setMaxPerChunk(attempts) + .setMinHeight(-4).setMaxHeight(44).setHeightDistribution(distribution) + .setSurfaceClearance(0).setPerClumpSpawnChance(0.7D) + .setReplaceableBlocks(new KList<>(hosts)); + } + + private PlatformBlockState state(String block) { + String key = block.contains(":") ? block : "minecraft:" + block; + return states.computeIfAbsent(key, value -> { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(value); + when(state.materialKey()).thenReturn(value); + when(state.isOre()).thenReturn(value.endsWith("_ore")); + when(state.isDeepSlate()).thenReturn(value.contains("deepslate")); + when(state.isAir()).thenReturn(value.endsWith("air")); + return state; + }); + } + + private static final class PaletteGenerator extends IrisDepositGenerator { + private final KList blocks; + + private PaletteGenerator(PlatformBlockState block) { + blocks = new KList<>(block); + } + + @Override + public KList getBlockData(IrisData data) { + return blocks; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java new file mode 100644 index 000000000..7a4ff1eba --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisNaturalCaveScanTest.java @@ -0,0 +1,239 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.actuator.IrisDecorantActuator; +import art.arcane.iris.engine.decorator.IrisCeilingDecorator; +import art.arcane.iris.engine.decorator.IrisSurfaceDecorator; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDecorationPart; +import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.common.data.B; +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.slices.MarkerMatter; +import org.junit.Test; +import org.junit.Before; +import org.junit.After; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +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.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +public class IrisNaturalCaveScanTest { + private MockedStatic blocks; + + @Before + public void prepareBlocks() { + blocks = mockStatic(B.class, CALLS_REAL_METHODS); + blocks.when(() -> B.getState(anyString())).thenReturn(null); + } + + @After + public void releaseBlocks() { + blocks.close(); + } + + @Test + public void narrowScanPreservesDecoratedBlocksMarkersAndMutationOrder() throws Exception { + verifyScan(5, 3); + } + + @Test + public void chunkScanPreservesDecoratedBlocksMarkersAndMutationOrder() throws Exception { + verifyScan(16, 16); + } + + @Test + public void listenerFailureReleasesMantleChunk() throws Exception { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("listener rejected decoration"); + Hunk output = fixture.output(3, 2).listen((x, y, z, state) -> { + throw failure; + }); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> fixture.modifier.decorateNaturalCaves(-32, 48, output))); + verify(fixture.chunk).release(); + } + + @Test + @SuppressWarnings("unchecked") + public void dimensionFailureReleasesMantleChunk() throws Exception { + Fixture fixture = new Fixture(); + Hunk output = mock(Hunk.class); + doReturn(3).when(output).getWidth(); + doReturn(2).when(output).getDepth(); + IllegalStateException failure = new IllegalStateException("height unavailable"); + doAnswer(call -> { throw failure; }).when(output).getHeight(); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> fixture.modifier.decorateNaturalCaves(-32, 48, output))); + verify(fixture.chunk).release(); + } + + private static void verifyScan(int width, int depth) throws Exception { + Fixture fixture = new Fixture(); + Hunk output = fixture.output(width, depth); + List writes = new ArrayList<>(); + fixture.modifier.decorateNaturalCaves(-32, 48, + output.listen((x, y, z, state) -> writes.add(x + ":" + y + ":" + z + ":" + state.key()))); + List expectedCalls = new ArrayList<>(); + List expectedMarkers = new ArrayList<>(); + List expectedWrites = new ArrayList<>(); + Method markerRoll = IrisCarveModifier.class.getDeclaredMethod("markerRoll", int.class, int.class, int.class, long.class); + markerRoll.setAccessible(true); + for (int x = 0; x < width; x++) { + for (int z = 0; z < depth; z++) { + for (int floor : new int[]{2, 20}) { + int ceiling = floor == 2 ? 5 : 22; + expectedCalls.add("floor:" + x + ":" + z + ":" + (floor - 1) + ":" + (ceiling - floor - 1)); + expectedCalls.add("ceiling:" + x + ":" + z + ":" + ceiling + ":" + (ceiling - floor - 1)); + expectedWrites.add(x + ":" + floor + ":" + z + ":minecraft:moss_block"); + if (floor == 2) { + expectedWrites.add(x + ":15:" + z + ":minecraft:stone"); + for (int y = 20; y <= 22; y++) { + expectedWrites.add(x + ":" + y + ":" + z + ":minecraft:cave_air"); + } + } + expectedWrites.add(x + ":" + ceiling + ":" + z + ":minecraft:calcite"); + if ((boolean) markerRoll.invoke(fixture.modifier, x - 32, ceiling, z + 48, 0x9E3779B97F4A7C15L)) { + expectedMarkers.add((x - 32) + ":" + ceiling + ":" + (z + 48) + ":" + MarkerMatter.CAVE_CEILING); + } + if ((boolean) markerRoll.invoke(fixture.modifier, x - 32, floor, z + 48, 0xC2B2AE3D27D4EB4FL)) { + expectedMarkers.add((x - 32) + ":" + floor + ":" + (z + 48) + ":" + MarkerMatter.CAVE_FLOOR); + } + } + for (int y = 0; y < 32; y++) { + PlatformBlockState expected = initialState(fixture, y); + if (y == 2 || y == 20) { + expected = fixture.floor; + } else if (y == 5 || y == 22) { + expected = fixture.ceiling; + } else if (y == 15) { + expected = fixture.stone; + } else if (y == 21) { + expected = fixture.air; + } + assertSame("block " + x + ":" + y + ":" + z, expected, output.getRaw(x, y, z)); + } + } + } + assertEquals(expectedCalls, fixture.calls); + assertEquals(expectedWrites, writes); + assertEquals(expectedMarkers, fixture.markers); + assertTrue("fixture must exercise marker writes", !expectedMarkers.isEmpty()); + verify(fixture.chunk).use(); + verify(fixture.chunk).release(); + } + + private static PlatformBlockState initialState(Fixture fixture, int y) { + return y >= 2 && y <= 5 || y == 10 || y >= 14 && y <= 17 || y >= 28 + ? fixture.air : fixture.stone; + } + + private static PlatformBlockState block(String key, boolean solid) { + PlatformBlockState state = mock(PlatformBlockState.class); + doReturn(key).when(state).key(); + doReturn(key).when(state).materialKey(); + doReturn(solid).when(state).isSolid(); + return state; + } + + private static final class Fixture { + private final PlatformBlockState air = block("minecraft:cave_air", false); + private final PlatformBlockState stone = block("minecraft:stone", true); + private final PlatformBlockState floor = block("minecraft:moss_block", true); + private final PlatformBlockState ceiling = block("minecraft:calcite", true); + private final IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + private final MantleChunk chunk; + private final List calls = new ArrayList<>(); + private final List markers = new ArrayList<>(); + + @SuppressWarnings("unchecked") + private Fixture() throws Exception { + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + chunk = mock(MantleChunk.class); + doReturn(engine).when(modifier).getEngine(); + doReturn(32).when(engine).getHeight(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + doReturn(engineMantle).when(engine).getMantle(); + doReturn(mantle).when(engineMantle).getMantle(); + doReturn(chunk).when(mantle).getChunk(-2, 3); + doReturn(chunk).when(chunk).use(); + doAnswer(call -> { + markers.add(call.getArgument(0) + ":" + call.getArgument(1) + ":" + call.getArgument(2) + ":" + call.getArgument(3)); + return null; + }).when(mantle).set(anyInt(), anyInt(), anyInt(), any()); + IrisBiome biome = mock(IrisBiome.class); + doReturn(new IrisDecorator[]{new IrisDecorator()}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE); + doReturn(new IrisDecorator[]{new IrisDecorator()}).when(biome).getDecoratorBucket(IrisDecorationPart.CEILING); + doReturn(biome).when(modifier).resolveCaveBoundaryBiome(eq(chunk), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), any(), any()); + IrisDecorantActuator decorant = mock(IrisDecorantActuator.class); + IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class); + IrisCeilingDecorator roof = mock(IrisCeilingDecorator.class); + doReturn(surface).when(decorant).getSurfaceDecorator(); + doReturn(roof).when(decorant).getCeilingDecorator(); + Field field = IrisCarveModifier.class.getDeclaredField("decorant"); + field.setAccessible(true); + field.set(modifier, decorant); + doAnswer(call -> { + int x = call.getArgument(0); + int z = call.getArgument(1); + int y = call.getArgument(11); + Hunk output = call.getArgument(8); + calls.add("floor:" + x + ":" + z + ":" + y + ":" + call.getArgument(12)); + output.setRaw(x, y + 1, z, floor); + if (y == 1) { + output.setRaw(x, 15, z, stone); + for (int height = 20; height <= 22; height++) { + output.setRaw(x, height, z, air); + } + } + return null; + }).when(surface).decorate(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), eq(biome), any(), anyInt(), anyInt()); + doAnswer(call -> { + int x = call.getArgument(0); + int z = call.getArgument(1); + int y = call.getArgument(11); + Hunk output = call.getArgument(8); + calls.add("ceiling:" + x + ":" + z + ":" + y + ":" + call.getArgument(12)); + output.setRaw(x, y, z, ceiling); + return null; + }).when(roof).decorate(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), eq(biome), any(), anyInt(), anyInt()); + } + + private Hunk output(int width, int depth) { + Hunk output = Hunk.newArrayHunk(width, 32, depth); + for (int x = 0; x < width; x++) { + for (int z = 0; z < depth; z++) { + for (int y = 0; y < 32; y++) { + output.setRaw(x, y, z, initialState(this, y)); + } + } + } + return output; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java index edb1e2fa6..b88b79337 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java @@ -40,6 +40,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -118,6 +119,7 @@ public class IrisDimensionCarvingResolverParityTest { assertSame("entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); } } + fixture.clearRecordedCalls(); } } @@ -138,6 +140,7 @@ public class IrisDimensionCarvingResolverParityTest { assertSame("mixed entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); } } + fixture.clearRecordedCalls(); } } @@ -154,6 +157,7 @@ public class IrisDimensionCarvingResolverParityTest { assertSame("cave biome mismatch at x=" + x + " y=" + y + " z=" + z, defaultBiome, stateBiome); } } + fixture.clearRecordedCalls(); } } @@ -174,6 +178,7 @@ public class IrisDimensionCarvingResolverParityTest { secondPlan[columnIndex] ); } + fixture.clearRecordedCalls(); } } } @@ -225,6 +230,26 @@ public class IrisDimensionCarvingResolverParityTest { assertSame(replacementExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY)); } + @Test + public void explicitStateInvalidatesWhenOnlyEngineDataChanges() { + Fixture fixture = createFixture(); + Engine engine = fixture.engine; + IrisData firstData = engine.getData(); + IrisData replacementData = mock(IrisData.class); + IrisDimensionCarvingEntry entry = mock(IrisDimensionCarvingEntry.class); + IrisBiome firstBiome = new IrisBiome(); + IrisBiome replacementBiome = new IrisBiome(); + doReturn(firstBiome).when(entry).getRealBiome(firstData); + doReturn(replacementBiome).when(entry).getRealBiome(replacementData); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + + assertSame(firstBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state)); + doReturn(replacementData).when(engine).getData(); + assertSame(replacementBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state)); + doReturn(firstData).when(engine).getData(); + assertSame(firstBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state)); + } + @Test public void resolverStateUsesWeakRuntimeIdentityBindings() throws NoSuchFieldException { Field engineIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("engineIdentity"); @@ -404,7 +429,9 @@ public class IrisDimensionCarvingResolverParityTest { doReturn(surfaceBiome).when(engine).getSurfaceBiome(anyInt(), anyInt()); doReturn(fallbackBiome).when(engine).getCaveBiome(anyInt(), anyInt()); - return new Fixture(engine); + return new Fixture(engine, new Object[]{engine, dimension, data, biomeLoader, + rootLowBiome, rootHighBiome, childABiome, childBBiome, childCBiome, + fallbackBiome, surfaceBiome}); } private Fixture createMixedDepthFixture() { @@ -490,7 +517,9 @@ public class IrisDimensionCarvingResolverParityTest { doReturn(surfaceBiome).when(engine).getSurfaceBiome(anyInt(), anyInt()); doReturn(fallbackBiome).when(engine).getCaveBiome(anyInt(), anyInt()); - return new Fixture(engine); + return new Fixture(engine, new Object[]{engine, dimension, data, biomeLoader, + rootLowBiome, rootHighBiome, childABiome, childBBiome, childCBiome, + childDBiome, childEBiome, childFBiome, childGBiome, fallbackBiome, surfaceBiome}); } private IrisDimensionCarvingEntry[] buildColumnPlan(Engine engine, IrisDimensionCarvingEntry rootEntry, int chunkX, int chunkZ, IrisDimensionCarvingResolver.State state) { @@ -660,7 +689,10 @@ public class IrisDimensionCarvingResolverParityTest { return Math.min(depth, MAX_CHILD_DEPTH); } - private record Fixture(Engine engine) { + private record Fixture(Engine engine, Object[] mocks) { + private void clearRecordedCalls() { + clearInvocations(mocks); + } } private record RetainedFixture(Engine engine, IrisDimensionCarvingEntry entry) { diff --git a/core/src/test/java/art/arcane/iris/util/project/noise/CNGSignedStateMutationTest.java b/core/src/test/java/art/arcane/iris/util/project/noise/CNGSignedStateMutationTest.java new file mode 100644 index 000000000..caccf7772 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/noise/CNGSignedStateMutationTest.java @@ -0,0 +1,176 @@ +package art.arcane.iris.util.project.noise; + +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class CNGSignedStateMutationTest { + private static final double EPSILON = 1.0E-12D; + + @Test + public void eachTransformRefreshesPreviouslyCachedCoordinates() { + List> mutations = List.of( + noise -> noise.scale(0.375D), + noise -> noise.scale(0.5D).bake().scale(0.75D), + noise -> noise.zoom(2D), + noise -> noise.oct(3), + noise -> noise.up(0.125D), + noise -> noise.down(0.125D), + noise -> noise.patch(0.75D), + noise -> noise.pow(1.25D), + noise -> noise.fractureWith(simplex(13L), 4D), + noise -> noise.child(simplex(17L)).injectWith(CNG.MULTIPLY)); + for (Consumer mutation : mutations) { + CNG sampled = simplex(11L); + sampled.noiseFastSigned2D(4D, 8D); + sampled.noiseFastSigned3D(4D, 6D, 8D); + mutation.accept(sampled); + CNG expected = simplex(11L); + mutation.accept(expected); + assertEquals((expected.noiseFast2D(4D, 8D) * 2D) - 1D, + sampled.noiseFastSigned2D(4D, 8D), EPSILON); + assertEquals((expected.noiseFast3D(4D, 6D, 8D) * 2D) - 1D, + sampled.noiseFastSigned3D(4D, 6D, 8D), EPSILON); + } + } + + @Test + public void generatorCallbackMutationInvalidatesOnlySubsequentSamples() { + for (boolean threeDimensions : new boolean[]{false, true}) { + RecordingNoise source = new RecordingNoise(); + CNG noise = new CNG(new RNG(11L), source, 1D, 1); + source.onSample = () -> noise.scale(0.5D); + assertEquals(value(threeDimensions, 4D, 6D, 8D), sample(noise, threeDimensions), 0D); + assertEquals(value(threeDimensions, 2D, 3D, 4D), sample(noise, threeDimensions), 0D); + assertEquals(value(threeDimensions, 2D, 3D, 4D), sample(noise, threeDimensions), 0D); + assertEquals(2, source.calls); + } + } + + @Test + public void nestedFractureOverridesRetainParentAndChildMutationBoundaries() { + for (boolean threeDimensions : new boolean[]{false, true}) { + RecordingNoise source = new RecordingNoise(); + CNG parent = new CNG(new RNG(11L), source, 1D, 1); + RecordingNoise fractureSource = new RecordingNoise(); + MutatingFracture fracture = new MutatingFracture(fractureSource); + fracture.beforeSample = () -> { + parent.scale(0.5D); + fracture.scale(0.25D); + }; + parent.fractureWith(fracture, 8D); + double shiftedX = 4D + value(threeDimensions, 1D, 1.5D, 2D) * 4D; + double shiftedY = 6D + value(false, 1.5D, 0D, 1D) * 4D; + double shiftedZ = 8D + (threeDimensions + ? value(true, 2D, 1D, 1.5D) : value(false, 2D, 0D, 1D)) * 4D; + assertEquals(value(threeDimensions, shiftedX, shiftedY, shiftedZ), sample(parent, threeDimensions), 0D); + assertEquals(value(threeDimensions, shiftedX * 0.5D, shiftedY * 0.5D, shiftedZ * 0.5D), + sample(parent, threeDimensions), 0D); + assertEquals(2, source.calls); + } + } + + @Test + public void callbackFailureRetainsItsMutationAndDoesNotPublishASample() { + for (boolean threeDimensions : new boolean[]{false, true}) { + RecordingNoise source = new RecordingNoise(); + CNG noise = new CNG(new RNG(11L), source, 1D, 1); + IllegalStateException failure = new IllegalStateException("sample failed"); + source.onSample = () -> { + noise.scale(0.25D); + throw failure; + }; + assertSame(failure, assertThrows(IllegalStateException.class, () -> sample(noise, threeDimensions))); + assertEquals(value(threeDimensions, 1D, 1.5D, 2D), sample(noise, threeDimensions), 0D); + assertEquals(value(threeDimensions, 1D, 1.5D, 2D), sample(noise, threeDimensions), 0D); + assertEquals(2, source.calls); + } + } + + private static CNG simplex(long seed) { + return new CNG(new RNG(seed), new SimplexNoise(seed), 0.75D, 1); + } + + private static double sample(CNG noise, boolean threeDimensions) { + return threeDimensions ? noise.noiseFastSigned3D(4D, 6D, 8D) : noise.noiseFastSigned2D(4D, 8D); + } + + private static double value(boolean threeDimensions, double x, double y, double z) { + return threeDimensions ? x * 0.01D + y * 0.02D + z * 0.03D : x * 0.01D + z * 0.02D; + } + + private static final class RecordingNoise implements NoiseGenerator { + private Runnable onSample; + private int calls; + + @Override + public double noise(double x) { + return noise(x, 0D); + } + + @Override + public double noise(double x, double z) { + return (noiseSigned(x, z) + 1D) * 0.5D; + } + + @Override + public double noise(double x, double y, double z) { + return (noiseSigned(x, y, z) + 1D) * 0.5D; + } + + @Override + public double noiseSigned(double x, double z) { + sampled(); + return value(false, x, 0D, z); + } + + @Override + public double noiseSigned(double x, double y, double z) { + sampled(); + return value(true, x, y, z); + } + + private void sampled() { + calls++; + Runnable action = onSample; + onSample = null; + if (action != null) { + action.run(); + } + } + } + + private static final class MutatingFracture extends CNG { + private Runnable beforeSample; + + private MutatingFracture(NoiseGenerator source) { + super(new RNG(13L), source, 1D, 1); + } + + @Override + public double noiseFastSigned2D(double x, double z) { + mutate(); + return super.noiseFastSigned2D(x, z); + } + + @Override + public double noiseFastSigned3D(double x, double y, double z) { + mutate(); + return super.noiseFastSigned3D(x, y, z); + } + + private void mutate() { + Runnable action = beforeSample; + beforeSample = null; + if (action != null) { + action.run(); + } + } + } +}