diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java index daadd5334..cf6638152 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java @@ -29,6 +29,7 @@ import java.util.function.LongSupplier; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.generation.runtime.DimensionStackContext; import art.arcane.iris.generation.runtime.DimensionStackLayout; import art.arcane.iris.generation.runtime.DimensionTerrainContext; @@ -338,7 +339,12 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { return cached; } } - H resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler); + H resolved; + try { + resolved = ProvisionalSampling.memoizable(() -> resolveStructureBiome(engine, quartX, quartY, quartZ, sampler)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } if (cacheable) { cache.put(runtimeIdentity, key, resolved); } @@ -388,8 +394,13 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { return cached; } } - H resolved = resolveVisibleBiome( - engine, quartX, quartY, quartZ, sampler, null); + H resolved; + try { + resolved = ProvisionalSampling.memoizable(() -> resolveVisibleBiome( + engine, quartX, quartY, quartZ, sampler, null)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } if (cacheable) { cache.put(runtimeIdentity, key, resolved); } @@ -531,7 +542,12 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { if (cached != null) { return cached; } - H resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler); + H resolved; + try { + resolved = ProvisionalSampling.memoizable(() -> resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } cache.put(runtimeIdentity, key, resolved); return resolved; } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancy.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancy.java new file mode 100644 index 000000000..685b3aa2b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancy.java @@ -0,0 +1,64 @@ +package art.arcane.iris.generation.hydrology; + +/** + * The chunks a planned tile publishes at least one footprint column into. A chunk outside every + * relevant tile's occupancy composes to no hydrology at all, so it can be answered without the + * tiles being resident. + */ +final class HydrologyChunkOccupancy { + static final HydrologyChunkOccupancy EMPTY = new HydrologyChunkOccupancy(0, 0, 0, 0, new long[0]); + + private final int minimumChunkX; + private final int minimumChunkZ; + private final int width; + private final int depth; + private final long[] bits; + + private HydrologyChunkOccupancy(int minimumChunkX, int minimumChunkZ, int width, int depth, long[] bits) { + this.minimumChunkX = minimumChunkX; + this.minimumChunkZ = minimumChunkZ; + this.width = width; + this.depth = depth; + this.bits = bits; + } + + static HydrologyChunkOccupancy of(RiverFootprint footprint) { + if (footprint == null) { + return EMPTY; + } + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (long packed : footprint.columns().keySet()) { + int chunkX = RiverFootprint.unpackX(packed) >> 4; + int chunkZ = RiverFootprint.unpackZ(packed) >> 4; + minimumX = Math.min(minimumX, chunkX); + minimumZ = Math.min(minimumZ, chunkZ); + maximumX = Math.max(maximumX, chunkX); + maximumZ = Math.max(maximumZ, chunkZ); + } + if (minimumX > maximumX) { + return EMPTY; + } + int width = maximumX - minimumX + 1; + int depth = maximumZ - minimumZ + 1; + long[] bits = new long[Math.toIntExact(Math.ceilDiv(Math.multiplyExact((long) width, depth), 64L))]; + for (long packed : footprint.columns().keySet()) { + int index = ((RiverFootprint.unpackZ(packed) >> 4) - minimumZ) * width + + (RiverFootprint.unpackX(packed) >> 4) - minimumX; + bits[index >>> 6] |= 1L << index; + } + return new HydrologyChunkOccupancy(minimumX, minimumZ, width, depth, bits); + } + + boolean occupies(int chunkX, int chunkZ) { + int localX = chunkX - minimumChunkX; + int localZ = chunkZ - minimumChunkZ; + if (localX < 0 || localZ < 0 || localX >= width || localZ >= depth) { + return false; + } + int index = localZ * width + localX; + return (bits[index >>> 6] & 1L << index) != 0L; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index 29461755c..6429e5cd7 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.volmlib.util.cache.CacheKey; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; @@ -9,7 +10,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -36,6 +37,13 @@ public final class HydrologyTileCache implements AutoCloseable { private static final int MAXIMUM_COMPOSED_CHUNKS = 256; private static final int CHUNK_SIZE = 16; private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE; + private static final int LOCAL_CHUNK_SLOTS = 16; + private static final byte AROUND_UNKNOWN = 0; + private static final byte AROUND_EMPTY = 1; + private static final byte AROUND_PUBLISHED = 2; + private static final int MAXIMUM_PREGENERATION_TILES = 4096; + private static final int PREGENERATION_HALO_BLOCKS = 512; + private static final HydrologyColumnSample[] NO_COLUMNS = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; private static final Cache SHARED_TILES = Caffeine.newBuilder() .maximumWeight(HydrologyCacheBudget.sharedStudioBytes()) .weigher((SharedTileKey key, HydrologyTile tile) -> HydrologyCacheWeights.bounded( @@ -48,17 +56,18 @@ public final class HydrologyTileCache implements AutoCloseable { private final int publicationRadius; private final int maximumEntries; private final Cache tiles; + private final ConcurrentHashMap occupancies; private final Cache composedChunks; private final Cache> diagnostics; private final AtomicLong cacheEpoch; private final AtomicBoolean closed; - private final ThreadLocal localChunkColumns; + private final ThreadLocal localChunks; private final Executor prefetchExecutor; + private final int prefetchSlots; private final ConcurrentLinkedQueue prefetchQueue; private final Set queuedPrefetches; private final LinkedHashSet queriedChunks; private final Set> demandedPlans; - private final AtomicBoolean prefetchActive; private final ConcurrentHashMap, CompletableFuture> planning; private final ConcurrentHashMap, PendingLoad> loading; private final ConcurrentHashMap, PendingLoad> composing; @@ -72,6 +81,8 @@ public final class HydrologyTileCache implements AutoCloseable { private volatile Runnable terrainPreparation; private PregenerationScope pregenerationScope; private int demandBatches; + private int activePrefetches; + private boolean pumping; public HydrologyTileCache(HydrologyPlanner planner) { this(planner, DEFAULT_MAXIMUM_ENTRIES, null, null, null); @@ -112,6 +123,7 @@ public final class HydrologyTileCache implements AutoCloseable { this.tileSize = planner.settings().routing().tileSize(); this.publicationRadius = planner.settings().publicationRadius(); this.prefetchExecutor = prefetchExecutor; + this.prefetchSlots = prefetchSlots(HydrologyPlanningAdmission.maximumRoots()); this.waitingForbidden = waitingForbidden; this.sharedCacheScope = sharedCacheScope; this.persistentStore = null; @@ -128,7 +140,7 @@ public final class HydrologyTileCache implements AutoCloseable { this.queuedPrefetches = ConcurrentHashMap.newKeySet(); this.queriedChunks = new LinkedHashSet<>(); this.demandedPlans = new HashSet<>(); - this.prefetchActive = new AtomicBoolean(); + this.occupancies = new ConcurrentHashMap<>(); HydrologyCacheBudget budget = HydrologyCacheBudget.runtime(); this.tiles = Caffeine.newBuilder() .maximumWeight(budget.tileBytes()) @@ -147,10 +159,18 @@ public final class HydrologyTileCache implements AutoCloseable { .build(); this.cacheEpoch = new AtomicLong(); this.closed = new AtomicBoolean(); - this.localChunkColumns = new ThreadLocal<>(); + this.localChunks = ThreadLocal.withInitial(LocalChunks::new); this.neighbourPrefetchEnabled = true; } + /** + * Speculative roots run beside each other but leave one root permit free, so a chunk that needs a + * tile nobody queued yet does not wait behind a full row of prefetches. + */ + static int prefetchSlots(int maximumRoots) { + return Math.max(1, maximumRoots - 1); + } + /** * The tile, planned on the prefetch executor when there is one so that a caller that gets * interrupted or cancelled (a map render, a command) never aborts a plan every other caller is @@ -317,9 +337,14 @@ public final class HydrologyTileCache implements AutoCloseable { try { present = tiles.getIfPresent(key); HydrologyTile tile = present == null ? planRequired(key, epoch) : present; + HydrologyChunkOccupancy occupancy = occupancies.get(key); + if (occupancy == null) { + occupancy = HydrologyChunkOccupancy.of(tile.footprint()); + } synchronized (publicationLock) { if (!closed.get() && cacheEpoch.get() == epoch) { tiles.put(key, tile); + occupancies.putIfAbsent(key, occupancy); } } owned.future().complete(tile); @@ -355,9 +380,20 @@ public final class HydrologyTileCache implements AutoCloseable { SHARED_TILES.invalidate(sharedKey); } } + if (attempt == 0L && store != null) { + HydrologyTile persisted = store.load(key).orElse(null); + if (persisted != null) { + planner.reuseResolvedTile(persisted); + if (sharedKey != null) { + SHARED_TILES.put(sharedKey, persisted); + } + IrisLogging.debug("Loaded persisted prepared hydrology tile %d,%d", key.tileX(), key.tileZ()); + return persisted; + } + } try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot( closed::get)) { - return planAdmitted(key, planningEpoch, sharedKey, store, attempt == 0L); + return planAdmitted(key, planningEpoch, sharedKey, store); } } catch (RuntimeException failure) { if (failure instanceof CancellationException || interrupted(failure)) { @@ -407,18 +443,9 @@ public final class HydrologyTileCache implements AutoCloseable { } } + /** Plans the tile under a root permit; a persisted plan is restored before any permit is taken. */ private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch, - SharedTileKey sharedKey, PreparedHydrologyTileStore store, - boolean reusePrepared) { - if (store != null && reusePrepared) { - HydrologyTile persisted = store.load(key).orElse(null); - if (persisted != null) { - planner.reuseResolvedTile(persisted); - SHARED_TILES.put(sharedKey, persisted); - IrisLogging.debug("Loaded persisted prepared hydrology tile %d,%d", key.tileX(), key.tileZ()); - return persisted; - } - } + SharedTileKey sharedKey, PreparedHydrologyTileStore store) { prepareTerrain(); HydrologyTile planned = planner.plan(key); synchronized (publicationLock) { @@ -470,10 +497,12 @@ public final class HydrologyTileCache implements AutoCloseable { } int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); - LocalChunkColumns local = localChunkColumns.get(); - if (local != null && local.epoch() == cacheEpoch.get() - && local.columns().chunkX() == chunkX && local.columns().chunkZ() == chunkZ) { - return HydrologyColumnSnapshot.ready(local.columns().columnAt(blockX, blockZ).orElse(null)); + ChunkColumns local = localChunks(cacheEpoch.get()).get(chunkX, chunkZ); + if (local != null) { + return HydrologyColumnSnapshot.ready(local.columnAt(blockX, blockZ).orElse(null)); + } + if (knownEmpty(chunkX, chunkZ)) { + return HydrologyColumnSnapshot.ready(null); } ChunkColumns composed = composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))); if (composed != null) { @@ -485,6 +514,7 @@ public final class HydrologyTileCache implements AutoCloseable { HydrologyTile tile = tiles.getIfPresent(key); if (tile == null) { requestUnplanned(chunkX, chunkZ); + ProvisionalSampling.mark(); return HydrologyColumnSnapshot.unavailable(); } snapshots.add(tile); @@ -497,18 +527,56 @@ public final class HydrologyTileCache implements AutoCloseable { } /** - * Whether every tile the column's chunk composes from is already planned. A caller that must not - * wait (the server thread answering a height or biome query) uses this before sampling: when the - * answer is false the missing tiles are handed to the prefetch executor so a later query finds - * them, and nothing is planned on the caller. + * Whether the column's chunk can be answered without planning: every tile it composes from is + * already planned. A caller that must not wait (the server thread answering a height or biome + * query) uses this before sampling: when the answer is false the missing tiles are handed to the + * prefetch executor so a later query finds them, nothing is planned on the caller, and the caller + * is marked as answering provisionally. */ public boolean isPlanned(int blockX, int blockZ) { int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); - if (composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))) != null) { + if (knownEmpty(chunkX, chunkZ) + || composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))) != null) { return true; } - return !requestUnplanned(chunkX, chunkZ); + if (requestUnplanned(chunkX, chunkZ)) { + ProvisionalSampling.mark(); + return false; + } + return true; + } + + /** + * Whether any planned footprint column lies in the chunk or one of its eight neighbours, which + * decides whether the chunk can hold published hydrology at all. Tiles are planned like a sample + * would plan them, and each thread remembers its recent final answers. + */ + public boolean hasColumnsAround(int chunkX, int chunkZ) { + long epoch = cacheEpoch.get(); + LocalChunks local = localChunks(epoch); + int slot = LocalChunks.slot(chunkX, chunkZ); + long key = RiverFootprint.pack(chunkX, chunkZ); + if (local.around[slot] != AROUND_UNKNOWN && local.aroundKeys[slot] == key) { + return local.around[slot] == AROUND_PUBLISHED; + } + boolean published = publishesAround(chunkX, chunkZ); + if (!waitsForbidden() && local.epoch == epoch) { + local.aroundKeys[slot] = key; + local.around[slot] = published ? AROUND_PUBLISHED : AROUND_EMPTY; + } + return published; + } + + private boolean publishesAround(int chunkX, int chunkZ) { + for (int offsetZ = -1; offsetZ <= 1; offsetZ++) { + for (int offsetX = -1; offsetX <= 1; offsetX++) { + if (!chunkColumns((chunkX + offsetX) * CHUNK_SIZE, (chunkZ + offsetZ) * CHUNK_SIZE).empty()) { + return true; + } + } + } + return false; } /** @@ -567,6 +635,7 @@ public final class HydrologyTileCache implements AutoCloseable { cacheEpoch.incrementAndGet(); planner.clearOwnerDrafts(); tiles.invalidateAll(); + occupancies.clear(); composedChunks.invalidateAll(); diagnostics.invalidateAll(); composing.forEach((key, load) -> { @@ -581,7 +650,7 @@ public final class HydrologyTileCache implements AutoCloseable { demandedPlans.clear(); demandBatches = 0; } - localChunkColumns.remove(); + localChunks.remove(); for (CompletableFuture future : cancelledQueries) { future.completeExceptionally(new CancellationException("Hydrology query was invalidated.")); } @@ -600,19 +669,27 @@ public final class HydrologyTileCache implements AutoCloseable { neighbourPrefetchEnabled = enabled; } + /** + * Queues every tile the area's chunks compose from, nearest the centre first and at most + * {@link #MAXIMUM_PREGENERATION_TILES} of them, so planning runs ahead of the generation front and + * each plan lands in the prepared-plan store. Speculation stays inside the area plus a halo that + * covers generation reaching past its edge. + */ public PregenerationScope preparePregeneration(PregenerationArea area) { Objects.requireNonNull(area, "area"); + long reach = (long) PREGENERATION_HALO_BLOCKS + publicationRadius; TileBounds bounds = new TileBounds( + tileCoordinate(Math.subtractExact(area.minimumBlockX(), reach), tileSize), + tileCoordinate(Math.subtractExact(area.minimumBlockZ(), reach), tileSize), + tileCoordinate(Math.addExact(area.maximumBlockX(), reach), tileSize), + tileCoordinate(Math.addExact(area.maximumBlockZ(), reach), tileSize)); + TileBounds required = new TileBounds( tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize), tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize), tileCoordinate(Math.addExact(area.maximumBlockX(), publicationRadius), tileSize), tileCoordinate(Math.addExact(area.maximumBlockZ(), publicationRadius), tileSize)); - List keys = prefetchAreaKeys( - Math.max(area.minimumBlockX(), (long) area.centerBlockX() - tileSize), - Math.max(area.minimumBlockZ(), (long) area.centerBlockZ() - tileSize), - Math.min(area.maximumBlockX(), (long) area.centerBlockX() + tileSize), - Math.min(area.maximumBlockZ(), (long) area.centerBlockZ() + tileSize), - area.centerBlockX(), area.centerBlockZ()); + List keys = nearestFirst(required, tileCoordinate(area.centerBlockX(), tileSize), + tileCoordinate(area.centerBlockZ(), tileSize), MAXIMUM_PREGENERATION_TILES); PregenerationScope scope = new PregenerationScope(bounds); try { enqueuePrefetchArea(keys, scope); @@ -713,47 +790,109 @@ public final class HydrologyTileCache implements AutoCloseable { && tile.tileSize() == tileSize; } + /** + * The chunk's composed columns. A chunk no planned tile publishes into is answered from the + * occupancy index alone, without composition or the tiles being resident; the last chunks each + * thread resolved answer repeated lookups without touching any shared structure. + */ private ChunkColumns chunkColumns(int blockX, int blockZ) { requireOpen(); int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); long epoch = cacheEpoch.get(); - LocalChunkColumns local = localChunkColumns.get(); - if (local != null && local.epoch() == epoch - && local.columns().chunkX() == chunkX - && local.columns().chunkZ() == chunkZ) { - return local.columns(); + LocalChunks local = localChunks(epoch); + ChunkColumns cached = local.get(chunkX, chunkZ); + if (cached != null) { + return cached; } - long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)); - if (waitsForbidden()) { - ChunkColumns present = composedChunks.getIfPresent(packedChunk); - if (present != null) { - return present; + TileBounds relevant = chunkTiles(chunkX, chunkZ); + ChunkColumns columns; + if (knownEmpty(chunkX, chunkZ, relevant)) { + columns = local.put(ChunkColumns.empty(chunkX, chunkZ)); + } else if (waitsForbidden()) { + columns = nonblockingChunkColumns(chunkX, chunkZ, epoch); + if (columns == null) { + ProvisionalSampling.mark(); + columns = ChunkColumns.empty(chunkX, chunkZ); + } else { + local.put(columns); } - ArrayList keys = relevantKeys(chunkX, chunkZ); - ArrayList snapshots = new ArrayList<>(keys.size()); - for (HydrologyTileKey key : keys) { - HydrologyTile tile = tiles.getIfPresent(key); - if (tile == null) { - requestChunk(chunkX, chunkZ); - return ChunkColumns.empty(chunkX, chunkZ); - } - snapshots.add(tile); - } - ChunkColumns columns = composeChunkColumns(chunkX, chunkZ, keys, snapshots); - synchronized (publicationLock) { - if (!closed.get() && cacheEpoch.get() == epoch) { - composedChunks.put(packedChunk, columns); - } - } - localChunkColumns.set(new LocalChunkColumns(epoch, columns)); - return columns; + } else { + long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)); + columns = local.put(loadChunkColumns(new CacheLoadKey<>(epoch, packedChunk), chunkX, chunkZ)); + } + if (!relevant.equals(local.prefetched)) { + local.prefetched = relevant; + prefetchNeighbours(relevant); } - ChunkColumns columns = loadChunkColumns(new CacheLoadKey<>(epoch, packedChunk), chunkX, chunkZ); - localChunkColumns.set(new LocalChunkColumns(epoch, columns)); return columns; } + /** The chunk's columns from resident tiles only, or null after handing its missing tiles to the planner. */ + private ChunkColumns nonblockingChunkColumns(int chunkX, int chunkZ, long epoch) { + long packedChunk = CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ)); + ChunkColumns present = composedChunks.getIfPresent(packedChunk); + if (present != null) { + return present; + } + ArrayList keys = relevantKeys(chunkX, chunkZ); + ArrayList snapshots = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + HydrologyTile tile = tiles.getIfPresent(key); + if (tile == null) { + requestChunk(chunkX, chunkZ); + return null; + } + snapshots.add(tile); + } + ChunkColumns columns = composeChunkColumns(chunkX, chunkZ, keys, snapshots); + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == epoch) { + composedChunks.put(packedChunk, columns); + } + } + return columns; + } + + private LocalChunks localChunks(long epoch) { + LocalChunks local = localChunks.get(); + if (local.epoch != epoch) { + Arrays.fill(local.slots, null); + Arrays.fill(local.around, AROUND_UNKNOWN); + local.prefetched = null; + local.epoch = epoch; + } + return local; + } + + /** The tiles a chunk's columns compose from: every tile within the publication radius of the chunk. */ + private TileBounds chunkTiles(int chunkX, int chunkZ) { + long minimumBlockX = (long) chunkX * CHUNK_SIZE; + long minimumBlockZ = (long) chunkZ * CHUNK_SIZE; + return new TileBounds( + tileCoordinate(minimumBlockX - publicationRadius, tileSize), + tileCoordinate(minimumBlockZ - publicationRadius, tileSize), + tileCoordinate(minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize), + tileCoordinate(minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize)); + } + + private boolean knownEmpty(int chunkX, int chunkZ) { + return knownEmpty(chunkX, chunkZ, chunkTiles(chunkX, chunkZ)); + } + + /** Whether every tile the chunk composes from is planned and none of them publishes into it. */ + private boolean knownEmpty(int chunkX, int chunkZ, TileBounds relevant) { + for (int tileZ = relevant.minimumZ(); tileZ <= relevant.maximumZ(); tileZ++) { + for (int tileX = relevant.minimumX(); tileX <= relevant.maximumX(); tileX++) { + HydrologyChunkOccupancy occupancy = occupancies.get(new HydrologyTileKey(tileX, tileZ)); + if (occupancy == null || occupancy.occupies(chunkX, chunkZ)) { + return false; + } + } + } + return true; + } + private ChunkColumns loadChunkColumns(CacheLoadKey loadKey, int chunkX, int chunkZ) { long epoch = loadKey.epoch(); long packedChunk = loadKey.key(); @@ -801,19 +940,12 @@ public final class HydrologyTileCache implements AutoCloseable { } } - /** The tiles a chunk's columns compose from: every tile within the publication radius of the chunk. */ private ArrayList relevantKeys(int chunkX, int chunkZ) { - int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); - int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); - int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); - int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize); - int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize); - int maximumTileZ = tileCoordinate((long) minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize); - ArrayList relevantKeys = new ArrayList<>( - Math.multiplyExact(maximumTileX - minimumTileX + 1, maximumTileZ - minimumTileZ + 1) - ); - for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { - for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + TileBounds relevant = chunkTiles(chunkX, chunkZ); + ArrayList relevantKeys = new ArrayList<>(Math.multiplyExact( + relevant.maximumX() - relevant.minimumX() + 1, relevant.maximumZ() - relevant.minimumZ() + 1)); + for (int tileZ = relevant.minimumZ(); tileZ <= relevant.maximumZ(); tileZ++) { + for (int tileX = relevant.minimumX(); tileX <= relevant.maximumX(); tileX++) { relevantKeys.add(new HydrologyTileKey(tileX, tileZ)); } } @@ -821,11 +953,17 @@ public final class HydrologyTileCache implements AutoCloseable { } private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) { + if (knownEmpty(chunkX, chunkZ)) { + return ChunkColumns.empty(chunkX, chunkZ); + } ArrayList relevantKeys = relevantKeys(chunkX, chunkZ); return composeChunkColumns(chunkX, chunkZ, relevantKeys, tiles(relevantKeys)); } private ChunkColumns composeChunkColumnsInline(int chunkX, int chunkZ, long epoch) { + if (knownEmpty(chunkX, chunkZ)) { + return ChunkColumns.empty(chunkX, chunkZ); + } ArrayList keys = relevantKeys(chunkX, chunkZ); ArrayList snapshots = new ArrayList<>(keys.size()); for (HydrologyTileKey key : keys) { @@ -839,41 +977,43 @@ public final class HydrologyTileCache implements AutoCloseable { List relevantTiles) { int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); - int minimumTileX = relevantKeys.getFirst().tileX(); - int maximumTileX = relevantKeys.getLast().tileX(); - int minimumTileZ = relevantKeys.getFirst().tileZ(); - int maximumTileZ = relevantKeys.getLast().tileZ(); - prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ); + if (knownEmpty(chunkX, chunkZ)) { + return ChunkColumns.empty(chunkX, chunkZ); + } HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; + boolean published = false; for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { for (int localX = 0; localX < CHUNK_SIZE; localX++) { int blockX = minimumBlockX + localX; int blockZ = minimumBlockZ + localZ; - columns[localZ * CHUNK_SIZE + localX] = composeColumn( + HydrologyColumnSample column = composeColumn( blockX, blockZ, relevantKeys, relevantTiles ) .orElse(null); + columns[localZ * CHUNK_SIZE + localX] = column; + published |= column != null; } } - return new ChunkColumns(chunkX, chunkZ, columns); + return published ? new ChunkColumns(chunkX, chunkZ, columns) : ChunkColumns.empty(chunkX, chunkZ); } /** - * Plans the ring of tiles around the ones a chunk just needed, on the prefetch executor, so a - * generation front walking outward finds its next tiles already planned instead of stalling on - * a cold plan. Tiles already cached or already being prefetched are skipped; a tile a chunk - * needs before its prefetch finishes simply joins that computation. + * Plans the ring of tiles around the ones a chunk needs, on the prefetch executor, so a generation + * front walking outward finds its next tiles already planned instead of stalling on a cold plan. + * Tiles already planned or already being prefetched are skipped; a tile a chunk needs before its + * prefetch finishes simply joins that computation. */ - private void prefetchNeighbours(int minimumTileX, int maximumTileX, int minimumTileZ, int maximumTileZ) { + private void prefetchNeighbours(TileBounds relevant) { if (prefetchExecutor == null || !neighbourPrefetchEnabled) { return; } - for (int tileZ = minimumTileZ - 1; tileZ <= maximumTileZ + 1; tileZ++) { - for (int tileX = minimumTileX - 1; tileX <= maximumTileX + 1; tileX++) { - if (tileX >= minimumTileX && tileX <= maximumTileX && tileZ >= minimumTileZ && tileZ <= maximumTileZ) { + for (int tileZ = relevant.minimumZ() - 1; tileZ <= relevant.maximumZ() + 1; tileZ++) { + for (int tileX = relevant.minimumX() - 1; tileX <= relevant.maximumX() + 1; tileX++) { + if (tileX >= relevant.minimumX() && tileX <= relevant.maximumX() + && tileZ >= relevant.minimumZ() && tileZ <= relevant.maximumZ()) { continue; } prefetch(new HydrologyTileKey(tileX, tileZ)); @@ -886,33 +1026,51 @@ public final class HydrologyTileCache implements AutoCloseable { if (prefetchExecutor == null) { return; } - enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, - centreBlockX, centreBlockZ), null); + TileBounds area = new TileBounds( + tileCoordinate((long) minimumBlockX - publicationRadius, tileSize), + tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize), + tileCoordinate((long) maximumBlockX + publicationRadius, tileSize), + tileCoordinate((long) maximumBlockZ + publicationRadius, tileSize)); + enqueuePrefetchArea(nearestFirst(area, tileCoordinate(centreBlockX, tileSize), + tileCoordinate(centreBlockZ, tileSize), Integer.MAX_VALUE), null); } - private List prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX, - long maximumBlockZ, int centreBlockX, int centreBlockZ) { - int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); - int maximumTileX = tileCoordinate((long) maximumBlockX + publicationRadius, tileSize); - int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize); - int maximumTileZ = tileCoordinate((long) maximumBlockZ + publicationRadius, tileSize); - int centreTileX = tileCoordinate(centreBlockX, tileSize); - int centreTileZ = tileCoordinate(centreBlockZ, tileSize); + /** Tiles of the bounds in rings around the centre tile, each ring in row order, up to the limit. */ + static List nearestFirst(TileBounds bounds, int centreX, int centreZ, int limit) { ArrayList keys = new ArrayList<>(); - for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { - for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { - keys.add(new HydrologyTileKey(tileX, tileZ)); + long rings = Math.max( + Math.max((long) centreX - bounds.minimumX(), (long) bounds.maximumX() - centreX), + Math.max((long) centreZ - bounds.minimumZ(), (long) bounds.maximumZ() - centreZ)); + for (long ring = 0L; ring <= rings && keys.size() < limit; ring++) { + long firstZ = Math.max(bounds.minimumZ(), centreZ - ring); + long lastZ = Math.min(bounds.maximumZ(), centreZ + ring); + for (long tileZ = firstZ; tileZ <= lastZ && keys.size() < limit; tileZ++) { + if (tileZ == centreZ - ring || tileZ == centreZ + ring) { + long firstX = Math.max(bounds.minimumX(), centreX - ring); + long lastX = Math.min(bounds.maximumX(), centreX + ring); + for (long tileX = firstX; tileX <= lastX && keys.size() < limit; tileX++) { + keys.add(new HydrologyTileKey((int) tileX, (int) tileZ)); + } + continue; + } + long westX = centreX - ring; + long eastX = centreX + ring; + if (westX >= bounds.minimumX() && westX <= bounds.maximumX()) { + keys.add(new HydrologyTileKey((int) westX, (int) tileZ)); + } + if (eastX >= bounds.minimumX() && eastX <= bounds.maximumX() && keys.size() < limit) { + keys.add(new HydrologyTileKey((int) eastX, (int) tileZ)); + } } } - keys.sort(Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX() - centreTileX), Math.abs(key.tileZ() - centreTileZ))) - .thenComparingInt(HydrologyTileKey::tileZ) - .thenComparingInt(HydrologyTileKey::tileX)); return keys; } private void enqueuePrefetchArea(List keys, PregenerationScope scope) { Set area = scope != null ? new HashSet<>(keys) : Set.of(); - tiles.cleanUp(); + if (persistentStore == null) { + tiles.cleanUp(); + } synchronized (prefetchQueue) { if (closed.get()) { if (scope != null) { @@ -937,7 +1095,7 @@ public final class HydrologyTileCache implements AutoCloseable { return; } for (HydrologyTileKey key : keys) { - if (!enqueuePrefetch(key) && speculativeEntryCount() >= maximumEntries) { + if (!enqueuePrefetch(key) && speculationFull()) { break; } } @@ -947,12 +1105,14 @@ public final class HydrologyTileCache implements AutoCloseable { private boolean prefetch(HydrologyTileKey key) { if (closed.get() - || tiles.getIfPresent(key) != null + || occupancies.containsKey(key) || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) || queuedPrefetches.contains(key)) { return false; } - tiles.cleanUp(); + if (persistentStore == null) { + tiles.cleanUp(); + } synchronized (prefetchQueue) { if (!enqueuePrefetch(key)) { return false; @@ -965,10 +1125,10 @@ public final class HydrologyTileCache implements AutoCloseable { private boolean enqueuePrefetch(HydrologyTileKey key) { if (closed.get() || pregenerationScope != null && !pregenerationScope.bounds.contains(key) - || tiles.getIfPresent(key) != null + || occupancies.containsKey(key) || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) || queuedPrefetches.contains(key) - || speculativeEntryCount() >= maximumEntries) { + || speculationFull()) { return false; } queuedPrefetches.add(key); @@ -976,6 +1136,15 @@ public final class HydrologyTileCache implements AutoCloseable { return true; } + /** + * Without a prepared-plan store a speculative tile lives only in memory, so speculation stops at + * the tile budget; with one every plan is persisted and its occupancy kept, so it never has to + * stay resident. + */ + private boolean speculationFull() { + return persistentStore == null && speculativeEntryCount() >= maximumEntries; + } + private int speculativeEntryCount() { long entries = tiles.estimatedSize(); long budget = HydrologyCacheBudget.runtime().tileBytes(); @@ -993,57 +1162,80 @@ public final class HydrologyTileCache implements AutoCloseable { return Math.max(0L, budget - weight) / expectedWeight; } + /** + * Starts queued work while nothing is demanded: a nonblocking chunk query on its own, otherwise + * up to {@link #prefetchSlots} speculative roots at once. One caller pumps at a time and loops, so + * work that completes on the calling thread resumes the loop instead of recursing into it. + */ private void pumpPrefetch() { synchronized (prefetchQueue) { - if (prefetchExecutor == null || closed.get() || demandBatches != 0 || !demandedPlans.isEmpty() - || !prefetchActive.compareAndSet(false, true)) { + if (pumping) { return; } + pumping = true; } - scheduleNextPrefetch(); - } - - private void scheduleNextPrefetch() { - CacheLoadKey loadKey = null; - ChunkCoordinate chunk; - long epoch; - synchronized (prefetchQueue) { - if (closed.get() || demandBatches != 0 || !demandedPlans.isEmpty()) { - prefetchActive.set(false); - return; - } - epoch = cacheEpoch.get(); - chunk = pollQuery(); - if (chunk != null) { - demandBatches++; - } else { - HydrologyTileKey key = pollPrefetch(); - if (key == null) { - prefetchActive.set(false); - return; - } - loadKey = new CacheLoadKey<>(epoch, key); - } - } - if (chunk != null) { - composeAsync(chunk, epoch).whenComplete((columns, failure) -> { + boolean idle = false; + try { + while (!idle) { + ChunkCoordinate chunk = null; + CacheLoadKey loadKey = null; + long epoch; synchronized (prefetchQueue) { - if (epoch == cacheEpoch.get()) { - queriedChunks.remove(chunk); + epoch = cacheEpoch.get(); + if (prefetchExecutor != null && !closed.get() && demandBatches == 0 && demandedPlans.isEmpty()) { + chunk = pollQuery(); + if (chunk != null) { + demandBatches++; + } else if (activePrefetches < prefetchSlots) { + HydrologyTileKey key = pollPrefetch(epoch); + if (key != null) { + activePrefetches++; + loadKey = new CacheLoadKey<>(epoch, key); + } + } + } + if (chunk == null && loadKey == null) { + pumping = false; + idle = true; } } - finishDemandBatch(epoch); - scheduleNextPrefetch(); - }); - } else { - planAsync(loadKey).whenComplete((tile, failure) -> scheduleNextPrefetch()); + if (chunk != null) { + startQuery(chunk, epoch); + } else if (loadKey != null) { + planAsync(loadKey).whenComplete((tile, failure) -> finishPrefetch()); + } + } + } finally { + if (!idle) { + synchronized (prefetchQueue) { + pumping = false; + } + } } } + private void startQuery(ChunkCoordinate chunk, long epoch) { + composeAsync(chunk, epoch).whenComplete((columns, failure) -> { + synchronized (prefetchQueue) { + if (epoch == cacheEpoch.get()) { + queriedChunks.remove(chunk); + } + } + finishDemandBatch(epoch); + }); + } + + private void finishPrefetch() { + synchronized (prefetchQueue) { + activePrefetches--; + } + pumpPrefetch(); + } + private ChunkCoordinate pollQuery() { while (!queriedChunks.isEmpty()) { ChunkCoordinate chunk = queriedChunks.getFirst(); - if (composedChunks.getIfPresent(chunk.packed()) == null) { + if (composedChunks.getIfPresent(chunk.packed()) == null && !knownEmpty(chunk.x(), chunk.z())) { return chunk; } queriedChunks.removeFirst(); @@ -1105,13 +1297,15 @@ public final class HydrologyTileCache implements AutoCloseable { } } - private HydrologyTileKey pollPrefetch() { - tiles.cleanUp(); - long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L); - if (speculativeCapacity(tiles.estimatedSize(), HydrologyCacheBudget.runtime().tileBytes(), weight) == 0L) { - prefetchQueue.clear(); - queuedPrefetches.clear(); - return null; + private HydrologyTileKey pollPrefetch(long epoch) { + if (persistentStore == null) { + tiles.cleanUp(); + long weight = tiles.policy().eviction().orElseThrow().weightedSize().orElse(0L); + if (speculativeCapacity(tiles.estimatedSize(), HydrologyCacheBudget.runtime().tileBytes(), weight) == 0L) { + prefetchQueue.clear(); + queuedPrefetches.clear(); + return null; + } } HydrologyTileKey key; while ((key = prefetchQueue.poll()) != null) { @@ -1119,7 +1313,8 @@ public final class HydrologyTileCache implements AutoCloseable { continue; } if ((pregenerationScope == null || pregenerationScope.bounds.contains(key)) - && tiles.getIfPresent(key) == null) { + && !occupancies.containsKey(key) + && !planning.containsKey(new CacheLoadKey<>(epoch, key))) { return key; } } @@ -1449,7 +1644,7 @@ public final class HydrologyTileCache implements AutoCloseable { private record PendingTile(int index, CompletableFuture future) { } - private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { + record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { private boolean contains(HydrologyTileKey key) { return key.tileX() >= minimumX && key.tileX() <= maximumX && key.tileZ() >= minimumZ && key.tileZ() <= maximumZ; @@ -1458,6 +1653,9 @@ public final class HydrologyTileCache implements AutoCloseable { private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { private long estimatedBytes() { + if (empty()) { + return 64L; + } long bytes = 64L + 8L * columns.length; for (HydrologyColumnSample column : columns) { if (column != null) { @@ -1467,9 +1665,16 @@ public final class HydrologyTileCache implements AutoCloseable { return bytes; } - /** A chunk with no hydrology at all, the answer for a caller that may not wait for its plan. */ + /** + * A chunk with no hydrology at all: one no planned tile publishes into, or the answer for a + * caller that may not wait for its plan. + */ private static ChunkColumns empty(int chunkX, int chunkZ) { - return new ChunkColumns(chunkX, chunkZ, new HydrologyColumnSample[CHUNK_COLUMN_COUNT]); + return new ChunkColumns(chunkX, chunkZ, NO_COLUMNS); + } + + private boolean empty() { + return columns == NO_COLUMNS; } private Optional columnAt(int blockX, int blockZ) { @@ -1479,7 +1684,27 @@ public final class HydrologyTileCache implements AutoCloseable { } } - private record LocalChunkColumns(long epoch, ChunkColumns columns) { + /** Direct-mapped over a 4 by 4 chunk neighbourhood, so a placement crossing chunk edges keeps its chunks. */ + private static final class LocalChunks { + private final ChunkColumns[] slots = new ChunkColumns[LOCAL_CHUNK_SLOTS]; + private final long[] aroundKeys = new long[LOCAL_CHUNK_SLOTS]; + private final byte[] around = new byte[LOCAL_CHUNK_SLOTS]; + private long epoch = Long.MIN_VALUE; + private TileBounds prefetched; + + private ChunkColumns get(int chunkX, int chunkZ) { + ChunkColumns columns = slots[slot(chunkX, chunkZ)]; + return columns != null && columns.chunkX() == chunkX && columns.chunkZ() == chunkZ ? columns : null; + } + + private ChunkColumns put(ChunkColumns columns) { + slots[slot(columns.chunkX(), columns.chunkZ())] = columns; + return columns; + } + + private static int slot(int chunkX, int chunkZ) { + return (chunkX & 3) | (chunkZ & 3) << 2; + } } private record ChunkCoordinate(int x, int z) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java index f769d6445..2cff804f9 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java @@ -195,6 +195,11 @@ public final class IrisHydrologyRuntime implements AutoCloseable { cache.prepareChunkColumns(blockX, blockZ); } + /** Whether the chunk or one of its eight neighbours holds a planned footprint column. */ + public boolean hasColumnsAround(int chunkX, int chunkZ) { + return cache.hasColumnsAround(chunkX, chunkZ); + } + public HydrologyRenderSample renderSample(double x, double z) { int blockX = (int) StrictMath.floor(x); int blockZ = (int) StrictMath.floor(z); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java index b2aaf802a..90b0fd61a 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java @@ -75,7 +75,8 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { IrisComplex complex = context.getComplex(); IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); - if (runtime == null || !complex.allowsMantleChunkWrite(chunkX, chunkZ)) { + if (runtime == null || !complex.allowsMantleChunkWrite(chunkX, chunkZ) + || !runtime.hasColumnsAround(chunkX, chunkZ)) { return; } int minimumX = chunkX << 4; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index 31ebc942e..d5d2ce2c6 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.integration.Identifier; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.block.TileData; @@ -32,6 +33,10 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private final List mutations; private final List placements = new ArrayList<>(); private final Long2ObjectOpenHashMap> overlay; + private boolean hydrologyGateKnown; + private int hydrologyGateChunkX; + private int hydrologyGateChunkZ; + private boolean hydrologyGateOpen; ObjectDestinationTransaction(MantleWriter writer, int destinationChunkX, int destinationChunkZ) { this.writer = writer; @@ -350,10 +355,24 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } private boolean hasProtectedHydrology(int x, int y, int z) { + if (!mayHoldHydrologyCells(x >> 4, z >> 4)) { + return false; + } HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); return hydrology != null && hydrology.protectsPlacement(); } + private boolean mayHoldHydrologyCells(int chunkX, int chunkZ) { + if (!hydrologyGateKnown || hydrologyGateChunkX != chunkX || hydrologyGateChunkZ != chunkZ) { + IrisComplex complex = writer.getEngine().getComplex(); + hydrologyGateOpen = complex == null || complex.mayHoldHydrologyCells(chunkX, chunkZ); + hydrologyGateChunkX = chunkX; + hydrologyGateChunkZ = chunkZ; + hydrologyGateKnown = true; + } + return hydrologyGateOpen; + } + private void rollback(LinkedHashMap originals, Throwable failure) { ArrayList> entries = new ArrayList<>(originals.entrySet()); entries.sort((first, second) -> Boolean.compare( diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index cd5d1a2af..4c04aa714 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -21,6 +21,7 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.generation.stream.GenerationStreams; import art.arcane.iris.generation.stream.CachedDoubleStream2D; import art.arcane.iris.generation.stream.CachedStream2D; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.configuration.IrisSettings; @@ -991,7 +992,11 @@ public class IrisComplex implements DataProvider { HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull(); if (hydrologyBanks3D != null && layer != null && layer.terrainOwned() && !layer.channel()) { Optional column = hydrologyBanks3D.columnIfReady(blockCoordinate(x), blockCoordinate(z)); - return column.isPresent() ? column.get().topY() : naturalHeightStream.getDouble(x, z); + if (column.isPresent()) { + return column.get().topY(); + } + ProvisionalSampling.mark(); + return naturalHeightStream.getDouble(x, z); } return sample.terrainHeight(); } @@ -1184,8 +1189,11 @@ public class IrisComplex implements DataProvider { return false; } HydrologyColumnLayer layer = snapshot.column() == null ? null : snapshot.column().primarySurfaceLayerOrNull(); - return layer == null || !layer.terrainOwned() || layer.channel() - || hydrologyBanks3D.columnIfReady(x, z).isPresent(); + if (layer == null || !layer.terrainOwned() || layer.channel() || hydrologyBanks3D.columnIfReady(x, z).isPresent()) { + return true; + } + ProvisionalSampling.mark(); + return false; } public ProceduralStream getRawHeightStream() { @@ -1218,6 +1226,17 @@ public class IrisComplex implements DataProvider { || !transitionGenerationPlan.boundary().isHistoricalChunk(chunkX, chunkZ); } + /** + * Whether the mantle chunk can hold hydrology cave cells. The hydrology component publishes them + * only from footprint columns within one block of the chunk, so a chunk with none around it holds + * none. A transition world or a studio keeps cells an earlier plan wrote, so there every chunk may. + */ + public boolean mayHoldHydrologyCells(int chunkX, int chunkZ) { + return hydrologyRuntime == null || transitionGenerationPlan != null + || terrainEngine == null || terrainEngine.isStudio() + || hydrologyRuntime.hasColumnsAround(chunkX, chunkZ); + } + public boolean allowsNewDiscreteContentAt(int blockX, int blockZ) { return transitionGenerationPlan == null || transitionGenerationPlan.allowsNewDiscreteContentAt(blockX, blockZ); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java b/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java index 37267ff4d..9997baeb6 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/CachedDoubleStream2D.java @@ -18,7 +18,7 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu super(); this.stream = stream; this.engine = engine; - this.cache = new WorldCache2DDouble((x, z) -> stream.getDouble(x, z), size); + this.cache = new WorldCache2DDouble((x, z) -> ProvisionalSampling.memoizableDouble(stream, x, z), size); IrisServices.get(PreservationRegistry.class).registerCache(this); } @@ -34,7 +34,7 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu @Override public Double get(double x, double z) { - return cache.get((int) x, (int) z); + return getDouble(x, z); } @Override @@ -44,7 +44,11 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu @Override public double getDouble(double x, double z) { - return cache.get((int) x, (int) z); + try { + return cache.get((int) x, (int) z); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.doubleValue(); + } } @Override @@ -75,13 +79,29 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu public void fillChunkRaw(int worldX, int worldZ, Object[] target) { int chunkX = worldX >> 4; int chunkZ = worldZ >> 4; - cache.fillChunk(chunkX, chunkZ, target); + try { + cache.fillChunk(chunkX, chunkZ, target); + } catch (ProvisionalSampling.Unmemoizable provisional) { + int originX = chunkX << 4; + int originZ = chunkZ << 4; + for (int index = 0; index < 256; index++) { + target[index] = getDouble(originX + (index & 15), originZ + (index >> 4)); + } + } } @Override public void fillChunkDoubles(int worldX, int worldZ, double[] target) { int chunkX = worldX >> 4; int chunkZ = worldZ >> 4; - cache.fillChunk(chunkX, chunkZ, target); + try { + cache.fillChunk(chunkX, chunkZ, target); + } catch (ProvisionalSampling.Unmemoizable provisional) { + int originX = chunkX << 4; + int originZ = chunkZ << 4; + for (int index = 0; index < 256; index++) { + target[index] = getDouble(originX + (index & 15), originZ + (index >> 4)); + } + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java index 12c1022e1..36a6a66cb 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java @@ -36,7 +36,8 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea super(); this.stream = stream; this.engine = engine; - cache = WorldCache2D.ofInts(stream::get, size, () -> new ChunkCache2D<>("iris")); + cache = WorldCache2D.ofInts((x, z) -> ProvisionalSampling.memoizable(stream, x, z), size, + () -> new ChunkCache2D<>("iris")); IrisServices.get(PreservationRegistry.class).registerCache(this); } @@ -52,7 +53,11 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea @Override public T get(double x, double z) { - return cache.get((int) x, (int) z); + try { + return cache.get((int) x, (int) z); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } } @Override @@ -87,6 +92,14 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea public void fillChunkRaw(int worldX, int worldZ, Object[] target) { int chunkX = worldX >> 4; int chunkZ = worldZ >> 4; - cache.fillChunk(chunkX, chunkZ, target); + try { + cache.fillChunk(chunkX, chunkZ, target); + } catch (ProvisionalSampling.Unmemoizable provisional) { + int originX = chunkX << 4; + int originZ = chunkZ << 4; + for (int index = 0; index < 256; index++) { + target[index] = get(originX + (index & 15), originZ + (index >> 4)); + } + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java new file mode 100644 index 000000000..07d1a3f21 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java @@ -0,0 +1,110 @@ +package art.arcane.iris.generation.stream; + +import art.arcane.volmlib.util.stream.ProceduralStream; + +import java.util.function.Supplier; + +/** + * Tracks answers a thread gave from inputs that are not final yet. The server thread never waits for + * a hydrology plan, so it answers from natural terrain until the plan lands; a value derived from such + * an answer is right for that caller only and must never be memoized, or generation would later read + * it and its output would depend on timing. + */ +public final class ProvisionalSampling { + private static final ThreadLocal TRACKERS = ThreadLocal.withInitial(Tracker::new); + + private ProvisionalSampling() { + } + + /** Records that the calling thread just answered from inputs that are not final. */ + public static void mark() { + TRACKERS.get().provisional = true; + } + + /** + * The stream's value for a memoizing cache, or {@link Unmemoizable} carrying it when sampling it + * read a provisional answer. The mark still reaches every enclosing resolver. + */ + public static T memoizable(ProceduralStream stream, int x, int z) { + Tracker tracker = TRACKERS.get(); + boolean outer = tracker.begin(); + T value; + boolean provisional; + try { + value = stream.get(x, z); + } finally { + provisional = tracker.end(outer); + } + if (provisional) { + throw new Unmemoizable(value); + } + return value; + } + + public static double memoizableDouble(ProceduralStream stream, int x, int z) { + Tracker tracker = TRACKERS.get(); + boolean outer = tracker.begin(); + double value; + boolean provisional; + try { + value = stream.getDouble(x, z); + } finally { + provisional = tracker.end(outer); + } + if (provisional) { + throw new Unmemoizable(value); + } + return value; + } + + public static T memoizable(Supplier resolver) { + Tracker tracker = TRACKERS.get(); + boolean outer = tracker.begin(); + T value; + boolean provisional; + try { + value = resolver.get(); + } finally { + provisional = tracker.end(outer); + } + if (provisional) { + throw new Unmemoizable(value); + } + return value; + } + + private static final class Tracker { + private boolean provisional; + + private boolean begin() { + boolean outer = provisional; + provisional = false; + return outer; + } + + private boolean end(boolean outer) { + boolean inner = provisional; + provisional = outer || inner; + return inner; + } + } + + /** Carries a provisional value past a memoizing cache so the cache stores nothing. */ + public static final class Unmemoizable extends RuntimeException { + private final transient Object value; + + private Unmemoizable(Object value) { + super(null, null, false, false); + this.value = value; + } + + @SuppressWarnings("unchecked") + public T value() { + return (T) value; + } + + public double doubleValue() { + return (Double) value; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java index 453c1130a..ac57005c9 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java @@ -10,6 +10,7 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout; import art.arcane.iris.generation.runtime.DimensionTerrainContext; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; @@ -327,7 +328,12 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy } } - H resolvedHolder = resolveStructureBiomeHolder(x, y, z); + H resolvedHolder; + try { + resolvedHolder = ProvisionalSampling.memoizable(() -> resolveStructureBiomeHolder(x, y, z)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } if (!cacheable) { return resolvedHolder; } @@ -453,7 +459,12 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy if (surfaceHolder != null) { return surfaceHolder; } - H resolvedSurfaceHolder = resolveSurfaceStructureBiomeHolder(x, z); + H resolvedSurfaceHolder; + try { + resolvedSurfaceHolder = ProvisionalSampling.memoizable(() -> resolveSurfaceStructureBiomeHolder(x, z)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } H existingSurfaceHolder = surfaceStructureBiomeCache.putIfAbsent( columnKey, resolvedSurfaceHolder); if (existingSurfaceHolder != null) { @@ -624,7 +635,12 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy } } - H resolvedHolder = resolveVisibleBiomeHolder(x, y, z, resolverState); + H resolvedHolder; + try { + resolvedHolder = ProvisionalSampling.memoizable(() -> resolveVisibleBiomeHolder(x, y, z, resolverState)); + } catch (ProvisionalSampling.Unmemoizable provisional) { + return provisional.value(); + } if (!cacheable) { return resolvedHolder; } diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index 1f909b6dd..93da66d5b 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -69,7 +69,6 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L; private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L; private static final long ADMISSION_WAIT_BOUND_MS = 500L; - private static final int HYDROLOGY_PREFETCH_HALO_BLOCKS = 512; private final World world; private final IrisRuntimeSchedulerMode runtimeSchedulerMode; private final IrisPaperLikeBackendMode paperLikeBackendMode; @@ -283,10 +282,10 @@ public class AsyncPregenMethod implements PregeneratorMethod { } HydrologyTileCache.PregenerationArea area = new HydrologyTileCache.PregenerationArea( centerBlockX, centerBlockZ, - (long) boundsMinRegionX * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS, - (long) boundsMinRegionZ * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS, - ((long) boundsMaxRegionX + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS, - ((long) boundsMaxRegionZ + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS); + (long) boundsMinRegionX * 512L, + (long) boundsMinRegionZ * 512L, + ((long) boundsMaxRegionX + 1L) * 512L - 1L, + ((long) boundsMaxRegionZ + 1L) * 512L - 1L); HydrologyTileCache.PregenerationScope scope = engine.getComplex().getHydrologyRuntime().preparePregeneration(area); hydrologyPrefetchScope = scope; if (closing.get()) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancyTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancyTest.java new file mode 100644 index 000000000..0481f49ee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyChunkOccupancyTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class HydrologyChunkOccupancyTest { + @Test + public void occupiesExactlyTheChunksHoldingFootprintColumnsAtSignedCoordinates() { + int[][] columns = {{0, 0}, {15, 15}, {-1, -1}, {-17, 40}, {1000, -2000}, {-4097, 4096}}; + HydrologyChunkOccupancy occupancy = HydrologyChunkOccupancy.of(footprint(columns)); + HashSet expected = new HashSet<>(); + for (int[] column : columns) { + expected.add(RiverFootprint.pack(Math.floorDiv(column[0], 16), Math.floorDiv(column[1], 16))); + } + int occupied = 0; + for (int chunkZ = -300; chunkZ <= 300; chunkZ++) { + for (int chunkX = -300; chunkX <= 300; chunkX++) { + boolean holds = occupancy.occupies(chunkX, chunkZ); + assertEquals(expected.contains(RiverFootprint.pack(chunkX, chunkZ)), holds); + occupied += holds ? 1 : 0; + } + } + assertEquals(expected.size(), occupied); + assertFalse(occupancy.occupies(Integer.MAX_VALUE >> 4, Integer.MIN_VALUE >> 4)); + } + + @Test + public void footprintsWithoutColumnsOccupyNothing() { + assertSame(HydrologyChunkOccupancy.EMPTY, HydrologyChunkOccupancy.of(RiverFootprint.empty())); + assertSame(HydrologyChunkOccupancy.EMPTY, HydrologyChunkOccupancy.of(null)); + assertFalse(HydrologyChunkOccupancy.EMPTY.occupies(0, 0)); + assertTrue(HydrologyChunkOccupancy.of(footprint(new int[][]{{3, 4}})).occupies(0, 0)); + } + + private static RiverFootprint footprint(int[][] columns) { + Map samples = new HashMap<>(); + for (int[] column : columns) { + samples.put(RiverFootprint.pack(column[0], column[1]), + new HydrologyColumnSample(column[0], column[1], 80, 63, false, "parent", List.of())); + } + return new RiverFootprint(samples); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index c0800e0ae..01996c70b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; import art.arcane.iris.testsupport.Await; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.volmlib.util.cache.CacheKey; +import com.github.benmanes.caffeine.cache.Cache; import org.junit.Test; import org.junit.Rule; import org.junit.rules.TemporaryFolder; @@ -14,6 +15,7 @@ import java.lang.reflect.Field; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -399,6 +401,7 @@ public class HydrologyTileCacheTest { HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + publishesInto(tile, RiverFootprint.pack(0, 0)); CountDownLatch started = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); when(tile.columnAt(anyInt(), anyInt())).thenAnswer(invocation -> { @@ -625,8 +628,10 @@ public class HydrologyTileCacheTest { HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run); try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration( new HydrologyTileCache.PregenerationArea(0, 0, -1536, -1536, 2047, 2047))) { - cache.prefetchArea(-4096, -4096, 4095, 4095, 0, 0); assertEquals(prefetchRectangle(-2, 2, -2, 2), new HashSet<>(planned)); + assertEquals(25, planned.size()); + cache.prefetchArea(-4096, -4096, 4095, 4095, 0, 0); + assertEquals(prefetchRectangle(-3, 2, -3, 2), new HashSet<>(planned)); planned.clear(); HydrologyTileKey demanded = new HydrologyTileKey(8, 8); assertSame(tile, cache.get(demanded)); @@ -682,27 +687,81 @@ public class HydrologyTileCacheTest { } @Test - public void largePregenBoundsKeepInitialWarmupLocal() { + public void largePregenBoundsPlanTheNearestTilesUpToTheLookaheadLimit() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyTile tile = mock(HydrologyTile.class); HydrologyPlannerSettings settings = stalePrefetchSettings(); when(planner.settings()).thenReturn(settings); - when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); ArrayList planned = new ArrayList<>(); doAnswer(invocation -> { planned.add(invocation.getArgument(0)); return tile; }).when(planner).plan(any(HydrologyTileKey.class)); HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run); + usePreparedStore(cache, mock(PreparedHydrologyTileStore.class)); try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration( new HydrologyTileCache.PregenerationArea(0, 0, Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE))) { - assertEquals(prefetchRectangle(-2, 1, -2, 1), new HashSet<>(planned)); - assertEquals(16, planned.size()); + assertEquals(4096, planned.size()); + assertEquals(4096, new HashSet<>(planned).size()); + assertEquals(new HydrologyTileKey(0, 0), planned.getFirst()); + assertEquals(prefetchRectangle(-1, 1, -1, 1), new HashSet<>(planned.subList(0, 9))); + assertTrue(new HashSet<>(planned).containsAll(prefetchRectangle(-31, 31, -31, 31))); + for (HydrologyTileKey key : planned) { + assertTrue(Math.max(Math.abs(key.tileX()), Math.abs(key.tileZ())) <= 32); + } } cache.close(); } + @Test + public void preparedStoreLetsPregenSpeculationRunPastTheTileBudget() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + RiverFootprint footprint = mock(RiverFootprint.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(tile.footprint()).thenReturn(footprint); + when(footprint.size()).thenReturn((int) (HydrologyCacheBudget.runtime().tileBytes() * 3L / 5L / 64L)); + when(footprint.columns()).thenReturn(Map.of()); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + PreparedHydrologyTileStore store = mock(PreparedHydrologyTileStore.class); + usePreparedStore(cache, store); + try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration(pregenArea(0, 0))) { + drainPrefetchTasks(queued); + assertEquals(prefetchRectangle(-2, 1, -2, 1), new HashSet<>(planned)); + assertEquals(16, planned.size()); + assertTrue(cache.size() < 16); + verify(store, times(16)).save(tile); + assertTrue(cache.isPlanned(40, 40)); + } + cache.close(); + } + + private static void usePreparedStore(HydrologyTileCache cache, PreparedHydrologyTileStore store) throws Exception { + Field persistentStore = HydrologyTileCache.class.getDeclaredField("persistentStore"); + persistentStore.setAccessible(true); + persistentStore.set(cache, store); + } + + /** Gives a mocked tile one footprint column in each listed chunk, so those chunks compose from it. */ + private static void publishesInto(HydrologyTile tile, long... chunks) { + HashMap columns = new HashMap<>(); + for (long chunk : chunks) { + columns.put(RiverFootprint.pack(RiverFootprint.unpackX(chunk) * 16, RiverFootprint.unpackZ(chunk) * 16), + mock(HydrologyColumnSample.class)); + } + RiverFootprint footprint = mock(RiverFootprint.class); + when(footprint.columns()).thenReturn(columns); + when(tile.footprint()).thenReturn(footprint); + } + private static HydrologyTileCache.PregenerationArea pregenArea(int x, int z) { return new HydrologyTileCache.PregenerationArea(x, z, (long) x - 1024, (long) z - 1024, (long) x + 1024, (long) z + 1024); @@ -868,6 +927,7 @@ public class HydrologyTileCacheTest { ArrayList visitedColumns = new ArrayList<>(); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + publishesInto(tile, RiverFootprint.pack(2, 3)); doAnswer(invocation -> { int blockX = invocation.getArgument(0); int blockZ = invocation.getArgument(1); @@ -1642,6 +1702,7 @@ public class HydrologyTileCacheTest { HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + publishesInto(tile, RiverFootprint.pack(0, 0), RiverFootprint.pack(secondChunkX, 0)); CountDownLatch started = new CountDownLatch(2); CountDownLatch release = new CountDownLatch(1); doAnswer(invocation -> { @@ -1675,6 +1736,7 @@ public class HydrologyTileCacheTest { when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + publishesInto(tile, RiverFootprint.pack(0, 0)); ExecutorService executor = Executors.newFixedThreadPool(8); try { HydrologyTileCache cache = new HydrologyTileCache(planner, 64); @@ -1779,6 +1841,8 @@ public class HydrologyTileCacheTest { HydrologyTile newTile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(oldTile, newTile); + publishesInto(oldTile, RiverFootprint.pack(0, 0)); + publishesInto(newTile, RiverFootprint.pack(0, 0)); CountDownLatch started = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); doAnswer(invocation -> { @@ -1815,6 +1879,7 @@ public class HydrologyTileCacheTest { HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + publishesInto(tile, RiverFootprint.pack(0, 0)); HydrologyTileCache cache = new HydrologyTileCache(planner, 8); doAnswer(invocation -> cache.columnAt(0, 0)).when(tile).columnAt(anyInt(), anyInt()); @@ -2338,6 +2403,7 @@ public class HydrologyTileCacheTest { when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + publishesInto(tile, RiverFootprint.pack(2, 2)); List queued = new ArrayList<>(); AtomicBoolean forbidden = new AtomicBoolean(true); HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add, forbidden::get); @@ -2399,6 +2465,7 @@ public class HydrologyTileCacheTest { HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + publishesInto(tile, RiverFootprint.pack(2, 2)); HydrologyTileCache cache = new HydrologyTileCache(planner, 64, null, () -> true); cache.get(new HydrologyTileKey(0, 0)); AtomicBoolean evicted = new AtomicBoolean(); @@ -2417,4 +2484,120 @@ public class HydrologyTileCacheTest { assertFalse(cache.columnSnapshot(40, 40).available()); } + + @Test + public void occupancyAnswersEmptyChunksExactlyWhereCompositionFindsNoColumn() { + HydrologyPlannerSettings settings = featureSettings(); + HydrologyPlanner planner = new HydrologyPlanner(811L, settings, this::featureTerrain); + HydrologyTileCache cache = new HydrologyTileCache(planner, 256); + int tileSize = settings.routing().tileSize(); + int radius = settings.publicationRadius(); + int emptyChunks = 0; + int publishedChunks = 0; + for (int chunkZ = -2; chunkZ <= 5; chunkZ++) { + for (int chunkX = -2; chunkX <= 5; chunkX++) { + boolean published = false; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int x = chunkX * 16 + localX; + int z = chunkZ * 16 + localZ; + boolean expected = false; + for (int tileZ = Math.floorDiv(z - radius, tileSize); tileZ <= Math.floorDiv(z + radius, tileSize); tileZ++) { + for (int tileX = Math.floorDiv(x - radius, tileSize); tileX <= Math.floorDiv(x + radius, tileSize); tileX++) { + expected |= cache.get(new HydrologyTileKey(tileX, tileZ)).columnAt(x, z).isPresent(); + } + } + assertEquals(expected, cache.columnAt(x, z).isPresent()); + published |= expected; + } + } + if (published) { + publishedChunks++; + } else { + emptyChunks++; + } + } + } + assertTrue(emptyChunks > 0); + assertTrue(publishedChunks > 0); + } + + @Test + public void emptyChunksAnswerFromOccupancyAfterTheirTilesLeaveMemory() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + publishesInto(tile, RiverFootprint.pack(0, 0)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64); + HydrologyTileKey origin = new HydrologyTileKey(0, 0); + + assertTrue(cache.columnAt(552, 552).isEmpty()); + verify(planner, times(1)).plan(origin); + verify(tile, never()).columnAt(anyInt(), anyInt()); + Field tilesField = HydrologyTileCache.class.getDeclaredField("tiles"); + tilesField.setAccessible(true); + ((Cache) tilesField.get(cache)).invalidateAll(); + assertEquals(0, cache.size()); + + assertTrue(cache.columnAt(568, 552).isEmpty()); + assertTrue(cache.isPlanned(568, 552)); + assertTrue(cache.columnSnapshot(568, 552).available()); + verify(planner, times(1)).plan(origin); + verify(tile, never()).columnAt(anyInt(), anyInt()); + + cache.columnAt(8, 8); + verify(tile, times(4 * 256)).columnAt(anyInt(), anyInt()); + } + + @Test + public void speculativeRootsLeaveOneRootPermitForDemand() { + assertEquals(1, HydrologyTileCache.prefetchSlots(1)); + assertEquals(1, HydrologyTileCache.prefetchSlots(2)); + assertEquals(3, HydrologyTileCache.prefetchSlots(4)); + assertEquals(7, HydrologyTileCache.prefetchSlots(8)); + } + + @Test + public void nearestFirstWalksRingsInRowOrderInsideTheBounds() { + HydrologyTileCache.TileBounds bounds = new HydrologyTileCache.TileBounds(-2, -1, 3, 2); + List keys = HydrologyTileCache.nearestFirst(bounds, 0, 0, Integer.MAX_VALUE); + ArrayList expected = new ArrayList<>(prefetchRectangle(-2, 3, -1, 2)); + expected.sort(Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX()), Math.abs(key.tileZ()))) + .thenComparingInt(HydrologyTileKey::tileZ) + .thenComparingInt(HydrologyTileKey::tileX)); + assertEquals(expected, keys); + List outside = HydrologyTileCache.nearestFirst(bounds, 9, -7, 5); + assertEquals(5, outside.size()); + assertEquals(new HydrologyTileKey(3, -1), outside.getFirst()); + for (HydrologyTileKey key : outside) { + assertTrue(key.tileX() >= -2 && key.tileX() <= 3 && key.tileZ() >= -1 && key.tileZ() <= 2); + } + } + + @Test + public void neighbourhoodAnswersFollowPublishedColumnsAndResetWithTheEpoch() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile publishing = mock(HydrologyTile.class); + HydrologyTile empty = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + AtomicReference current = new AtomicReference<>(publishing); + when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> current.get()); + publishesInto(publishing, RiverFootprint.pack(0, 0)); + when(publishing.columnAt(0, 0)).thenReturn(Optional.of( + new HydrologyColumnSample(0, 0, 90, 63, false, "parent", List.of()))); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64); + + assertTrue(cache.hasColumnsAround(1, 1)); + assertTrue(cache.hasColumnsAround(-1, 0)); + assertFalse(cache.hasColumnsAround(2, 0)); + assertTrue(cache.hasColumnsAround(1, 1)); + current.set(empty); + cache.clear(); + assertFalse(cache.hasColumnsAround(1, 1)); + assertFalse(cache.hasColumnsAround(0, 0)); + } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java index 88d8c385e..d04d28f48 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java @@ -61,10 +61,12 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class MantleHydrologyComponentTest { @@ -2007,4 +2009,23 @@ public class MantleHydrologyComponentTest { openToSurface.add(position); } } + + @Test + public void chunksWithoutNearbyFootprintColumnsPublishNothingWithoutReadingCavePlans() { + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + ChunkContext context = mock(ChunkContext.class); + MantleWriter writer = mock(MantleWriter.class); + when(context.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(complex.allowsMantleChunkWrite(3, -4)).thenReturn(true); + when(runtime.hasColumnsAround(3, -4)).thenReturn(false); + + new MantleHydrologyComponent(mock(EngineMantle.class)).generateLayer(writer, 3, -4, context); + + verify(runtime).hasColumnsAround(3, -4); + verify(runtime, never()).cavePlansIn(anyInt(), anyInt(), anyInt(), anyInt()); + verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble()); + verifyNoInteractions(writer); + } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java index eb784c8df..b38337348 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -74,6 +75,7 @@ public class MantleWriterPreObjectJournalTest { EngineMantle engineMantle = mock(EngineMantle.class); when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engineMantle.getEngine()).thenReturn(mock(Engine.class)); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); matter = spy(new IrisMatter(16, 16, 16)); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index 8dcdfbab0..782201d42 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -43,6 +43,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -657,4 +658,30 @@ public class ObjectDestinationTransactionTest { private record Marker(String value) { } + + @Test + public void chunksThatCannotHoldHydrologyCellsSkipTheCellLookup() { + MantleWriter writer = writer(); + IrisComplex complex = mock(IrisComplex.class); + when(writer.getEngine().getComplex()).thenReturn(complex); + when(complex.mayHoldHydrologyCells(1, 0)).thenReturn(true); + when(writer.getPrerequisiteDataIfPresent(anyInt(), anyInt(), anyInt(), any())) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + NativeBlockState block = mock(NativeBlockState.class); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + for (int x = 0; x < 16; x++) { + transaction.setData(x, 4, 0, block); + } + transaction.setData(16, 4, 0, block); + + for (int x = 0; x < 16; x++) { + assertSame(block, transaction.get(x, 4, 0)); + } + assertEquals(16, transaction.sourcePlanSince(0).mutationsFor(0, 0).size()); + assertTrue(transaction.sourcePlanSince(0).mutationsFor(1, 0).isEmpty()); + verify(complex, times(1)).mayHoldHydrologyCells(0, 0); + verify(writer, never()).getPrerequisiteDataIfPresent(0, 4, 0, HydrologyCaveCell.class); + verify(writer).getPrerequisiteDataIfPresent(16, 4, 0, HydrologyCaveCell.class); + } } diff --git a/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java new file mode 100644 index 000000000..745c981fc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.generation.stream; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class ProvisionalSamplingTest { + @Test + public void provisionalValuesReachTheCallerWithoutBeingMemoized() { + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(PreservationRegistry.class)).thenReturn(mock(PreservationRegistry.class)); + AtomicBoolean planned = new AtomicBoolean(); + AtomicInteger samples = new AtomicInteger(); + ProceduralStream source = ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + if (!planned.get()) { + ProvisionalSampling.mark(); + return -1D; + } + return x + z; + }); + CachedDoubleStream2D cached = new CachedDoubleStream2D("provisional", mock(Engine.class), source, 4); + + assertEquals(-1D, cached.getDouble(3, 4), 0D); + assertEquals(-1D, cached.get(3D, 4D), 0D); + assertEquals(2, samples.get()); + planned.set(true); + assertEquals(7D, cached.getDouble(3, 4), 0D); + assertEquals(7D, cached.getDouble(3, 4), 0D); + assertEquals(3, samples.get()); + } + } + + @Test + public void derivedCachesInheritTheMarkAndFillChunksWithoutStoringIt() { + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(PreservationRegistry.class)).thenReturn(mock(PreservationRegistry.class)); + AtomicBoolean planned = new AtomicBoolean(); + AtomicInteger samples = new AtomicInteger(); + ProceduralStream source = ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + if (!planned.get() && x == 5 && z == 0) { + ProvisionalSampling.mark(); + return -1D; + } + return 1D; + }); + CachedDoubleStream2D base = new CachedDoubleStream2D("base", mock(Engine.class), source, 4); + CachedStream2D derived = new CachedStream2D<>("derived", mock(Engine.class), + base.convert((Double value) -> value < 0D ? "natural" : "planned"), 4); + + Object[] row = new Object[256]; + derived.fillChunkRaw(0, 0, row); + assertEquals("natural", row[5]); + assertEquals("planned", row[6]); + assertEquals("natural", derived.get(5D, 0D)); + planned.set(true); + int before = samples.get(); + assertEquals("planned", derived.get(5D, 0D)); + assertEquals(before + 1, samples.get()); + assertEquals("planned", derived.get(6D, 0D)); + assertEquals(before + 1, samples.get()); + } + } +}