mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-19 15:04:01 +00:00
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
This commit is contained in:
+68
@@ -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<NativeStructureVolume> 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<NativeStructureVolume> 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<Engine, NativeStructureVolumeIndex> 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) {
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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<NativeStructureVolume> 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<NativeStructureVolume> scopedOriginVolumes(
|
||||
Engine engine, int chunkX, int chunkZ, Integer pinnedRuntimeId
|
||||
) {
|
||||
if (pinnedRuntimeId != null) {
|
||||
synchronized (originCache) {
|
||||
KList<NativeStructureVolume> 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);
|
||||
|
||||
@@ -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<MantlePass> 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<Matter> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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"
|
||||
);
|
||||
|
||||
@@ -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<Long, GenerationBoundary> boundaryCache;
|
||||
private final Map<Long, TerrainBoundarySignatureStore.Snapshot> terrainSignatureCache;
|
||||
private final ArrayBlockingQueue<PendingSemanticClaim> 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<PendingSemanticClaim> batch = new ArrayList<>(MAXIMUM_SEMANTIC_BATCH);
|
||||
List<GenerationSemanticIndex.Claim> 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<ChunkGenerationSemantics> semantics(int chunkX, int chunkZ) {
|
||||
public Optional<ChunkGenerationSemantics> 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;
|
||||
|
||||
@@ -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<Matter> mantle = engine.getMantle().getMantle();
|
||||
int minimumX = Math.multiplyExact(chunkX, CHUNK_SIZE);
|
||||
int minimumZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
|
||||
Set<CavePosition> 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<CavePosition> 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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Claim> 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<Long, List<Claim>> 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<Long, List<Claim>> 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<Claim> claims) {
|
||||
try {
|
||||
requireJournalUsable();
|
||||
int regionX = regionX(regionKey);
|
||||
int regionZ = regionZ(regionKey);
|
||||
RegionShard baseRegion = loadRegionLocked(regionKey, regionX, regionZ);
|
||||
RegionShard nextRegion = baseRegion;
|
||||
List<ChunkGenerationSemantics> 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<Long> activationIds) throws IOException {
|
||||
Objects.requireNonNull(stored, "stored chunks");
|
||||
Set<Long> 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<Long> 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<ChunkGenerationSemantics> 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<ChunkGenerationSemantics> recordsSnapshot() {
|
||||
lock.writeLock().lock();
|
||||
publicationLock.lock();
|
||||
try {
|
||||
requireJournalUsable();
|
||||
List<ChunkGenerationSemantics> 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<Long> allRegionKeys() {
|
||||
ArrayList<Long> 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<Long> 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<ChunkGenerationSemantics> 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;
|
||||
|
||||
@@ -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<String, byte[]> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long, RegionIndex> regions = new LinkedHashMap<>(64, 0.75F, true);
|
||||
private final LinkedHashMap<Long, Optional<SavedBiomeChunk>> 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<SavedBiomeChunk> 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<Long, List<PendingWrite>> 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<PendingWrite> 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<PendingWrite> batch : batches.values()) {
|
||||
for (PendingWrite pending : batch) {
|
||||
if (!pending.completed) {
|
||||
pending.failure = unavailable;
|
||||
pending.completed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistBatch(List<PendingWrite> batch) {
|
||||
Map<Integer, PendingWrite> 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<SavedBiomeChunk> 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<PendingWrite> 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;
|
||||
|
||||
|
||||
+34
-12
@@ -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,
|
||||
|
||||
+57
-2
@@ -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<HydrologyTileKey, Boolean> unplannedQueries = Caffeine.newBuilder()
|
||||
.maximumSize(MAXIMUM_CACHE_TILES)
|
||||
.build();
|
||||
private final Cache<PolicyKey, ResolvedPolicy> resolvedPolicies = Caffeine.newBuilder()
|
||||
.maximumSize(MAXIMUM_RESOLVED_POLICIES)
|
||||
.build();
|
||||
private final LinkedHashMap<Long, HydrologyTerrainSample> 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<String> profiles = validProfiles(policy.profiles());
|
||||
List<String> 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<IrisBiome> 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<String> validProfiles(List<String> 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<String> profiles) {
|
||||
}
|
||||
|
||||
private record PolicyKey(IrisRegion region, IrisBiome biome, IrisData data, ResourceLoader<IrisBiome> 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Matter> chunk = getMantle().getChunk(x, z).use();
|
||||
try {
|
||||
synchronized (chunk) {
|
||||
|
||||
@@ -42,6 +42,30 @@ public final class TerrainMatterView {
|
||||
}
|
||||
}
|
||||
|
||||
public static MatterCavern getComposedCavern(MantleChunk<Matter> 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 <T> void iterate(MantleChunk<Matter> chunk, Class<T> type,
|
||||
Consumer4<Integer, Integer, Integer, T> consumer) {
|
||||
Objects.requireNonNull(chunk, "Terrain mantle chunk");
|
||||
@@ -54,7 +78,8 @@ public final class TerrainMatterView {
|
||||
List<TerrainEntry<T>> 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.<T>getSlice(type).get(x & 15, y & 15, z & 15);
|
||||
return matter == null ? null : raw(matter, x, y, z, type);
|
||||
}
|
||||
|
||||
private static <T> T raw(Matter matter, int x, int y, int z, Class<T> type) {
|
||||
return !matter.hasSlice(type) ? null : matter.<T>getSlice(type).get(x & 15, y & 15, z & 15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
|
||||
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
|
||||
try {
|
||||
IrisData wallData = getData();
|
||||
walls.forEach((rx, yy, rz, cavern) -> {
|
||||
HydrologyCaveCell hydrology = dataIfPresent(
|
||||
mantleChunk, rx, yy, rz, HydrologyCaveCell.class);
|
||||
@@ -167,7 +168,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
: resolveCustomBiome(customBiomeCache, customBiome);
|
||||
|
||||
if (biome != null) {
|
||||
PlatformBlockState data = biome.getWall().get(rng, worldX, yy, worldZ, getData());
|
||||
PlatformBlockState data = biome.getWall().get(rng, worldX, yy, worldZ, wallData);
|
||||
int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz);
|
||||
|
||||
if (data != null && B.isSolid(output.getRaw(rx, yy, rz)) && yy < surfaceHeights[columnIndex]) {
|
||||
@@ -566,9 +567,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
}
|
||||
|
||||
private MatterCavern composedCavernAt(MantleChunk<Matter> 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> T dataIfPresent(MantleChunk<Matter> mantleChunk, int x, int y, int z, Class<T> type) {
|
||||
@@ -875,12 +874,15 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomes = new Long2ObjectOpenHashMap<>(256);
|
||||
Map<String, IrisBiome> 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<PlatformBlockState
|
||||
return null;
|
||||
}
|
||||
|
||||
IrisBiome xPos = sampleCaveBiome(caveBiomeCache, x + CAVE_BIOME_BLEND_RADIUS, y, z, resolverState);
|
||||
IrisBiome xNeg = sampleCaveBiome(caveBiomeCache, x - CAVE_BIOME_BLEND_RADIUS, y, z, resolverState);
|
||||
IrisBiome zPos = sampleCaveBiome(caveBiomeCache, x, y, z + CAVE_BIOME_BLEND_RADIUS, resolverState);
|
||||
IrisBiome zNeg = sampleCaveBiome(caveBiomeCache, x, y, z - CAVE_BIOME_BLEND_RADIUS, resolverState);
|
||||
|
||||
if (xPos == center && xNeg == center && zPos == center && zNeg == center) {
|
||||
return center;
|
||||
}
|
||||
|
||||
int roll = Math.floorMod(rng.nextParallelRNG(BlockPosition.toLong(x, y, z)).nextInt(), CAVE_BIOME_BLEND_TOTAL_WEIGHT);
|
||||
if (roll < CAVE_BIOME_BLEND_CENTER_WEIGHT) {
|
||||
return center;
|
||||
}
|
||||
roll -= CAVE_BIOME_BLEND_CENTER_WEIGHT;
|
||||
if (roll == 0) {
|
||||
return xPos != null ? xPos : center;
|
||||
}
|
||||
if (roll == 1) {
|
||||
return xNeg != null ? xNeg : center;
|
||||
}
|
||||
if (roll == 2) {
|
||||
return zPos != null ? zPos : center;
|
||||
}
|
||||
return zNeg != null ? zNeg : center;
|
||||
IrisBiome neighbor = switch (roll) {
|
||||
case 0 -> 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<IrisBiome> caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) {
|
||||
|
||||
@@ -83,7 +83,12 @@ public class IrisDepositModifier extends EngineAssignedModifier<PlatformBlockSta
|
||||
long seed = x * 341873128712L + z * 132897987541L;
|
||||
PreparedDeposit[] prepared = new PreparedDeposit[PREPARATION_BATCH_COUNT];
|
||||
int pending = 0;
|
||||
IrisEngine generationEngine = getEngine() instanceof IrisEngine irisEngine
|
||||
&& irisEngine.hasGenerationRuntimeScope() ? irisEngine : null;
|
||||
PreparationContext preparation = new PreparationContext(context, generationEngine,
|
||||
generationEngine == null ? null : generationEngine.captureGenerationRuntimeBinding(), new Throwable[PREPARATION_BATCH_COUNT]);
|
||||
MantleChunk chunk = getEngine().getMantle().getMantle().getChunk(x, z).use();
|
||||
Throwable callerFailure = null;
|
||||
try {
|
||||
for (int i = 0; i < generators.size(); i++) {
|
||||
DepositPlan plan = plan(generators.get(i), rng.nextParallelRNG(seed * (i + 1L)));
|
||||
@@ -92,19 +97,25 @@ public class IrisDepositModifier extends EngineAssignedModifier<PlatformBlockSta
|
||||
int limit = Math.min(plan.attempts(), first + CLUMPS_PER_BATCH);
|
||||
int index = pending++;
|
||||
burst.queue(scopedDepositTask(
|
||||
() -> 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<PlatformBlockSta
|
||||
}
|
||||
|
||||
private void completeBatches(BurstExecutor burst, PreparedDeposit[] prepared, MantleChunk chunk,
|
||||
Hunk<PlatformBlockState> terrain, int x, int z, ChunkContext context) {
|
||||
Hunk<PlatformBlockState> 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<PlatformBlockSta
|
||||
return mixed ^ (mixed >>> 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) {
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,16 @@ final class CompatPools {
|
||||
String subjectType,
|
||||
String subjectKey,
|
||||
String field) {
|
||||
return surviving(loader, keys, data, subjectType, subjectKey, field, null);
|
||||
}
|
||||
|
||||
static <T extends IrisRegistrant> KList<String> surviving(ResourceLoader<T> loader,
|
||||
List<String> keys,
|
||||
IrisData data,
|
||||
String subjectType,
|
||||
String subjectKey,
|
||||
String field,
|
||||
Runnable unresolvedReference) {
|
||||
KList<String> 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);
|
||||
|
||||
+21
-16
@@ -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<CarvingChoice> 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<CarvingChoice> buildSelectionPlan(
|
||||
Engine engine,
|
||||
IrisData data,
|
||||
IrisDimensionCarvingEntry parent,
|
||||
IrisBiome parentBiome,
|
||||
Map<String, IrisDimensionCarvingEntry> 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<String, IrisDimensionCarvingEntry> 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) {
|
||||
|
||||
@@ -105,12 +105,16 @@ public class IrisRiverPolicy {
|
||||
* selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null.
|
||||
*/
|
||||
public KList<String> compatBiomes(KList<String> declared, IrisData data, String field) {
|
||||
return compatBiomes(declared, data, field, null);
|
||||
}
|
||||
|
||||
public KList<String> compatBiomes(KList<String> 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<String> getAllBiomeIds() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<B> blocks = mockStatic(B.class)) {
|
||||
blocks.when(() -> B.getState(anyString())).thenReturn(air);
|
||||
blocks.when(() -> B.isSolid(any())).thenAnswer(call ->
|
||||
call.<PlatformBlockState>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<PlatformBlockState> 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<TerrainMatterView> 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<Integer, Integer, Integer, MatterCavern> 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<Matter> 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<Matter> 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<EngineRuntimeBuilder.RuntimeAssembly>());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
+214
-4
@@ -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<Optional<ChunkGenerationSemantics>> 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<GenerationHistory.GenerationStage> 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<Thread> 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<Future<Boolean>> 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<GenerationHistory.GenerationStage> stages = new ArrayList<>();
|
||||
List<Future<Boolean>> 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<Boolean> 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<GenerationHistory.GenerationStage> stages = new ArrayList<>();
|
||||
for (int x : new int[]{0, 32, 32, 64}) {
|
||||
stages.add(history.openStage(x, 0));
|
||||
}
|
||||
List<Thread> 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<Future<Boolean>> 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<Boolean> 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<FileChannel> 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<FileChannel> 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<Thread> 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<FileChannel> 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));
|
||||
|
||||
+164
@@ -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<GenerationSemanticIndex.Claim> 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<GenerationSemanticIndex.Claim> 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<FileChannel> 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<GenerationSemanticIndex.Claim> 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<GenerationSemanticIndex.Claim> 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<FileChannel> 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<GenerationSemanticIndex.Claim> claims = List.of(
|
||||
pending(0, "first"), pending(0, "first"), pending(32, "other-region"));
|
||||
IllegalStateException failure = new IllegalStateException("Unexpected channel failure");
|
||||
try (MockedStatic<FileChannel> 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<FileChannel> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Integer> heights = List.of(0, 15, 16, 255, 256, 767, 4063);
|
||||
Set<String> expected = new HashSet<>();
|
||||
List<ChunkGenerationSemantics.BlockPosition> 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<Integer, Integer, Integer, MatterCavern> 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<Integer, Integer, Integer, HydrologyCaveCell> 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() {
|
||||
|
||||
+276
@@ -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<Boolean> rejected = executor.submit(() -> {
|
||||
try (MockedStatic<FileChannel> 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<Boolean> 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<FileChannel> 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<FileChannel> 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<FileChannel> 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<FileChannel> 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<FileChannel> 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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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<String> 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<String> sorted = new ArrayList<>(keys);
|
||||
Collections.sort(sorted);
|
||||
for (Set<String> 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());
|
||||
}
|
||||
}
|
||||
+313
@@ -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<Integer> 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<GenerationActivation> 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<ChunkGenerationSemantics> 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<Throwable> 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<FileChannel> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> keys) {
|
||||
List<TerrainBoundarySignature> 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<TerrainBoundarySignature> columns = new ArrayList<>(256);
|
||||
List<BoundaryColumnGeometry.Voxel> palette = List.of(
|
||||
|
||||
@@ -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<Future<ClaimResult>> 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<ClaimResult> 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<Future<ClaimResult>> 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<Future<ClaimResult>> 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<Future<ClaimResult>> 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<ClaimResult> 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<Future<ClaimResult>> enqueue(SavedBiomeStore store, Path root, BatchControl control,
|
||||
ExecutorService workers, List<SavedBiomeChunk> 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<Future<ClaimResult>> 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<RandomAccessFile> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+259
@@ -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<IrisBiome> excludedLoader = loader(true);
|
||||
ResourceLoader<IrisBiome> 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<IrisBiome> loader(boolean excluded) {
|
||||
ResourceLoader<IrisBiome> 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<IrisBiome> loader = loader(false);
|
||||
private final AtomicReference<IrisRegion> region = new AtomicReference<>(new IrisRegion());
|
||||
private final AtomicReference<IrisBiome> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Chunk> 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<Chunk> 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<Chunk> expected = fullScan(fixture, -7, -4, radius);
|
||||
List<Chunk> 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<Chunk> 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<Chunk> 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<Chunk> expected = fullScan(fixture, 0, 0, 4);
|
||||
List<Chunk> 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<Chunk> fullScan(CoverageFixture fixture, int x, int z, int radius) {
|
||||
List<Chunk> 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<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
Set<Chunk> real = new HashSet<>();
|
||||
Set<Chunk> 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<Matter> mantle,
|
||||
Set<Chunk> real, Set<Chunk> cleaned, AtomicInteger reads) {
|
||||
}
|
||||
}
|
||||
@@ -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<MatterCavern> caverns = Arrays.asList(null, natural, tagged);
|
||||
List<HydrologyCaveCell> hydrology = new ArrayList<>();
|
||||
hydrology.add(null);
|
||||
for (HydrologyCaveAction action : HydrologyCaveAction.values()) {
|
||||
hydrology.add(new HydrologyCaveCell(action, "river", "iris:flooded"));
|
||||
}
|
||||
List<PreObjectMatterCell> 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<Matter> chunk = chunk(matter);
|
||||
for (MatterCavern rawCavern : caverns) {
|
||||
for (HydrologyCaveCell rawHydrology : hydrology) {
|
||||
for (PreObjectMatterCell journal : journals) {
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(15, 3, 2, rawCavern);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(15, 3, 2, rawHydrology);
|
||||
matter.<PreObjectMatterCell>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<Matter> 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.<MatterCavern>slice(MatterCavern.class).set(15, 3, 2, natural);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(15, 3, 2, seal);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(15, 3, 2,
|
||||
PreObjectMatterCell.string("iris:owner"));
|
||||
MatterSlice<PreObjectMatterCell> journal = spy(matter.<PreObjectMatterCell>getSlice(PreObjectMatterCell.class));
|
||||
MatterSlice<HydrologyCaveCell> hydrology = spy(matter.<HydrologyCaveCell>getSlice(HydrologyCaveCell.class));
|
||||
matter.getSliceMap().put(PreObjectMatterCell.class, journal);
|
||||
matter.getSliceMap().put(HydrologyCaveCell.class, hydrology);
|
||||
MantleChunk<Matter> 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<Matter> 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<Matter> 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<Matter> chunk(Matter matter) {
|
||||
MantleChunk<Matter> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.<MatterCavern>slice(MatterCavern.class).set(1, 3, 2, original);
|
||||
MantleChunk<Matter> 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<MatterCavern> 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.<MatterCavern>slice(MatterCavern.class).set(1, 3, 2, original);
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(4, 3, 2, original);
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
List<MatterCavern> 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<Matter> chunk = mock(MantleChunk.class);
|
||||
Consumer4<Integer, Integer, Integer, Integer> consumer = (x, y, z, value) -> {
|
||||
assertFalse(Thread.holdsLock(chunk));
|
||||
assertEquals(7, value.intValue());
|
||||
};
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, Integer> 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 <T> void assertJournalIteration(Class<T> type, T original, T content,
|
||||
Function<T, PreObjectMatterCell> capture) {
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
matter.<T>slice(type).set(1, 3, 2, content);
|
||||
matter.<T>slice(type).set(2, 3, 2, content);
|
||||
matter.<T>slice(type).set(4, 3, 2, original);
|
||||
matter.<T>slice(type).set(5, 3, 2, content);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(1, 3, 2, capture.apply(null));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(2, 3, 2, capture.apply(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(3, 3, 2, capture.apply(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(5, 3, 2,
|
||||
type == String.class ? PreObjectMatterCell.cavern(null) : PreObjectMatterCell.string(null));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(6, 3, 2, capture.apply(null));
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
List<Integer> positions = new ArrayList<>();
|
||||
List<T> 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.<T>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<Matter> chunk(Matter matter) {
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
when(chunk.exists(0)).thenReturn(true);
|
||||
when(chunk.get(0)).thenReturn(matter);
|
||||
doAnswer(invocation -> {
|
||||
assertTrue(Thread.holdsLock(chunk));
|
||||
Class<Object> type = invocation.getArgument(0);
|
||||
Consumer4<Integer, Integer, Integer, Object> consumer = invocation.getArgument(1);
|
||||
if (!matter.hasSlice(type)) {
|
||||
|
||||
+203
@@ -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<IrisBiome[]> 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<Long> 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<Long> 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<IrisBiome> cache = new Long2ObjectOpenHashMap<>();
|
||||
Long2ObjectOpenHashMap<IrisBiome> 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<IrisBiome> 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<IrisBiome> 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<IrisBiome> 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;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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<Matter> mantleChunk = mock(MantleChunk.class);
|
||||
Matter floorMatter = mock(Matter.class);
|
||||
|
||||
+61
-11
@@ -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<Matter> 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());
|
||||
|
||||
+223
@@ -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<String, PlatformBlockState> 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<Matter> chunk = mock(MantleChunk.class);
|
||||
Mantle<Matter> 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<PlatformBlockState> 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<B> 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<PlatformBlockState> blocks;
|
||||
|
||||
private PaletteGenerator(PlatformBlockState block) {
|
||||
blocks = new KList<>(block);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KList<PlatformBlockState> getBlockData(IrisData data) {
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<B> 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<PlatformBlockState> 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<PlatformBlockState> 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<PlatformBlockState> output = fixture.output(width, depth);
|
||||
List<String> writes = new ArrayList<>();
|
||||
fixture.modifier.decorateNaturalCaves(-32, 48,
|
||||
output.listen((x, y, z, state) -> writes.add(x + ":" + y + ":" + z + ":" + state.key())));
|
||||
List<String> expectedCalls = new ArrayList<>();
|
||||
List<String> expectedMarkers = new ArrayList<>();
|
||||
List<String> 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<Matter> chunk;
|
||||
private final List<String> calls = new ArrayList<>();
|
||||
private final List<String> markers = new ArrayList<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Mantle<Matter> 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<PlatformBlockState> 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<PlatformBlockState> 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<PlatformBlockState> output(int width, int depth) {
|
||||
Hunk<PlatformBlockState> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-3
@@ -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) {
|
||||
|
||||
+176
@@ -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<Consumer<CNG>> 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<CNG> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user