From 366033a53502a5d3ec528390f6ad125efb62fd7d Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Fri, 4 Sep 2026 00:02:43 -0400 Subject: [PATCH] CORRECTIONS - this is the corrections from the afoemrnioned commits --- .../NativeStructureVolumeIndexTest.java | 122 ++++++ .../core/PendingWorldReplacementManager.java | 102 +++-- ...dingWorldReplacementManagerPolicyTest.java | 60 +++ ...ingWorldReplacementThreadAffinityTest.java | 25 +- .../BukkitEngineLifecycleContractTest.java | 2 +- .../nativegen/NativeStructureVolumeIndex.java | 64 +++ .../iris/modded/command/ModdedPregenJob.java | 7 +- .../arcane/iris/core/gui/PregeneratorJob.java | 71 +++- .../core/pregenerator/IrisPregenerator.java | 17 +- .../PregenPerformanceProfile.java | 40 +- .../iris/core/pregenerator/PregenTask.java | 23 +- .../core/pregenerator/PregeneratorMethod.java | 3 + .../methods/AsyncOrMedievalPregenMethod.java | 5 + .../methods/AsyncPregenMethod.java | 163 ++++---- .../methods/CachedPregenMethod.java | 5 + .../methods/HybridPregenMethod.java | 5 + .../methods/MedievalPregenMethod.java | 9 +- .../arcane/iris/core/tools/IrisToolbelt.java | 10 +- .../art/arcane/iris/engine/IrisComplex.java | 17 +- .../art/arcane/iris/engine/IrisEngine.java | 45 +++ .../hydrology/HydrologyFootprintCompiler.java | 4 +- .../HydrologyNaturalTerrainSampler.java | 4 + .../engine/hydrology/HydrologyPlanner.java | 134 +++++-- .../engine/hydrology/HydrologyTileCache.java | 249 ++++++++++-- .../hydrology/StudioHydrologyTileStore.java | 375 ++++++++++++++++++ .../IrisHydrologyRoutingTerrainSampler.java | 5 + .../runtime/IrisHydrologyRuntime.java | 20 +- .../mantle/components/IrisCaveCarver3D.java | 76 +++- .../iris/engine/object/IrisDimension.java | 3 + .../engine/platform/BukkitChunkGenerator.java | 26 +- .../stream/utility/CachedDoubleStream2D.java | 4 + .../IrisPregeneratorInitTest.java | 93 +++++ .../PregenPerformanceProfileTest.java | 37 +- .../PregenTaskLatticeOrderTest.java | 40 +- .../AsyncPregenMethodConcurrencyCapTest.java | 15 + .../CachedPregenMethodCompletionTest.java | 31 ++ .../PregenMantleCleanupContractTest.java | 70 ++++ .../engine/IrisComplexStudioCacheTest.java | 22 + .../iris/engine/SharedCornerBoundsTest.java | 1 + .../HydrologyFootprintCompilerTest.java | 15 +- .../hydrology/HydrologyPlannerTest.java | 27 ++ .../hydrology/HydrologyTileCacheTest.java | 231 ++++++++++- .../StudioHydrologyTileStoreTest.java | 172 ++++++++ ...risHydrologyRoutingTerrainSamplerTest.java | 24 ++ .../IrisCaveCarver3DNearParityTest.java | 179 +++++++++ .../IrisDimensionReachableBiomesTest.java | 29 ++ 46 files changed, 2425 insertions(+), 256 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java index edda00a7b..3691e9d41 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureVolumeIndexTest.java @@ -20,6 +20,11 @@ import org.junit.Test; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +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.assertEquals; @@ -107,6 +112,61 @@ public class NativeStructureVolumeIndexTest { assertTrue(unreachable.resolve(null, 0, 0, 15, 15).isEmpty()); } + @Test + public void overlappingColdQueriesCoordinateBeforeResolvingOrigins() throws Exception { + BlockingResolver resolver = new BlockingResolver(); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15)); + assertTrue(resolver.awaitFirstEntry()); + Future> adjacent = executor.submit(() -> index.resolve(null, 16, 0, 31, 15)); + + assertFalse(resolver.awaitSecondEntry(250)); + resolver.release(); + assertTrue(first.get(5, TimeUnit.SECONDS).isEmpty()); + assertTrue(adjacent.get(5, TimeUnit.SECONDS).isEmpty()); + assertEquals(306, resolver.resolutions()); + } finally { + resolver.release(); + executor.shutdownNow(); + } + } + + @Test + public void distantColdQueriesCanResolveOriginsConcurrently() throws Exception { + BlockingResolver resolver = new BlockingResolver(); + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(resolver); + int distantChunkX = findDisjointWindow(0, 0); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15)); + assertTrue(resolver.awaitFirstEntry()); + Future> distant = executor.submit( + () -> index.resolve(null, distantChunkX << 4, 0, (distantChunkX << 4) + 15, 15)); + + assertTrue(resolver.awaitSecondEntry(5_000)); + resolver.release(); + assertTrue(first.get(5, TimeUnit.SECONDS).isEmpty()); + assertTrue(distant.get(5, TimeUnit.SECONDS).isEmpty()); + } finally { + resolver.release(); + executor.shutdownNow(); + } + } + + @Test + public void coordinatedResolutionPreservesCanonicalOriginOrder() { + NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting(new OrderedResolver()); + + KList volumes = index.resolve(null, 0, 0, 15, 15); + + assertEquals(289, volumes.size()); + assertEquals("-8:-8", volumes.getFirst().structure()); + assertEquals("0:0", volumes.get(144).structure()); + assertEquals("8:8", volumes.getLast().structure()); + } + @Test public void volumeResolutionNeverReadsChunkOrOwnershipState() throws Exception { String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource"))); @@ -134,6 +194,16 @@ public class NativeStructureVolumeIndexTest { return new StructureStart(source, new ChunkPos(chunkX, chunkZ), 0, new PiecesContainer(List.of(piece))); } + private static int findDisjointWindow(int chunkX, int chunkZ) { + long sourceMask = NativeStructureVolumeIndex.originWindowStripeMask(chunkX, chunkZ); + for (int candidate = 64; candidate < 16_384; candidate += 17) { + if ((sourceMask & NativeStructureVolumeIndex.originWindowStripeMask(candidate, chunkZ)) == 0L) { + return candidate; + } + } + throw new IllegalStateException("Unable to find a disjoint native structure window stripe"); + } + private static final class CountingResolver implements NativeStructureVolumeIndex.OriginResolver { private final AtomicInteger resolutions = new AtomicInteger(); private final int originChunkX; @@ -157,4 +227,56 @@ public class NativeStructureVolumeIndexTest { return NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); } } + + private static final class BlockingResolver implements NativeStructureVolumeIndex.OriginResolver { + private final AtomicInteger resolutions = new AtomicInteger(); + private final CountDownLatch firstEntry = new CountDownLatch(1); + private final CountDownLatch secondEntry = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + + private int resolutions() { + return resolutions.get(); + } + + private boolean awaitFirstEntry() throws InterruptedException { + return firstEntry.await(5, TimeUnit.SECONDS); + } + + private boolean awaitSecondEntry(long timeoutMillis) throws InterruptedException { + return secondEntry.await(timeoutMillis, TimeUnit.MILLISECONDS); + } + + private void release() { + release.countDown(); + } + + @Override + public KList volumesAt(Engine engine, int chunkX, int chunkZ) { + int resolution = resolutions.incrementAndGet(); + if (resolution == 1) { + firstEntry.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to release native structure resolution"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while coordinating native structure resolution", e); + } + } else if (resolution == 2) { + secondEntry.countDown(); + } + return NativeStructureVolume.NONE; + } + } + + private static final class OrderedResolver implements NativeStructureVolumeIndex.OriginResolver { + @Override + public KList volumesAt(Engine engine, int chunkX, int chunkZ) { + return new KList<>(new NativeStructureVolume( + chunkX + ":" + chunkZ, + 0, 0, 0, + 15, 255, 15)); + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index ec7b71f94..76dcbf23d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -37,6 +37,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.event.world.WorldSaveEvent; import java.io.File; import java.io.IOException; @@ -61,7 +62,8 @@ import java.util.concurrent.TimeoutException; public final class PendingWorldReplacementManager implements Listener { private static final WorldSlotKey OVERWORLD_KEY = WorldSlotKey.minecraft("overworld"); - private static final long SAFE_ENTRY_TIMEOUT_SECONDS = 30L; + private static final long SAVED_LOCATION_INSPECTION_TIMEOUT_SECONDS = 30L; + private static final long SAFE_ENTRY_TIMEOUT_SECONDS = 600L; private final Iris plugin; private final Set cleanupInFlight = new HashSet<>(); @@ -70,6 +72,7 @@ public final class PendingWorldReplacementManager implements Listener { private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard; private volatile Path overworldEntryWorldDirectory; private volatile CompletableFuture overworldSafeEntry; + private volatile CompletableFuture overworldSpawnPersistence; private volatile UUID overworldEntryAwaitingVerification; private volatile World overworldEntryWorld; private volatile boolean paperEntryListenerRegistered; @@ -377,22 +380,23 @@ public final class PendingWorldReplacementManager implements Listener { int chunkX = anchor.getBlockX() >> 4; int chunkZ = anchor.getBlockZ() >> 4; WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); - CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); + CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true, true); if (chunkFuture == null) { throw new IOException("The replacement spawn chunk request was not accepted."); } chunkFuture .thenCompose(chunk -> runtime.resolveSafeEntry(world, anchor)) .thenCompose(this::applyOverworldSpawn) - .thenCompose(this::persistOverworldSpawn) .whenComplete((safeEntry, failure) -> { if (failure != null) { targetFuture.completeExceptionally(failure); Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure); return; } + CompletableFuture persistence = new CompletableFuture<>(); + overworldSpawnPersistence = persistence; + persistence.thenRun(() -> retireOverworldEntryIfComplete(guard.transactionId())); targetFuture.complete(safeEntry.clone()); - retireOverworldEntryIfComplete(guard.transactionId()); }); } catch (Throwable failure) { targetFuture.completeExceptionally(failure); @@ -422,35 +426,19 @@ public final class PendingWorldReplacementManager implements Listener { return applied; } - private CompletableFuture persistOverworldSpawn(Location safeEntry) { - Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone(); - World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world"); - CompletableFuture persisted = new CompletableFuture<>(); - boolean scheduled = J.runGlobal(() -> { - try { - world.save(); - persisted.complete(requiredSafeEntry.clone()); - } catch (Throwable failure) { - persisted.completeExceptionally(failure); - } - }); - if (!scheduled) { - persisted.completeExceptionally(new IOException("Could not schedule replacement spawn persistence.")); - } - return persisted; - } - private CompletableFuture inspectLoginCollision(Location location) { Location requiredLocation = Objects.requireNonNull(location, "location").clone(); World world = Objects.requireNonNull(requiredLocation.getWorld(), "location.world"); int chunkX = requiredLocation.getBlockX() >> 4; int chunkZ = requiredLocation.getBlockZ() >> 4; WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); - CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); + CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, false, true); if (chunkFuture == null) { return CompletableFuture.failedFuture(new IOException("The saved login chunk request was not accepted.")); } - return chunkFuture.thenCompose(chunk -> inspectLoadedLoginCollision(requiredLocation)); + return chunkFuture.thenCompose(chunk -> chunk == null + ? CompletableFuture.completedFuture(false) + : inspectLoadedLoginCollision(requiredLocation)); } private CompletableFuture inspectLoadedLoginCollision(Location location) { @@ -528,14 +516,14 @@ public final class PendingWorldReplacementManager implements Listener { private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId) { WorldReplacementEntryGuard.Entry current = overworldEntryGuard; Path worldDirectory = overworldEntryWorldDirectory; - CompletableFuture safeEntry = overworldSafeEntry; + CompletableFuture persistence = overworldSpawnPersistence; if (current == null || worldDirectory == null || !current.transactionId().equals(transactionId) || !current.pendingPlayers().isEmpty() - || safeEntry == null - || !safeEntry.isDone() - || safeEntry.isCompletedExceptionally()) { + || persistence == null + || !persistence.isDone() + || persistence.isCompletedExceptionally()) { return; } try { @@ -545,6 +533,7 @@ public final class PendingWorldReplacementManager implements Listener { overworldEntryGuard = null; overworldEntryWorldDirectory = null; overworldSafeEntry = null; + overworldSpawnPersistence = null; overworldEntryWorld = null; pendingEntryAcknowledgements.entrySet().removeIf(entry -> entry.getValue().equals(transactionId)); } catch (Throwable failure) { @@ -581,6 +570,22 @@ public final class PendingWorldReplacementManager implements Listener { J.a(() -> discoverLoadedWorldTransaction(worldKey)); } + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldSave(WorldSaveEvent event) { + World replacementWorld = overworldEntryWorld; + CompletableFuture safeEntry = overworldSafeEntry; + CompletableFuture persistence = overworldSpawnPersistence; + if (event.getWorld() != replacementWorld + || safeEntry == null + || !safeEntry.isDone() + || safeEntry.isCompletedExceptionally() + || persistence == null + || persistence.isDone()) { + return; + } + persistence.complete(null); + } + ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer) throws IOException, InterruptedException, ExecutionException, TimeoutException { WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; @@ -603,12 +608,22 @@ public final class PendingWorldReplacementManager implements Listener { return null; } if (!newPlayer) { - boolean collisionSafe = inspectLoginCollision(requiredSavedLocation) - .get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (collisionSafe) { + SavedLocationInspection inspection = awaitSavedLocationInspection( + inspectLoginCollision(requiredSavedLocation), + SAVED_LOCATION_INSPECTION_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + if (inspection.safe()) { completeOverworldEntry(playerId, guard.transactionId()); return null; } + if (inspection.failure() instanceof TimeoutException) { + Iris.warn("Saved Overworld login location inspection timed out for " + playerId + + "; using the prepared replacement spawn."); + } else if (inspection.failure() != null) { + Iris.reportError("Could not inspect the saved Overworld login location for " + playerId + + "; using the prepared replacement spawn.", inspection.failure()); + } } CompletableFuture safeEntry = overworldSafeEntry; if (safeEntry == null) { @@ -620,6 +635,26 @@ public final class PendingWorldReplacementManager implements Listener { return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer); } + static SavedLocationInspection awaitSavedLocationInspection( + CompletableFuture inspection, + long timeout, + TimeUnit timeUnit + ) throws InterruptedException { + CompletableFuture requiredInspection = Objects.requireNonNull(inspection, "inspection"); + TimeUnit requiredTimeUnit = Objects.requireNonNull(timeUnit, "timeUnit"); + try { + return new SavedLocationInspection( + Boolean.TRUE.equals(requiredInspection.get(timeout, requiredTimeUnit)), + null + ); + } catch (TimeoutException failure) { + return new SavedLocationInspection(false, failure); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + return new SavedLocationInspection(false, cause); + } + } + void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId) throws IOException { WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; if (guard == null @@ -999,6 +1034,7 @@ public final class PendingWorldReplacementManager implements Listener { overworldEntryGuard = loaded.orElse(null); overworldEntryWorldDirectory = loaded.isPresent() ? target.worldDirectory() : null; overworldSafeEntry = null; + overworldSpawnPersistence = null; overworldEntryAwaitingVerification = null; } @@ -1006,6 +1042,7 @@ public final class PendingWorldReplacementManager implements Listener { overworldEntryGuard = null; overworldEntryWorldDirectory = null; overworldSafeEntry = null; + overworldSpawnPersistence = null; overworldEntryAwaitingVerification = null; overworldEntryWorld = null; pendingEntryAcknowledgements.clear(); @@ -1157,6 +1194,9 @@ public final class PendingWorldReplacementManager implements Listener { } } + record SavedLocationInspection(boolean safe, Throwable failure) { + } + private static final class RestartBoundaryRequired extends IOException { private RestartBoundaryRequired(String message) { super(message); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java index ae099e225..3b3c0654f 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java @@ -13,9 +13,16 @@ import org.mockito.MockedStatic; import java.io.IOException; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; 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.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -121,6 +128,59 @@ public class PendingWorldReplacementManagerPolicyTest { } } + @Test + public void savedLocationInspectionPreservesVerifiedSafeLocations() throws Exception { + PendingWorldReplacementManager.SavedLocationInspection inspection = + PendingWorldReplacementManager.awaitSavedLocationInspection( + CompletableFuture.completedFuture(true), + 0L, + TimeUnit.NANOSECONDS + ); + + assertTrue(inspection.safe()); + assertNull(inspection.failure()); + } + + @Test + public void savedLocationInspectionRedirectsUnsafeLocations() throws Exception { + PendingWorldReplacementManager.SavedLocationInspection inspection = + PendingWorldReplacementManager.awaitSavedLocationInspection( + CompletableFuture.completedFuture(false), + 0L, + TimeUnit.NANOSECONDS + ); + + assertFalse(inspection.safe()); + assertNull(inspection.failure()); + } + + @Test + public void savedLocationInspectionTimeoutFallsBackWithoutThrowing() throws Exception { + PendingWorldReplacementManager.SavedLocationInspection inspection = + PendingWorldReplacementManager.awaitSavedLocationInspection( + new CompletableFuture<>(), + 0L, + TimeUnit.NANOSECONDS + ); + + assertFalse(inspection.safe()); + assertTrue(inspection.failure() instanceof TimeoutException); + } + + @Test + public void savedLocationInspectionFailureRetainsItsCauseForReporting() throws Exception { + IOException failure = new IOException("chunk load failed"); + PendingWorldReplacementManager.SavedLocationInspection inspection = + PendingWorldReplacementManager.awaitSavedLocationInspection( + CompletableFuture.failedFuture(failure), + 0L, + TimeUnit.NANOSECONDS + ); + + assertFalse(inspection.safe()); + assertSame(failure, inspection.failure()); + } + private record EnvironmentExpectation(SlotKind slotKind, IrisEnvironment environment) { } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java index 3300dbcb7..15438443d 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java @@ -219,7 +219,7 @@ public class PendingWorldReplacementThreadAffinityTest { String listenerSource = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")).replace("\r\n", "\n"); String preparation = method(managerSource, "private void prepareOverworldEntry(World world)"); - String persistence = method(managerSource, "private CompletableFuture persistOverworldSpawn(Location safeEntry)"); + String persistence = method(managerSource, "public void onWorldSave(WorldSaveEvent event)"); String retirement = method( managerSource, "private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId)" @@ -230,18 +230,27 @@ public class PendingWorldReplacementThreadAffinityTest { ); String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))); - assertBefore(preparation, ".thenCompose(this::applyOverworldSpawn)", - ".thenCompose(this::persistOverworldSpawn)"); - assertBefore(preparation, "targetFuture.complete(safeEntry.clone())", - "retireOverworldEntryIfComplete(guard.transactionId())"); - assertTrue(persistence.contains("world.save()")); + assertBefore(preparation, "overworldSpawnPersistence = persistence", + "targetFuture.complete(safeEntry.clone())"); + assertTrue(persistence.contains("event.getWorld() != replacementWorld")); + assertTrue(persistence.contains("persistence.complete(null)")); assertTrue(retirement.contains("!current.pendingPlayers().isEmpty()")); - assertTrue(retirement.contains("!safeEntry.isDone()")); - assertTrue(retirement.contains("safeEntry.isCompletedExceptionally()")); + assertTrue(retirement.contains("!persistence.isDone()")); + assertTrue(retirement.contains("persistence.isCompletedExceptionally()")); assertTrue(listener.contains("event.isNewPlayer()")); assertTrue(generatorSource.contains("world.getHighestBlockYAt(initialSpawn) + 1")); } + @Test + public void loginCollisionInspectionDoesNotGenerateMissingReplacementChunks() throws Exception { + String managerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java")).replace("\r\n", "\n"); + String inspection = method(managerSource, "private CompletableFuture inspectLoginCollision(Location location)"); + + assertTrue(inspection.contains("requestChunkAsync(world, chunkX, chunkZ, false, true)")); + assertBefore(inspection, "chunk == null", "inspectLoadedLoginCollision(requiredLocation)"); + } + private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState( WorldSlotKey worldKey, long seed, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java index f4ee99165..06bd55ecc 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java @@ -51,7 +51,7 @@ public class BukkitEngineLifecycleContractTest { String waitedShutdown = method(jobSource, "private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs)"); - assertBefore(waitedShutdown, "inst.worker.interrupt()", "inst.worker.join("); + assertBefore(waitedShutdown, "inst.requestStop()", "inst.worker.join("); assertTrue(waitedShutdown.contains("inst.worker.isAlive()")); String hooksSource = Files.readString(Path.of(System.getProperty("iris.bukkitEnginePlatformHooksSource"))); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 2e5ba4e7a..1a1a12f1e 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -36,6 +36,7 @@ import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.IntBinaryOperator; import java.util.function.Supplier; @@ -49,6 +50,8 @@ import java.util.function.Supplier; */ public final class NativeStructureVolumeIndex { private static final int ORIGIN_REACH_CHUNKS = NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS; + private static final int ORIGIN_WINDOW_DIAMETER = ORIGIN_REACH_CHUNKS * 2 + 1; + private static final int ORIGIN_WINDOW_STRIPE_COUNT = Long.SIZE; private static final int MAX_CACHED_ORIGIN_CHUNKS = 16_384; private static final int MAX_CACHED_QUERY_CHUNKS = 4_096; private static final Map INDEXES = @@ -63,6 +66,7 @@ public final class NativeStructureVolumeIndex { new ConcurrentHashMap<>(); private final ConcurrentHashMap>> queryBuilds = new ConcurrentHashMap<>(); + private final ReentrantLock[] originWindowStripes = createOriginWindowStripes(); private NativeStructureVolumeIndex(Context context) { this.context = Objects.requireNonNull(context, "Native structure volume context must not be null"); @@ -128,6 +132,16 @@ public final class NativeStructureVolumeIndex { } private KList buildChunkVolumes(Engine engine, int chunkX, int chunkZ) { + long stripeMask = originWindowStripeMask(chunkX, chunkZ); + lockOriginWindow(stripeMask); + try { + return buildChunkVolumesCoordinated(engine, chunkX, chunkZ); + } finally { + unlockOriginWindow(stripeMask); + } + } + + private KList buildChunkVolumesCoordinated(Engine engine, int chunkX, int chunkZ) { int minX = chunkX << 4; int minZ = chunkZ << 4; int maxX = minX + 15; @@ -149,6 +163,20 @@ public final class NativeStructureVolumeIndex { return volumes == null ? NativeStructureVolume.NONE : volumes; } + 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); + int minCellZ = Math.floorDiv(chunkZ - ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); + int maxCellZ = Math.floorDiv(chunkZ + ORIGIN_REACH_CHUNKS, ORIGIN_WINDOW_DIAMETER); + long mask = 0L; + for (int cellX = minCellX; cellX <= maxCellX; cellX++) { + for (int cellZ = minCellZ; cellZ <= maxCellZ; cellZ++) { + mask |= 1L << originWindowStripe(cellX, cellZ); + } + } + return mask; + } + KList originVolumes(Engine engine, int chunkX, int chunkZ) { return cached(originCache, originBuilds, chunkKey(chunkX, chunkZ), () -> origins.volumesAt(engine, chunkX, chunkZ)); @@ -369,6 +397,42 @@ public final class NativeStructureVolumeIndex { return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); } + private static int originWindowStripe(int cellX, int cellZ) { + long mixed = chunkKey(cellX, cellZ); + mixed ^= mixed >>> 33; + mixed *= 0xff51afd7ed558ccdL; + mixed ^= mixed >>> 33; + mixed *= 0xc4ceb9fe1a85ec53L; + mixed ^= mixed >>> 33; + return (int) mixed & (ORIGIN_WINDOW_STRIPE_COUNT - 1); + } + + private static ReentrantLock[] createOriginWindowStripes() { + ReentrantLock[] stripes = new ReentrantLock[ORIGIN_WINDOW_STRIPE_COUNT]; + for (int i = 0; i < stripes.length; i++) { + stripes[i] = new ReentrantLock(); + } + return stripes; + } + + private void lockOriginWindow(long stripeMask) { + long pending = stripeMask; + while (pending != 0L) { + int stripe = Long.numberOfTrailingZeros(pending); + originWindowStripes[stripe].lock(); + pending &= pending - 1L; + } + } + + private void unlockOriginWindow(long stripeMask) { + long pending = stripeMask; + while (pending != 0L) { + int stripe = Long.SIZE - 1 - Long.numberOfLeadingZeros(pending); + originWindowStripes[stripe].unlock(); + pending &= ~(1L << stripe); + } + } + private static Map> lru(int capacity) { return new LinkedHashMap<>(16, 0.75F, true) { @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java index fc257cf35..651c11878 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java @@ -61,7 +61,6 @@ public final class ModdedPregenJob { .radiusX(radiusBlocks) .radiusZ(radiusBlocks) .build(); - PregenPerformanceProfile.apply(engine); ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync); PregeneratorMethod method = moddedMethod; if (cached) { @@ -71,7 +70,11 @@ public final class ModdedPregenJob { ACTIVE.set(active); dimension = level.dimension().identifier().toString(); try { - PregeneratorJob job = new PregeneratorJob(task, method, engine); + PregeneratorJob job = new PregeneratorJob(new PregeneratorJob.Configuration( + task, + method, + engine, + () -> PregenPerformanceProfile.apply(engine))); job.whenDone(() -> { if (!moddedMethod.hasPendingFinalSave()) { ACTIVE.compareAndSet(active, null); diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 4fbef3eb4..0166f54b4 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -43,6 +43,7 @@ import art.arcane.iris.util.common.scheduling.J; import java.awt.Color; import java.awt.EventQueue; import java.util.List; +import java.util.Objects; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; @@ -69,6 +70,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private final MemoryMonitor monitor; private final PregenTask task; private final AtomicBoolean saving; + private final AtomicBoolean stopRequested; private final List> onProgress = new CopyOnWriteArrayList<>(); private final List whenDone = new CopyOnWriteArrayList<>(); private final IrisPregenerator pregenerator; @@ -91,10 +93,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { private volatile long lastElapsed = 0L; private volatile String lastMethod = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING); - public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) { + public PregeneratorJob(Configuration configuration) { + PregenTask task = configuration.task(); + PregeneratorMethod method = configuration.method(); + Engine engine = configuration.engine(); this.engine = engine; monitor = new MemoryMonitor(50); saving = new AtomicBoolean(false); + stopRequested = new AtomicBoolean(false); info = new String[]{IrisLanguage.plain(DesktopUiMessages.PREGEN_INITIALIZING)}; this.task = task; this.pregenerator = new IrisPregenerator(task, method, this); @@ -122,11 +128,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } } - worker = new Thread(() -> { - J.sleep(1000); - computeBounds(); - this.pregenerator.start(); - }, "Iris Pregenerator"); + worker = new Thread(() -> runWorker(configuration.preparation()), "Iris Pregenerator"); worker.setPriority(Thread.MIN_PRIORITY); worker.setDaemon(true); worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex)); @@ -155,6 +157,26 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } } + private void runWorker(Runnable preparation) { + try { + J.sleep(1000); + if (stopRequested.get()) { + onClose(); + return; + } + preparation.run(); + if (stopRequested.get()) { + onClose(); + return; + } + computeBounds(); + pregenerator.start(); + } catch (Throwable failure) { + IrisLogging.reportError("Pregen startup failed.", failure); + onClose(); + } + } + private void computeBounds() { task.iterateAllChunks((xx, zz) -> { min.setX(Math.min(xx, min.getX())); @@ -178,10 +200,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return false; } - J.a(() -> { - inst.pregenerator.close(); - inst.worker.interrupt(); - }); + inst.requestStop(); return true; } @@ -204,8 +223,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs) { - inst.pregenerator.close(); - inst.worker.interrupt(); + inst.requestStop(); try { inst.worker.join(Math.max(1L, timeoutMs)); } catch (InterruptedException e) { @@ -357,12 +375,16 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { } public void stop() { - J.a(() -> { - pregenerator.close(); - worker.interrupt(); - close(); - instance.compareAndSet(this, null); - }); + requestStop(); + close(); + } + + private void requestStop() { + if (!stopRequested.compareAndSet(false, true)) { + return; + } + pregenerator.close(); + worker.interrupt(); } public void close() { @@ -583,4 +605,17 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { public String[] progress() { return info; } + + public record Configuration( + PregenTask task, + PregeneratorMethod method, + Engine engine, + Runnable preparation + ) { + public Configuration { + Objects.requireNonNull(task, "Pregen task"); + Objects.requireNonNull(method, "Pregen method"); + Objects.requireNonNull(preparation, "Pregen preparation"); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 109eea2d5..f1b63d029 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -214,6 +214,7 @@ public class IrisPregenerator { checkRegions(); int[] regionBounds = task.regionBounds(); generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]); + generator.onPregenStart(task.getCenter().getX(), task.getCenter().getZ()); task.iterateRegions((x, z) -> visitRegion(x, z, true)); task.iterateRegions((x, z) -> visitRegion(x, z, false)); completed = true; @@ -288,10 +289,17 @@ public class IrisPregenerator { } private void shutdown() { + Thread.interrupted(); shutdownStep("saving", listener::onSaving); shutdownStep("generator", generator::close); + Thread.interrupted(); shutdownStep("ticker", ticker::interrupt); - shutdownStep("listener", listener::onClose); + shutdownStep("mantle", () -> { + Mantle mantle = getMantle(); + if (mantle != null) { + reclaimTectonicPlates(mantle); + } + }); shutdownStep("protocol", () -> { IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class); if (protocolServer != null) { @@ -299,12 +307,7 @@ public class IrisPregenerator { protocolServer.pregenEnd(jobId, total > 0 && generated.get() >= total); } }); - shutdownStep("mantle", () -> { - Mantle mantle = getMantle(); - if (mantle != null) { - reclaimTectonicPlates(mantle); - } - }); + shutdownStep("listener", listener::onClose); } private void shutdownStep(String step, Runnable action) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java index b79a4c1a8..15d2dfe86 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfile.java @@ -18,16 +18,17 @@ package art.arcane.iris.core.pregenerator; -import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.MeteredCache; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.util.project.stream.ProceduralStream; -import art.arcane.iris.util.project.stream.utility.CachedStream2D; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; +import art.arcane.iris.util.project.stream.utility.CachedStream2D; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -68,7 +69,7 @@ public final class PregenPerformanceProfile { public static void apply(Engine engine) { apply(); - if (requiresNoiseCacheRefresh(engine)) { + if (!prepareNoiseCacheInPlace(engine)) { engine.hotloadComplex(); logApplied(); } @@ -77,35 +78,44 @@ public final class PregenPerformanceProfile { public static void applyToGenerator(PlatformChunkGenerator generator) { apply(); Engine engine = generator == null ? null : generator.getEngine(); - if (requiresNoiseCacheRefresh(engine)) { + if (!prepareNoiseCacheInPlace(engine)) { generator.hotloadComplexAsync(HOTLOAD_ACQUISITION_TIMEOUT_SECONDS, TimeUnit.SECONDS).join(); logApplied(); } } - /** Two workers per core: enough to keep cores busy while a share of the pool sits in waits. */ static int pregenBurstParallelism(int availableProcessors) { return Math.max(4, availableProcessors * 2); } - private static boolean requiresNoiseCacheRefresh(Engine engine) { + private static boolean prepareNoiseCacheInPlace(Engine engine) { if (engine == null) { - return false; + return true; } IrisComplex complex = engine.getComplex(); if (complex == null) { - return true; - } - ProceduralStream heightStream = complex.getHeightStream(); - if (!(heightStream instanceof MeteredCache cache)) { - return true; + return false; } ProceduralStream caveBiomeStream = complex.getCaveBiomeStream(); if (!(caveBiomeStream instanceof CachedStream2D cachedStream) || !cachedStream.usesFastCache()) { + return false; + } + int configuredChunks = IrisSettings.get().getPerformance().getNoiseCacheSize(); + if (!resizeNoiseCache(complex.getNaturalHeightStream(), configuredChunks)) { + return false; + } + return resizeNoiseCache(complex.getHeightStream(), configuredChunks); + } + + private static boolean resizeNoiseCache(ProceduralStream stream, int configuredChunks) { + if (stream instanceof CachedDoubleStream2D cachedStream) { + cachedStream.setMaximumChunks(configuredChunks); return true; } - long configuredMaxSize = (long) IrisSettings.get().getPerformance().getNoiseCacheSize() * 256L; - return cache.getMaxSize() != configuredMaxSize; + if (!(stream instanceof MeteredCache cache)) { + return false; + } + return cache.getMaxSize() == (long) configuredChunks * 256L; } private static void logApplied() { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index 8479b25d7..6092bb258 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -128,6 +128,10 @@ public class PregenTask { private static int[] computeOrder(long key) { int pullX = (int) (key >> 32); int pullZ = (int) key; + if (pullX >= 0 && pullX <= 31 && pullZ >= 0 && pullZ <= 31) { + return packOrder(contiguousOrder(pullX, pullZ)); + } + Position2 pull = new Position2(pullX, pullZ); KList p = new KList<>(); new Spiraler(33, 33, (x, z) -> { @@ -140,7 +144,10 @@ public class PregenTask { p.add(new Position2(xx, zz)); }).drain(); p.sort(Comparator.comparing((i) -> i.distance(pull))); - KList ordered = latticeOrder(p); + return packOrder(latticeOrder(p)); + } + + private static int[] packOrder(KList ordered) { int[] packed = new int[ordered.size()]; for (int index = 0; index < ordered.size(); index++) { Position2 position = ordered.get(index); @@ -150,6 +157,16 @@ public class PregenTask { return packed; } + private static KList contiguousOrder(int pullX, int pullZ) { + KList ordered = new KList<>(); + new Spiraler(65, 65, (x, z) -> { + if (x >= 0 && x <= 31 && z >= 0 && z <= 31) { + ordered.add(new Position2(x, z)); + } + }).setOffset(pullX, pullZ).drain(); + return ordered; + } + /** * Regroups a region's chunk order into {@link #CHUNK_LATTICE_STRIDE}-spaced lattices, keeping * the pull order inside each lattice. Chunks generated back to back then sit a stride apart, so @@ -186,9 +203,11 @@ public class PregenTask { public void iterateChunks(int rX, int rZ, Spiraled s) { Bound bound = bounds.chunk(); + int pullX = PowerOfTwoCoordinates.blockToChunkFloor(center.getX()) - PowerOfTwoCoordinates.regionToChunk(rX); + int pullZ = PowerOfTwoCoordinates.blockToChunkFloor(center.getZ()) - PowerOfTwoCoordinates.regionToChunk(rZ); iterateRegion(rX, rZ, ((x, z) -> { if (bound.check(x, z)) s.on(x, z); - })); + }), pullX, pullZ); } public int[] regionBounds() { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java index e6ec6ad6f..af6344527 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregeneratorMethod.java @@ -83,6 +83,9 @@ public interface PregeneratorMethod { default void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { } + default void onPregenStart(int centerBlockX, int centerBlockZ) { + } + default void onRegionSubmitted(int regionX, int regionZ) { } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java index b87a366bc..8b5e1b59c 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncOrMedievalPregenMethod.java @@ -87,6 +87,11 @@ public class AsyncOrMedievalPregenMethod implements PregeneratorMethod { method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { + method.onPregenStart(centerBlockX, centerBlockZ); + } + @Override public void onRegionSubmitted(int regionX, int regionZ) { method.onRegionSubmitted(regionX, regionZ); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 262ed73be..9800983b7 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -88,6 +88,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final boolean urgent; private final ConcurrentHashMap regionPending; private final ConcurrentHashMap> regionChunks; + private final Queue> pendingEvictions; private final KSet drainedRegions; private final KSet evictedRegions; private volatile int evictionWindowRegions; @@ -166,6 +167,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.urgent = false; this.regionPending = new ConcurrentHashMap<>(); this.regionChunks = new ConcurrentHashMap<>(); + this.pendingEvictions = new ConcurrentLinkedQueue<>(); this.drainedRegions = new KSet<>(); this.evictedRegions = new KSet<>(); this.evictionWindowRegions = -1; @@ -173,7 +175,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.boundsMinRegionZ = Integer.MIN_VALUE; this.boundsMaxRegionX = Integer.MAX_VALUE; this.boundsMaxRegionZ = Integer.MAX_VALUE; - this.adaptiveInFlightLimit = new AtomicInteger(this.threads); + this.adaptiveInFlightLimit = new AtomicInteger(computeInitialInFlightLimit( + this.threads, + this.effectiveWorkerThreads)); this.adaptiveMinInFlightLimit = Math.max(4, Math.min(16, Math.max(1, this.threads / 4))); int pregenWorldHeight = world.getMaxHeight() - world.getMinHeight(); this.backpressure = new PregenMantleBackpressure( @@ -280,30 +284,15 @@ public class AsyncPregenMethod implements PregeneratorMethod { boundsMinRegionZ = minRegionZ; boundsMaxRegionX = maxRegionX; boundsMaxRegionZ = maxRegionZ; - prefetchHydrology(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } - /** - * Plans the hydrology tiles of the whole area up front, nearest the centre first, so the spiral - * finds its tiles ready instead of stalling on each cold plan. - */ - private void prefetchHydrology(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { Engine engine = resolveMetricsEngine(); if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { return; } - int minimumBlockX = minRegionX << 9; - int minimumBlockZ = minRegionZ << 9; - int maximumBlockX = ((maxRegionX + 1) << 9) - 1; - int maximumBlockZ = ((maxRegionZ + 1) << 9) - 1; - engine.getComplex().getHydrologyRuntime().prefetchArea( - minimumBlockX, - minimumBlockZ, - maximumBlockX, - maximumBlockZ, - (minimumBlockX + maximumBlockX) / 2, - (minimumBlockZ + maximumBlockZ) / 2 - ); + engine.getComplex().getHydrologyRuntime().preparePregeneration(centerBlockX, centerBlockZ); } private boolean inBounds(int rx, int rz) { @@ -367,47 +356,22 @@ public class AsyncPregenMethod implements PregeneratorMethod { return true; } - private void evictRegion(long c) { + private CompletableFuture evictRegion(long c) { if (!evictedRegions.add(c)) { - return; + return CompletableFuture.completedFuture(null); } regionPending.remove(c); Queue chunks = regionChunks.remove(c); if (chunks == null || chunks.isEmpty()) { - return; + return CompletableFuture.completedFuture(null); } - try { - if (foliaRuntime) { - Chunk anchor = null; - for (Chunk chunk : chunks) { - if (chunk == null) { - continue; - } - - if (anchor == null) { - anchor = chunk; - } - - int cx = chunk.getX(); - int cz = chunk.getZ(); - if (!J.runRegion(world, cx, cz, () -> unloadChunkSafely(cx, cz))) { - unloadChunkSafely(cx, cz); - } - } - - if (anchor != null) { - int ax = anchor.getX(); - int az = anchor.getZ(); - if (!J.runRegion(world, ax, az, () -> INMS.get().flushChunkIO(world))) { - INMS.get().flushChunkIO(world); - } - } - return; - } - - J.s(() -> { + CompletableFuture eviction; + if (foliaRuntime) { + eviction = evictFoliaRegion(chunks); + } else { + eviction = J.sfut(() -> { for (Chunk chunk : chunks) { if (chunk != null) { unloadChunkSafely(chunk.getX(), chunk.getZ()); @@ -416,9 +380,49 @@ public class AsyncPregenMethod implements PregeneratorMethod { INMS.get().flushChunkIO(world); }); - } catch (Throwable e) { - IrisLogging.reportError(e); } + return trackEviction(eviction); + } + + private CompletableFuture evictFoliaRegion(Queue chunks) { + List> unloads = new ArrayList<>(chunks.size()); + Chunk anchor = null; + for (Chunk chunk : chunks) { + if (chunk == null) { + continue; + } + if (anchor == null) { + anchor = chunk; + } + + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + unloads.add(J.runRegionFuture(world, chunkX, chunkZ, () -> unloadChunkSafely(chunkX, chunkZ))); + } + + CompletableFuture unloaded = CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)); + if (anchor == null) { + return unloaded; + } + + int anchorX = anchor.getX(); + int anchorZ = anchor.getZ(); + return unloaded.thenCompose(ignored -> J.runRegionFuture( + world, + anchorX, + anchorZ, + () -> INMS.get().flushChunkIO(world))); + } + + private CompletableFuture trackEviction(CompletableFuture eviction) { + pendingEvictions.add(eviction); + eviction.whenComplete((ignored, failure) -> { + pendingEvictions.remove(eviction); + if (failure != null) { + IrisLogging.reportError(failure); + } + }); + return eviction; } private void unloadChunkSafely(int cx, int cz) { @@ -438,36 +442,18 @@ public class AsyncPregenMethod implements PregeneratorMethod { private void flushAllRemainingChunks() { List keys = new ArrayList<>(regionChunks.keySet()); - - if (foliaRuntime) { - for (Long rk : keys) { - evictRegion(rk); - } - return; + List> evictions = new ArrayList<>(keys.size() + pendingEvictions.size()); + for (Long regionKey : keys) { + evictions.add(evictRegion(regionKey)); } + evictions.addAll(pendingEvictions); - CompletableFuture flush = J.sfut(() -> { - for (Long rk : keys) { - if (!evictedRegions.add(rk)) { - continue; - } - - regionPending.remove(rk); - Queue chunks = regionChunks.remove(rk); - if (chunks == null) { - continue; - } - - for (Chunk chunk : chunks) { - if (chunk != null) { - unloadChunkSafely(chunk.getX(), chunk.getZ()); - } - } - } - - world.save(); - INMS.get().flushChunkIO(world); - }); + CompletableFuture evictionsComplete = CompletableFuture.allOf( + evictions.toArray(CompletableFuture[]::new)); + CompletableFuture settledEvictions = evictionsComplete.handle((ignored, failure) -> null); + CompletableFuture flush = foliaRuntime + ? settledEvictions + : settledEvictions.thenCompose(ignored -> J.sfut(() -> INMS.get().flushChunkIO(world))); try { flush.get(FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -585,9 +571,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { return; } - int deficit = threads - current; - int step = deficit > (threads / 2) ? Math.max(2, threads / 8) : 1; - int next = Math.min(threads, current + step); + int next = nextAdaptiveInFlightLimit(current, threads); if (adaptiveInFlightLimit.compareAndSet(current, next)) { logAdaptiveLimit("increase", next); notifyPermitWaiters(); @@ -628,6 +612,15 @@ public class AsyncPregenMethod implements PregeneratorMethod { return strictSerial ? 1 : Math.max(1, recommendedCap); } + static int computeInitialInFlightLimit(int concurrencyCap, int workerThreads) { + int initial = Math.max(4, Math.max(1, workerThreads)); + return Math.min(Math.max(1, concurrencyCap), initial); + } + + static int nextAdaptiveInFlightLimit(int current, int concurrencyCap) { + return Math.min(Math.max(1, concurrencyCap), Math.max(1, current) + 1); + } + static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) { int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); if (detectedWorkerPoolThreads > 0) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java index 6fe0f860e..c5073d9c8 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethod.java @@ -175,6 +175,11 @@ public class CachedPregenMethod implements PregeneratorMethod { method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { + method.onPregenStart(centerBlockX, centerBlockZ); + } + @Override public void onRegionSubmitted(int regionX, int regionZ) { method.onRegionSubmitted(regionX, regionZ); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java index 7e902edc4..74fcdb7c6 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/HybridPregenMethod.java @@ -80,6 +80,11 @@ public class HybridPregenMethod implements PregeneratorMethod { inWorld.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); } + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { + inWorld.onPregenStart(centerBlockX, centerBlockZ); + } + @Override public void onRegionSubmitted(int regionX, int regionZ) { inWorld.onRegionSubmitted(regionX, regionZ); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java index b2560d3d1..c56602aef 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.pregenerator.methods; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.pregenerator.PregenListener; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.tools.IrisToolbelt; @@ -123,7 +124,7 @@ public class MedievalPregenMethod implements PregeneratorMethod { futures.clear(); } - private void unloadAndSaveAllChunks(boolean saveWorld) { + private void unloadAndSaveAllChunks(boolean flushChunkIo) { if (J.isFolia()) { lastUse.clear(); return; @@ -138,12 +139,12 @@ public class MedievalPregenMethod implements PregeneratorMethod { for (Chunk i : new ArrayList<>(lastUse.keySet())) { Long lastUseTime = lastUse.get(i); if (lastUseTime != null && M.ms() - lastUseTime >= 10) { - i.unload(); + i.unload(true); lastUse.remove(i); } } - if (saveWorld) { - world.save(); + if (flushChunkIo) { + INMS.get().flushChunkIO(world); } }); diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index 20b351d12..5a91bed69 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -311,13 +311,19 @@ public class IrisToolbelt { if (PregeneratorJob.getInstance() != null) { throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop."); } - applyPregenPerformanceProfile(engine); boolean useCachedWrapper = false; if (cached && engine != null) { IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); useCachedWrapper = runtimeSchedulerMode != IrisRuntimeSchedulerMode.FOLIA; } - return new PregeneratorJob(task, useCachedWrapper ? new CachedPregenMethod(method, resolvePregenCache(engine.getWorld()), task) : method, engine); + PregeneratorMethod activeMethod = useCachedWrapper + ? new CachedPregenMethod(method, resolvePregenCache(engine.getWorld()), task) + : method; + return new PregeneratorJob(new PregeneratorJob.Configuration( + task, + activeMethod, + engine, + () -> applyPregenPerformanceProfile(engine))); } private static PregenCache resolvePregenCache(IrisWorld world) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index df2a4a424..bec080fe9 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -78,6 +78,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; +import java.nio.file.Path; import java.util.function.BiFunction; @Data @@ -87,6 +88,7 @@ public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); private static final int GRID_BOUNDS_CACHE_SIZE = 8192; + private static final int STUDIO_NOISE_CACHE_SIZE = 32_768; /** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20; private static final int HEIGHT_BOUNDS_GRID = 4; @@ -170,7 +172,7 @@ public class IrisComplex implements DataProvider { } public IrisComplex(Engine engine, boolean simple) { - int cacheSize = IrisSettings.get().getPerformance().getNoiseCacheSize(); + int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize()); IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); this.rng = new RNG(engine.getSeedManager().getComplex()); @@ -320,6 +322,7 @@ public class IrisComplex implements DataProvider { footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint), () -> engine.getPlatformHooks().isMainThread() )); + hydrologyRuntime.setNeighbourPrefetchEnabled(!engine.isStudio()); } heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) .cache2DDouble("heightStream", engine, cacheSize); @@ -373,6 +376,16 @@ public class IrisComplex implements DataProvider { //@done } + static int noiseCacheSize(Engine engine, int configuredSize) { + return engine.isStudio() ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize; + } + + void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) { + if (hydrologyRuntime != null) { + hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot); + } + } + static InferredType resolveNaturalInferredType( ProceduralStream bridgeStream, IrisBiome focusBiome, @@ -958,7 +971,7 @@ public class IrisComplex implements DataProvider { // catches the same corner reached by another thread or by hydrology planning earlier. SharedCornerBounds shared = sharedCornerBounds(); long sharedKey = SharedCornerBounds.key(gx, gz, interpolatorIndex); - long packed = shared.contains(sharedKey) ? shared.get(sharedKey) : Long.MIN_VALUE; + long packed = shared.get(sharedKey); if (packed == Long.MIN_VALUE) { packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz); if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 486f431b1..0e27aaa85 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -40,6 +40,7 @@ import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.pack.PackValidationCache; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.spi.IrisLogging; @@ -70,6 +71,7 @@ import lombok.Getter; import lombok.Setter; import java.io.File; +import java.nio.file.Path; import java.util.HashSet; import java.util.Locale; import java.util.Objects; @@ -224,6 +226,7 @@ public class IrisEngine implements Engine { getData().registerEngine(this); _t0 = M.ms(); long phaseStartedAt = System.nanoTime(); + String studioCacheIdentity = currentStudioCacheIdentity(); getData().loadPrefetch(this); IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); logStudioInitializationPhase("load_prefetch", phaseStartedAt, false); @@ -236,6 +239,7 @@ public class IrisEngine implements Engine { _t0 = M.ms(); phaseStartedAt = System.nanoTime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); + enableStableStudioCache(initialRuntime, studioCacheIdentity); runtimeBuilder.publishRuntime(initialRuntime, null); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); logPackCompatSummary(); @@ -318,6 +322,47 @@ public class IrisEngine implements Engine { Boolean.toString(skipped)); } + private String currentStudioCacheIdentity() { + if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) { + return ""; + } + try { + String contentFingerprint = PackValidationCache.contentFingerprint( + IrisPlatforms.get().packsFolderNoCreate()); + String contextFingerprint = PackValidationCache.contextFingerprint(); + if (contentFingerprint.isBlank() || contextFingerprint.isBlank()) { + return ""; + } + return contentFingerprint + contextFingerprint; + } catch (RuntimeException failure) { + IrisLogging.warn("Studio runtime cache fingerprint failed: " + failure.getMessage()); + return ""; + } + } + + private void enableStableStudioCache(EngineRuntime runtime, String initialIdentity) { + if (initialIdentity.isBlank()) { + return; + } + String finalIdentity = currentStudioCacheIdentity(); + if (!initialIdentity.equals(finalIdentity)) { + IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled."); + return; + } + runtime.complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot()); + IrisLogging.debug("Enabled shared Studio hydrology cache identity=" + + initialIdentity.substring(0, Math.min(12, initialIdentity.length()))); + } + + private Path studioHydrologyCacheRoot() { + Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath().toAbsolutePath().normalize(); + Path packRoot = packsRoot.resolve(getDimension().getLoadKey()).normalize(); + if (!packRoot.startsWith(packsRoot)) { + throw new IllegalStateException("Studio dimension key resolves outside the Iris packs folder."); + } + return packRoot.resolve(".iris").resolve("studio-hydrology"); + } + private void startGenerationCacheWarm(long phaseStartedAtNanos) { if (!backgroundTasks.scheduleTrackedTask(() -> warmGenerationCaches(phaseStartedAtNanos))) { throw new IllegalStateException("Iris background task admission closed before generation cache warming."); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java index a84e8816a..bd3d00efa 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -19,8 +19,8 @@ import java.util.Objects; final class HydrologyFootprintCompiler { private static final int FEATURE_ROLE_COUNT = 7; - private static final int COURSE_FOOTPRINT_CACHE_SIZE = 8; - private static final int VALIDATION_RASTER_CACHE_SIZE = 8; + private static final int COURSE_FOOTPRINT_CACHE_SIZE = 32; + private static final int VALIDATION_RASTER_CACHE_SIZE = 32; private static final int[][] HORIZONTAL_NEIGHBORS = { {1, 0}, {-1, 0}, {0, 1}, {0, -1} }; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java index bcdae50ca..ce1f6e3af 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java @@ -2,4 +2,8 @@ package art.arcane.iris.engine.hydrology; public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler { HydrologyTerrainSample sampleBasis(int blockX, int blockZ); + + default HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) { + return sampleBasis(blockX, blockZ); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 7cb8a1a3e..45c503abb 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -320,9 +320,33 @@ public final class HydrologyPlanner { resolvedOwners.invalidateAll(); } + void reuseResolvedTile(HydrologyTile tile) { + Objects.requireNonNull(tile, "tile"); + if (tile.worldSeed() != worldSeed + || tile.settingsFingerprint() != settings.fingerprint() + || tile.tileSize() != settings.routing().tileSize()) { + throw new IllegalArgumentException("Hydrology tile does not match this planner."); + } + HydrologyCaveCourseFilter.Result result = new HydrologyCaveCourseFilter.Result( + tile.nodes(), + tile.edges(), + tile.outlets(), + tile.courses(), + tile.cavePlans() + ); + HydrologyOwnerDraft draft = new HydrologyOwnerDraft( + tile.key(), + result, + tile.diagnosticCandidates(), + null + ); + resolvedOwners.put(tile.key(), new CrossTileResolvedOwner(draft, List.of())); + } + private HydrologyOwnerDraft compileOwnerDraft( HydrologyTileKey key, - CrossTileDraftAdmission crossTileAdmission + CrossTileDraftAdmission crossTileAdmission, + boolean retainFootprintCompiler ) { PlanningSamples previous = planningSamples.get(); if (previous == null) { @@ -333,7 +357,11 @@ public final class HydrologyPlanner { draftProfiles.set(profile); long started = System.nanoTime(); try { - HydrologyOwnerDraft draft = compileScopedOwnerDraft(key, crossTileAdmission); + HydrologyOwnerDraft draft = compileScopedOwnerDraft( + key, + crossTileAdmission, + retainFootprintCompiler + ); profile.log(key, ownerColorRank(key), System.nanoTime() - started); return draft; } finally { @@ -350,7 +378,8 @@ public final class HydrologyPlanner { private HydrologyOwnerDraft compileScopedOwnerDraft( HydrologyTileKey key, - CrossTileDraftAdmission crossTileAdmission + CrossTileDraftAdmission crossTileAdmission, + boolean retainFootprintCompiler ) { Objects.requireNonNull(key, "key"); ArrayList diagnostics = new ArrayList<>(); @@ -560,7 +589,12 @@ public final class HydrologyPlanner { } HydrologyCaveCourseFilter.Result result = publication.result(); List uniqueDiagnostics = uniqueDiagnostics(diagnostics); - HydrologyOwnerDraft draft = new HydrologyOwnerDraft(key, result, uniqueDiagnostics); + HydrologyOwnerDraft draft = new HydrologyOwnerDraft( + key, + result, + uniqueDiagnostics, + retainFootprintCompiler ? footprintCompiler : null + ); validateOwnerDraftReach(draft); return draft; } @@ -576,14 +610,17 @@ public final class HydrologyPlanner { rejection.winnerSourceId() )); } - HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( - settings, - new HydrologyFootprintCompiler.Sampling( - sampler, - geometrySampler, - naturalSampler - ) - ); + HydrologyFootprintCompiler footprintCompiler = resolution.draft().footprintCompiler(); + if (footprintCompiler == null) { + footprintCompiler = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + sampler, + geometrySampler, + naturalSampler + ) + ); + } MaterializedHydrology materialized = materializeFinalHydrology( result, diagnostics, @@ -743,11 +780,14 @@ public final class HydrologyPlanner { try { int ownerRank = ownerColorRank(key); ColorRankedDraftAdmission admission = new ColorRankedDraftAdmission(key, ownerRank, context); - HydrologyOwnerDraft draft = compileOwnerDraft(key, admission); - validateOwnerDraftReach(draft); + HydrologyOwnerDraft draft = compileOwnerDraft( + key, + admission, + key.equals(context.root()) + ); CrossTileResolvedOwner resolved = new CrossTileResolvedOwner(draft, admission.observedRejections()); context.remember(key, resolved); - resolvedOwners.put(key, resolved); + resolvedOwners.put(key, resolved.withoutFootprintCompiler()); owned.complete(resolved); return resolved; } catch (Throwable failure) { @@ -3955,7 +3995,7 @@ public final class HydrologyPlanner { int lowest = center.naturalHeight(); for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) { for (double direction = -1D; direction <= 1D; direction += 2D) { - HydrologyTerrainSample side = sampleLandBasis( + HydrologyTerrainSample side = sampleLandBasisWithoutSlope( (int) StrictMath.round(point.x() + normalX * distance * direction), (int) StrictMath.round(point.z() + normalZ * distance * direction)); if (side != null) { @@ -4597,8 +4637,8 @@ public final class HydrologyPlanner { private boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) { List crossing = rasterLine(start, end); - HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); - HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); + HydrologyTerrainSample startTerrain = sampleLandBasisWithoutSlope(start.x(), start.z()); + HydrologyTerrainSample endTerrain = sampleLandBasisWithoutSlope(end.x(), end.z()); if (startTerrain == null || endTerrain == null) { return false; } @@ -4606,7 +4646,7 @@ public final class HydrologyPlanner { int threshold = settings.hydraulics().waterfallMinimumDrop(); for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { HydrologyPoint point = crossing.get(pointIndex); - HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(point.x(), point.z()); if (terrain == null || !terrain.transitAllowed() || boundaryHeight - terrain.naturalHeight() >= threshold) { @@ -4679,7 +4719,7 @@ public final class HydrologyPlanner { List crossing = rasterLine(start, end); for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { HydrologyPoint point = crossing.get(pointIndex); - HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(point.x(), point.z()); if (terrain == null || terrain.ocean() || !terrain.transitAllowed()) { return false; } @@ -6531,7 +6571,7 @@ public final class HydrologyPlanner { HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance); int bankX = bankPoint.x(); int bankZ = bankPoint.z(); - return sampleLandBasis(bankX, bankZ); + return sampleLandBasisWithoutSlope(bankX, bankZ); } private HydrologyPoint surfaceBankPoint(HydrologyPoint point, Direction tangent, int signedDistance) { @@ -7721,6 +7761,33 @@ public final class HydrologyPlanner { return terrain == null || terrain.ocean() ? null : terrain; } + private HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) { + if (naturalSampler == null) { + return sampleBasis(blockX, blockZ); + } + PlanningSamples samples = planningSamples.get(); + if (samples == null) { + return naturalSampler.sampleBasisWithoutSlope(blockX, blockZ); + } + long packed = RiverFootprint.pack(blockX, blockZ); + HydrologyTerrainSample fullSample = samples.basis.get(packed); + if (fullSample != null || samples.basis.containsKey(packed)) { + return fullSample; + } + HydrologyTerrainSample cached = samples.basisWithoutSlope.get(packed); + if (cached != null || samples.basisWithoutSlope.containsKey(packed)) { + return cached; + } + HydrologyTerrainSample sampled = naturalSampler.sampleBasisWithoutSlope(blockX, blockZ); + samples.basisWithoutSlope.put(packed, sampled); + return sampled; + } + + private HydrologyTerrainSample sampleLandBasisWithoutSlope(int blockX, int blockZ) { + HydrologyTerrainSample terrain = sampleBasisWithoutSlope(blockX, blockZ); + return terrain == null || terrain.ocean() ? null : terrain; + } + private HydrologyTerrainSample sampleDetailed(int blockX, int blockZ) { PlanningSamples samples = planningSamples.get(); if (samples == null) { @@ -7790,6 +7857,7 @@ public final class HydrologyPlanner { private static final class PlanningSamples { private final Long2ObjectOpenHashMap basis; + private final Long2ObjectOpenHashMap basisWithoutSlope; private final Long2ObjectOpenHashMap detailed; private final Long2ObjectOpenHashMap classifications; private final HashMap geometry; @@ -7802,6 +7870,7 @@ public final class HydrologyPlanner { private PlanningSamples() { this.basis = new Long2ObjectOpenHashMap<>(); + this.basisWithoutSlope = new Long2ObjectOpenHashMap<>(); this.detailed = new Long2ObjectOpenHashMap<>(); this.classifications = new Long2ObjectOpenHashMap<>(); this.geometry = new HashMap<>(); @@ -8295,8 +8364,15 @@ public final class HydrologyPlanner { private record HydrologyOwnerDraft( HydrologyTileKey key, HydrologyCaveCourseFilter.Result result, - List diagnostics + List diagnostics, + HydrologyFootprintCompiler footprintCompiler ) { + private HydrologyOwnerDraft withoutFootprintCompiler() { + if (footprintCompiler == null) { + return this; + } + return new HydrologyOwnerDraft(key, result, diagnostics, null); + } } private record MaterializedHydrology( @@ -8528,7 +8604,8 @@ public final class HydrologyPlanner { throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound."); } CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner); - if (existing != null && !existing.equals(owner)) { + if (existing != null + && !existing.withoutFootprintCompiler().equals(owner.withoutFootprintCompiler())) { throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results."); } if (resolved.size() > maximumOwners) { @@ -8555,6 +8632,10 @@ public final class HydrologyPlanner { private int ownerCount() { return resolved.size(); } + + private HydrologyTileKey root() { + return root; + } } private record CrossTileResolvedOwner( @@ -8565,6 +8646,13 @@ public final class HydrologyPlanner { Objects.requireNonNull(draft, "draft"); observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections")); } + + private CrossTileResolvedOwner withoutFootprintCompiler() { + HydrologyOwnerDraft uncachedDraft = draft.withoutFootprintCompiler(); + return uncachedDraft == draft + ? this + : new CrossTileResolvedOwner(uncachedDraft, observedRejections); + } } private record CrossTileRejectionKey(long courseId, long winnerCourseId) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index 7aef40bcb..f614331b7 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -1,49 +1,70 @@ package art.arcane.iris.engine.hydrology; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.cache.CacheKey; import com.github.benmanes.caffeine.cache.Cache; 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.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; public final class HydrologyTileCache implements AutoCloseable { private static final int DEFAULT_MAXIMUM_ENTRIES = 64; + private static final int MAXIMUM_SHARED_ENTRIES = 8; 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 Cache SHARED_TILES = Caffeine.newBuilder() + .maximumSize(MAXIMUM_SHARED_ENTRIES) + .expireAfterAccess(10, TimeUnit.MINUTES) + .build(); private final HydrologyPlanner planner; private final int maximumEntries; private final Cache tiles; private final Cache composedChunks; private final AtomicLong cacheEpoch; + private final AtomicBoolean closed; private final ThreadLocal localChunkColumns; private final Executor prefetchExecutor; + private final ConcurrentLinkedQueue prefetchQueue; + private final Set queuedPrefetches; + private final AtomicBoolean prefetchActive; private final ConcurrentHashMap> planning; private final BooleanSupplier waitingForbidden; + private volatile SharedCacheScope sharedCacheScope; + private volatile StudioHydrologyTileStore persistentStore; + private volatile boolean neighbourPrefetchEnabled; public HydrologyTileCache(HydrologyPlanner planner) { - this(planner, DEFAULT_MAXIMUM_ENTRIES); + this(planner, DEFAULT_MAXIMUM_ENTRIES, null, null, null); } public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) { - this(planner, maximumEntries, null); + this(planner, maximumEntries, null, null, null); } public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) { - this(planner, maximumEntries, prefetchExecutor, null); + this(planner, maximumEntries, prefetchExecutor, null, null); } /** @@ -58,15 +79,30 @@ public final class HydrologyTileCache implements AutoCloseable { int maximumEntries, Executor prefetchExecutor, BooleanSupplier waitingForbidden + ) { + this(planner, maximumEntries, prefetchExecutor, waitingForbidden, null); + } + + public HydrologyTileCache( + HydrologyPlanner planner, + int maximumEntries, + Executor prefetchExecutor, + BooleanSupplier waitingForbidden, + SharedCacheScope sharedCacheScope ) { this.planner = Objects.requireNonNull(planner, "planner"); this.prefetchExecutor = prefetchExecutor; this.waitingForbidden = waitingForbidden; + this.sharedCacheScope = sharedCacheScope; + this.persistentStore = null; this.planning = new ConcurrentHashMap<>(); if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive."); } this.maximumEntries = maximumEntries; + this.prefetchQueue = new ConcurrentLinkedQueue<>(); + this.queuedPrefetches = ConcurrentHashMap.newKeySet(); + this.prefetchActive = new AtomicBoolean(); this.tiles = Caffeine.newBuilder() .maximumSize(maximumEntries) .build(); @@ -74,7 +110,9 @@ public final class HydrologyTileCache implements AutoCloseable { .maximumSize(MAXIMUM_COMPOSED_CHUNKS) .build(); this.cacheEpoch = new AtomicLong(); + this.closed = new AtomicBoolean(); this.localChunkColumns = new ThreadLocal<>(); + this.neighbourPrefetchEnabled = true; } /** @@ -87,17 +125,16 @@ public final class HydrologyTileCache implements AutoCloseable { if (prefetchExecutor == null || plansInline()) { return planInline(key); } - return awaitTile(planAsync(key)); + return awaitTile(planDemandAsync(key)); } - /** - * A pool worker plans on its own thread. Parking the workers of one pool on tasks of another is how - * two saturated pools deadlock each other (generation workers waiting for planning that waits for - * generation workers), and a ForkJoin worker keeps the planner's nested fan-out alive by helping - * with its own forks instead of waiting for them. - */ - private static boolean plansInline() { - return Thread.currentThread() instanceof ForkJoinWorkerThread; + private boolean plansInline() { + if (prefetchExecutor instanceof MultiBurst burst) { + return burst.ownsCurrentThread(); + } + return prefetchExecutor instanceof ForkJoinPool pool + && Thread.currentThread() instanceof ForkJoinWorkerThread worker + && worker.getPool() == pool; } private HydrologyTile planInline(HydrologyTileKey key) { @@ -116,8 +153,45 @@ public final class HydrologyTileCache implements AutoCloseable { * cached so the next request plans it again. */ private HydrologyTile planOrEmpty(HydrologyTileKey key) { + long planningEpoch = cacheEpoch.get(); + SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key); + HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey); + if (shared != null && validSharedTile(shared, sharedKey)) { + planner.reuseResolvedTile(shared); + IrisLogging.debug("Reused shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + return shared; + } + if (shared != null) { + SHARED_TILES.invalidate(sharedKey); + } + StudioHydrologyTileStore store = persistentStore; + if (store != null && persistentStudioKey(key)) { + HydrologyTile persisted = store.load(key).orElse(null); + if (persisted != null) { + SHARED_TILES.put(sharedKey, persisted); + planner.reuseResolvedTile(persisted); + IrisLogging.debug("Loaded persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + return persisted; + } + } try { - return planner.plan(key); + HydrologyTile planned = planner.plan(key); + if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { + HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned); + if (existing == null) { + IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + } + } + if (store != null && persistentStudioKey(key) && !closed.get() && planningEpoch == cacheEpoch.get()) { + try { + store.save(planned); + IrisLogging.debug("Persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + } catch (IOException failure) { + IrisLogging.reportError("Failed to persist Studio hydrology tile " + + key.tileX() + "," + key.tileZ() + ".", failure); + } + } + return planned; } catch (RuntimeException failure) { if (interrupted(failure)) { throw failure; @@ -205,6 +279,10 @@ public final class HydrologyTileCache implements AutoCloseable { public void clear() { cacheEpoch.incrementAndGet(); planner.clearOwnerDrafts(); + synchronized (prefetchQueue) { + prefetchQueue.clear(); + queuedPrefetches.clear(); + } tiles.invalidateAll(); composedChunks.invalidateAll(); localChunkColumns.remove(); @@ -219,11 +297,56 @@ public final class HydrologyTileCache implements AutoCloseable { return maximumEntries; } + public void setNeighbourPrefetchEnabled(boolean enabled) { + neighbourPrefetchEnabled = enabled; + } + + public void preparePregeneration(int centerBlockX, int centerBlockZ) { + neighbourPrefetchEnabled = true; + int tileSize = planner.settings().routing().tileSize(); + prefetchArea( + Math.subtractExact(centerBlockX, tileSize), + Math.subtractExact(centerBlockZ, tileSize), + Math.addExact(centerBlockX, tileSize), + Math.addExact(centerBlockZ, tileSize), + centerBlockX, + centerBlockZ + ); + } + + public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) { + if (closed.get()) { + throw new IllegalStateException("Hydrology tile cache is closed."); + } + sharedCacheScope = Objects.requireNonNull(scope, "scope"); + persistentStore = persistentRoot == null + ? null + : new StudioHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize()); + } + + private boolean persistentStudioKey(HydrologyTileKey key) { + int tileSize = planner.settings().routing().tileSize(); + int publicationRadius = planner.settings().publicationRadius(); + int minimumTile = Math.subtractExact(tileCoordinate(-(long) publicationRadius, tileSize), 1); + int maximumTile = Math.addExact(tileCoordinate(CHUNK_SIZE - 1L + publicationRadius, tileSize), 1); + return key.tileX() >= minimumTile && key.tileX() <= maximumTile + && key.tileZ() >= minimumTile && key.tileZ() <= maximumTile; + } + @Override public void close() { + closed.set(true); clear(); } + private boolean validSharedTile(HydrologyTile tile, SharedTileKey sharedKey) { + SharedCacheScope scope = sharedKey.scope(); + return tile.key().equals(sharedKey.tileKey()) + && tile.worldSeed() == scope.worldSeed() + && tile.settingsFingerprint() == scope.settingsFingerprint() + && tile.tileSize() == planner.settings().routing().tileSize(); + } + private ChunkColumns chunkColumns(int blockX, int blockZ) { int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); @@ -306,7 +429,7 @@ public final class HydrologyTileCache implements AutoCloseable { * needs before its prefetch finishes simply joins that computation. */ private void prefetchNeighbours(int minimumTileX, int maximumTileX, int minimumTileZ, int maximumTileZ) { - if (prefetchExecutor == null) { + if (prefetchExecutor == null || !neighbourPrefetchEnabled) { return; } for (int tileZ = minimumTileZ - 1; tileZ <= maximumTileZ + 1; tileZ++) { @@ -319,11 +442,7 @@ public final class HydrologyTileCache implements AutoCloseable { } } - /** - * Plans every tile that touches the block area, nearest to the centre first, on the prefetch - * executor. A pregeneration calls this once up front so its spiral never waits on a cold plan - * once the first tiles are in; nothing happens without a prefetch executor. - */ + /** Plans tiles that touch the block area in nearest-first order without flooding the planner. */ public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) { if (prefetchExecutor == null) { return; @@ -342,16 +461,70 @@ public final class HydrologyTileCache implements AutoCloseable { keys.add(new HydrologyTileKey(tileX, tileZ)); } } - keys.sort(java.util.Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX() - centreTileX), Math.abs(key.tileZ() - centreTileZ))) + keys.sort(Comparator.comparingInt((HydrologyTileKey key) -> Math.max(Math.abs(key.tileX() - centreTileX), Math.abs(key.tileZ() - centreTileZ))) .thenComparingInt(HydrologyTileKey::tileZ) .thenComparingInt(HydrologyTileKey::tileX)); for (HydrologyTileKey key : keys) { - prefetch(key); + if (!prefetch(key) && speculativeEntryCount() >= maximumEntries) { + break; + } } } - private void prefetch(HydrologyTileKey key) { - planAsync(key); + private boolean prefetch(HydrologyTileKey key) { + synchronized (prefetchQueue) { + if (closed.get() + || tiles.getIfPresent(key) != null + || planning.containsKey(key) + || queuedPrefetches.contains(key) + || speculativeEntryCount() >= maximumEntries) { + return false; + } + queuedPrefetches.add(key); + prefetchQueue.add(key); + } + pumpPrefetch(); + return true; + } + + private int speculativeEntryCount() { + tiles.cleanUp(); + return Math.toIntExact(Math.min( + maximumEntries, + tiles.estimatedSize() + planning.size() + queuedPrefetches.size() + )); + } + + private void pumpPrefetch() { + if (prefetchExecutor == null || closed.get() || !prefetchActive.compareAndSet(false, true)) { + return; + } + scheduleNextPrefetch(); + } + + private void scheduleNextPrefetch() { + HydrologyTileKey key = pollPrefetch(); + if (key == null || closed.get()) { + prefetchActive.set(false); + if (!prefetchQueue.isEmpty()) { + pumpPrefetch(); + } + return; + } + planAsync(key).whenComplete((tile, failure) -> scheduleNextPrefetch()); + } + + private HydrologyTileKey pollPrefetch() { + HydrologyTileKey key; + while ((key = prefetchQueue.poll()) != null) { + if (!queuedPrefetches.remove(key)) { + continue; + } + if (tiles.getIfPresent(key) == null) { + return key; + } + } + return null; } /** @@ -375,7 +548,7 @@ public final class HydrologyTileCache implements AutoCloseable { int end = Math.min(loaded.length, start + batch); futures.clear(); for (int index = start; index < end; index++) { - futures.add(planAsync(keys.get(index))); + futures.add(planDemandAsync(keys.get(index))); } for (int index = start; index < end; index++) { loaded[index] = awaitTile(futures.get(index - start)); @@ -416,6 +589,11 @@ public final class HydrologyTileCache implements AutoCloseable { return future; } + private CompletableFuture planDemandAsync(HydrologyTileKey key) { + queuedPrefetches.remove(key); + return planAsync(key); + } + private static HydrologyTile awaitTile(CompletableFuture future) { try { return future.join(); @@ -518,4 +696,27 @@ public final class HydrologyTileCache implements AutoCloseable { private record LocalChunkColumns(long epoch, ChunkColumns columns) { } + + public record SharedCacheScope( + String runtimeIdentity, + long worldSeed, + int worldHeight, + String dimensionKey, + long settingsFingerprint + ) { + public SharedCacheScope { + if (runtimeIdentity == null || runtimeIdentity.isBlank()) { + throw new IllegalArgumentException("Hydrology shared cache runtime identity is required."); + } + if (worldHeight < 3) { + throw new IllegalArgumentException("Hydrology shared cache world height must be at least three."); + } + if (dimensionKey == null || dimensionKey.isBlank()) { + throw new IllegalArgumentException("Hydrology shared cache dimension key is required."); + } + } + } + + private record SharedTileKey(SharedCacheScope scope, HydrologyTileKey tileKey) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java b/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java new file mode 100644 index 000000000..5b05d4a74 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java @@ -0,0 +1,375 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +final class StudioHydrologyTileStore { + private static final int SCHEMA_VERSION = 1; + private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L; + private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L; + private static final Gson GSON = new GsonBuilder() + .disableHtmlEscaping() + .serializeSpecialFloatingPointValues() + .create(); + + private final Path directory; + private final HydrologyTileCache.SharedCacheScope scope; + private final int expectedTileSize; + + StudioHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) { + this.scope = Objects.requireNonNull(scope, "scope"); + if (expectedTileSize < 1) { + throw new IllegalArgumentException("Hydrology tile size must be positive."); + } + this.expectedTileSize = expectedTileSize; + this.directory = Objects.requireNonNull(root, "root") + .toAbsolutePath() + .normalize() + .resolve(scopeFingerprint(scope)); + } + + Optional load(HydrologyTileKey key) { + Path file = file(key); + try { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(file) + || Files.size(file) > MAXIMUM_COMPRESSED_BYTES) { + return Optional.empty(); + } + } catch (IOException failure) { + return Optional.empty(); + } + try (InputStream raw = Files.newInputStream(file); + InputStream buffered = new BufferedInputStream(raw); + InputStream compressed = new GZIPInputStream(buffered); + InputStream bounded = new LimitedInputStream(compressed, MAXIMUM_DECOMPRESSED_BYTES); + Reader reader = new InputStreamReader(bounded, StandardCharsets.UTF_8)) { + PersistedTile persisted = GSON.fromJson(reader, PersistedTile.class); + if (persisted == null + || persisted.schemaVersion() != SCHEMA_VERSION + || !key.equals(persisted.key()) + || persisted.worldSeed() != scope.worldSeed() + || persisted.settingsFingerprint() != scope.settingsFingerprint()) { + return Optional.empty(); + } + HydrologyTile tile = persisted.toTile(); + return valid(tile, key) ? Optional.of(tile) : Optional.empty(); + } catch (IOException | RuntimeException failure) { + return Optional.empty(); + } + } + + void save(HydrologyTile tile) throws IOException { + if (!valid(tile, tile.key())) { + throw new IOException("Refused to persist a hydrology tile outside the active Studio cache scope."); + } + Files.createDirectories(directory); + Path target = file(tile.key()); + Path staged = Files.createTempFile(directory, ".hydrology-tile-", ".tmp"); + try { + try (OutputStream raw = Files.newOutputStream(staged); + OutputStream buffered = new BufferedOutputStream(raw); + OutputStream compressed = new GZIPOutputStream(buffered); + Writer writer = new OutputStreamWriter(compressed, StandardCharsets.UTF_8)) { + GSON.toJson(PersistedTile.from(tile), writer); + } + if (Files.size(staged) > MAXIMUM_COMPRESSED_BYTES) { + throw new IOException("Studio hydrology tile cache entry exceeds the size limit."); + } + try { + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(staged); + } + } + + Path file(HydrologyTileKey key) { + return directory.resolve("tile-" + key.tileX() + "-" + key.tileZ() + ".json.gz"); + } + + private boolean valid(HydrologyTile tile, HydrologyTileKey key) { + return tile != null + && key.equals(tile.key()) + && tile.worldSeed() == scope.worldSeed() + && tile.settingsFingerprint() == scope.settingsFingerprint() + && tile.tileSize() == expectedTileSize; + } + + private static String scopeFingerprint(HydrologyTileCache.SharedCacheScope scope) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + update(digest, scope.runtimeIdentity()); + update(digest, Long.toString(scope.worldSeed())); + update(digest, Integer.toString(scope.worldHeight())); + update(digest, scope.dimensionKey()); + update(digest, Long.toString(scope.settingsFingerprint())); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + private static void update(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); + digest.update(bytes); + } + + private record PersistedTile( + int schemaVersion, + HydrologyTileKey key, + long worldSeed, + long settingsFingerprint, + int tileSize, + List nodes, + List edges, + List outlets, + List courses, + List cavePlans, + List diagnosticCandidates, + List columns + ) { + private static PersistedTile from(HydrologyTile tile) { + ArrayList courses = new ArrayList<>(tile.courses().size()); + for (RiverCourse course : tile.courses()) { + courses.add(PersistedCourse.from(course)); + } + ArrayList cavePlans = new ArrayList<>(tile.cavePlans().size()); + for (HydrologyCavePlan cavePlan : tile.cavePlans()) { + cavePlans.add(PersistedCavePlan.from(cavePlan)); + } + return new PersistedTile( + SCHEMA_VERSION, + tile.key(), + tile.worldSeed(), + tile.settingsFingerprint(), + tile.tileSize(), + tile.nodes(), + tile.edges(), + tile.outlets(), + courses, + cavePlans, + tile.diagnosticCandidates(), + new ArrayList<>(tile.footprint().columns().values()) + ); + } + + private HydrologyTile toTile() { + requireCollection(nodes, "nodes"); + requireCollection(edges, "edges"); + requireCollection(outlets, "outlets"); + requireCollection(courses, "courses"); + requireCollection(cavePlans, "cavePlans"); + requireCollection(diagnosticCandidates, "diagnosticCandidates"); + requireCollection(columns, "columns"); + ArrayList restoredCourses = new ArrayList<>(courses.size()); + for (PersistedCourse course : courses) { + restoredCourses.add(Objects.requireNonNull(course, "course").toCourse()); + } + ArrayList restoredCavePlans = new ArrayList<>(cavePlans.size()); + for (PersistedCavePlan cavePlan : cavePlans) { + restoredCavePlans.add(Objects.requireNonNull(cavePlan, "cavePlan").toPlan()); + } + HashMap restoredColumns = HashMap.newHashMap(columns.size()); + for (HydrologyColumnSample column : columns) { + HydrologyColumnSample required = Objects.requireNonNull(column, "column"); + HydrologyColumnSample existing = restoredColumns.put( + RiverFootprint.pack(required.x(), required.z()), required); + if (existing != null) { + throw new IllegalArgumentException("Duplicate hydrology footprint column."); + } + } + return new HydrologyTile( + key, + worldSeed, + settingsFingerprint, + tileSize, + nodes, + edges, + outlets, + restoredCourses, + restoredCavePlans, + diagnosticCandidates, + new RiverFootprint(restoredColumns) + ); + } + + private static void requireCollection(List values, String name) { + Objects.requireNonNull(values, name); + } + } + + private record PersistedCourse( + long id, + RiverCourseType type, + Long sourceNodeId, + Long outletId, + String profileKey, + int discharge, + List drainageEdges, + List segments + ) { + private static PersistedCourse from(RiverCourse course) { + return new PersistedCourse( + course.id(), + course.type(), + course.sourceNodeId().isPresent() ? course.sourceNodeId().getAsLong() : null, + course.outletId().isPresent() ? course.outletId().getAsLong() : null, + course.profileKey(), + course.discharge(), + course.drainageEdges(), + course.segments() + ); + } + + private RiverCourse toCourse() { + return new RiverCourse( + id, + type, + sourceNodeId == null ? OptionalLong.empty() : OptionalLong.of(sourceNodeId), + outletId == null ? OptionalLong.empty() : OptionalLong.of(outletId), + profileKey, + discharge, + drainageEdges, + segments + ); + } + } + + private record PersistedCavePlan( + HydrologyCaveSource source, + HydrologyCaveRejection rejection, + List cells, + Long arbitrationWinnerSourceId + ) { + private static PersistedCavePlan from(HydrologyCavePlan plan) { + ArrayList cells = new ArrayList<>(plan.baselinePreconditions().size()); + plan.forEachPrecondition((CavePosition position, CaveVoxelPrecondition precondition) -> cells.add( + new PersistedCaveCell(position, plan.actions().get(position), precondition))); + cells.sort(Comparator.comparingInt((PersistedCaveCell cell) -> cell.position().x()) + .thenComparingInt(cell -> cell.position().z()) + .thenComparingInt(cell -> cell.position().y())); + return new PersistedCavePlan( + plan.source(), + plan.rejection(), + cells, + plan.arbitrationWinnerSourceId().isPresent() + ? plan.arbitrationWinnerSourceId().getAsLong() + : null + ); + } + + private HydrologyCavePlan toPlan() { + LinkedHashMap actions = new LinkedHashMap<>(); + LinkedHashMap preconditions = new LinkedHashMap<>(); + for (PersistedCaveCell cell : Objects.requireNonNull(cells, "cells")) { + PersistedCaveCell required = Objects.requireNonNull(cell, "cell"); + CaveVoxelPrecondition existing = preconditions.put(required.position(), required.precondition()); + if (existing != null) { + throw new IllegalArgumentException("Duplicate hydrology cave cell."); + } + if (required.action() != null) { + actions.put(required.position(), required.action()); + } + } + return new HydrologyCavePlan( + source, + rejection, + actions, + preconditions, + arbitrationWinnerSourceId == null + ? OptionalLong.empty() + : OptionalLong.of(arbitrationWinnerSourceId) + ); + } + } + + private record PersistedCaveCell( + CavePosition position, + HydrologyCaveAction action, + CaveVoxelPrecondition precondition + ) { + private PersistedCaveCell { + Objects.requireNonNull(position, "position"); + Objects.requireNonNull(precondition, "precondition"); + } + } + + private static final class LimitedInputStream extends FilterInputStream { + private final long limit; + private long count; + + private LimitedInputStream(InputStream input, long limit) { + super(input); + this.limit = limit; + } + + @Override + public int read() throws IOException { + int value = super.read(); + if (value >= 0) { + advance(1L); + } + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + int read = super.read(bytes, offset, length); + if (read > 0) { + advance(read); + } + return read; + } + + private void advance(long amount) throws IOException { + count += amount; + if (count > limit) { + throw new IOException("Studio hydrology tile cache entry exceeds the decompressed size limit."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index 3b5400bee..63815306e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -111,6 +111,11 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return basis.terrain().withSlope(localSlope(blockX, blockZ, basis.naturalHeight())); } + @Override + public HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) { + return basis(blockX, blockZ).terrain(); + } + TerrainBasis basis(int blockX, int blockZ) { long packed = pack(blockX, blockZ); synchronized (lock) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index 530d73548..448f8407a 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -69,6 +69,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.nio.file.Path; import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; @@ -168,11 +169,28 @@ public final class IrisHydrologyRuntime implements AutoCloseable { return cache.columnAt(blockX, blockZ); } - /** Plans every tile touching the block area ahead of time, nearest the centre first. */ + /** Plans a bounded window of tiles touching the block area ahead of time, nearest the centre first. */ public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) { cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ); } + public void preparePregeneration(int centerBlockX, int centerBlockZ) { + cache.preparePregeneration(centerBlockX, centerBlockZ); + } + + public void setNeighbourPrefetchEnabled(boolean enabled) { + cache.setNeighbourPrefetchEnabled(enabled); + } + + public void enableSharedCache(String runtimeIdentity, Path persistentRoot) { + cache.enableSharedCache(new HydrologyTileCache.SharedCacheScope( + runtimeIdentity, + context.seed(), + context.worldHeight(), + context.dimension().getLoadKey(), + settings.fingerprint()), persistentRoot); + } + public void prepareChunkColumns(int blockX, int blockZ) { cache.prepareChunkColumns(blockX, blockZ); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 880c9a432..79c73eb3c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -447,6 +447,7 @@ public class IrisCaveCarver3D { int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnTopY = scratch.activeColumnTopY; int activeColumnCount = 0; + int activeMaxY = Integer.MIN_VALUE; for (int index = 0; index < 256; index++) { double columnWeight = clampedWeights[index]; @@ -458,6 +459,7 @@ public class IrisCaveCarver3D { passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); activeColumnIndices[activeColumnCount] = index; activeColumnTopY[activeColumnCount] = columnMaxY[index]; + activeMaxY = Math.max(activeMaxY, columnMaxY[index]); activeColumnCount++; } @@ -469,11 +471,12 @@ public class IrisCaveCarver3D { double[] planeThresholdLimit = scratch.planeThresholdLimit; boolean[] planeCarve = scratch.planeCarve; int minSection = PowerOfTwoCoordinates.floorDivPow2(minY, 4); - int maxSection = PowerOfTwoCoordinates.floorDivPow2(maxY, 4); + int passMaxY = Math.min(maxY, activeMaxY); + int maxSection = PowerOfTwoCoordinates.floorDivPow2(passMaxY, 4); for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex)); - int sectionMaxY = Math.min(maxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); + int sectionMaxY = Math.min(passMaxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); MatterSlice cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex); for (int y = sectionMinY; y <= sectionMaxY; y++) { @@ -579,6 +582,7 @@ public class IrisCaveCarver3D { int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnTopY = scratch.activeColumnTopY; int activeColumnCount = 0; + int activeMaxY = Integer.MIN_VALUE; for (int index = 0; index < 256; index++) { double columnWeight = clampedWeights[index]; @@ -590,6 +594,7 @@ public class IrisCaveCarver3D { passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); activeColumnIndices[activeColumnCount] = index; activeColumnTopY[activeColumnCount] = columnMaxY[index]; + activeMaxY = Math.max(activeMaxY, columnMaxY[index]); activeColumnCount++; } @@ -601,7 +606,8 @@ public class IrisCaveCarver3D { double[] planeThresholdLimit = scratch.planeThresholdLimit; boolean[] planeCarve = scratch.planeCarve; int minSection = PowerOfTwoCoordinates.floorDivPow2(minY, 4); - int maxSection = PowerOfTwoCoordinates.floorDivPow2(maxY, 4); + int passMaxY = Math.min(maxY, activeMaxY); + int maxSection = PowerOfTwoCoordinates.floorDivPow2(passMaxY, 4); int effectiveAdaptiveSampleStep = Math.max(adaptiveSampleStep, ADAPTIVE_DEEP_SAMPLE_STEP); double effectiveAdaptiveThresholdMargin = resolveAdaptivePlaneThresholdMargin( adaptiveThresholdMargin, @@ -611,7 +617,7 @@ public class IrisCaveCarver3D { for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex)); - int sectionMaxY = Math.min(maxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); + int sectionMaxY = Math.min(passMaxY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex) + 15); MatterSlice cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex); for (int y = sectionMinY; y <= sectionMaxY; y++) { @@ -1459,6 +1465,41 @@ public class IrisCaveCarver3D { } } + private void classifyDeepAdaptivePlaneFromSamples( + CaveCarveScratch scratch, + int[] planeColumnIndices, + double[] planeThresholdLimit, + int planeCount, + boolean[] planeCarve, + int adaptiveSampleStep, + double[] adaptivePlaneDensity, + int axisCells, + int axisSamples + ) { + prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisSamples); + int[] adaptiveCellZ = scratch.adaptiveCellZ; + int[] adaptiveRow0 = scratch.adaptiveRow0; + int[] adaptiveRow1 = scratch.adaptiveRow1; + double[] adaptiveTx = scratch.adaptiveTx; + double[] adaptiveTz = scratch.adaptiveTz; + for (int planeIndex = 0; planeIndex < planeCount; planeIndex++) { + int columnIndex = planeColumnIndices[planeIndex]; + int cellZ = adaptiveCellZ[columnIndex]; + int row0 = adaptiveRow0[columnIndex]; + int row1 = adaptiveRow1[columnIndex]; + double tx = adaptiveTx[columnIndex]; + double tz = adaptiveTz[columnIndex]; + double d00 = adaptivePlaneDensity[row0 + cellZ]; + double d01 = adaptivePlaneDensity[row0 + cellZ + 1]; + double d10 = adaptivePlaneDensity[row1 + cellZ]; + double d11 = adaptivePlaneDensity[row1 + cellZ + 1]; + double dx0 = d00 + ((d10 - d00) * tx); + double dx1 = d01 + ((d11 - d01) * tx); + double predictedDensity = dx0 + ((dx1 - dx0) * tz); + planeCarve[planeIndex] = predictedDensity <= planeThresholdLimit[planeIndex] * inverseNormalization; + } + } + private void classifyAdaptivePlaneColumnsWarpOnly( CaveCarveScratch scratch, int x0, @@ -1615,6 +1656,21 @@ public class IrisCaveCarver3D { double[] remainingMin, double[] remainingMax ) { + if (adaptiveSampleStep >= ADAPTIVE_DEEP_SAMPLE_STEP) { + classifyDeepAdaptivePlaneFromSamples( + scratch, + planeColumnIndices, + planeThresholdLimit, + planeCount, + planeCarve, + adaptiveSampleStep, + adaptivePlaneDensity, + axisCells, + axisSamples + ); + return; + } + double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; prepareAdaptivePlaneColumns( @@ -2235,17 +2291,29 @@ public class IrisCaveCarver3D { } int support = 0; + int misses = 0; if (isDensitySolid(scratch, x + 1, y, z, threshold)) { support++; + } else { + misses++; } if (isDensitySolid(scratch, x - 1, y, z, threshold)) { support++; + } else if (++misses >= 2) { + return false; } if (isDensitySolid(scratch, x, y, z + 1, threshold)) { support++; + } else if (++misses >= 2) { + return false; } if (isDensitySolid(scratch, x, y, z - 1, threshold)) { support++; + if (support >= 4) { + return true; + } + } else if (++misses >= 2) { + return false; } if (isDensitySolid(scratch, x, aboveY, z, threshold)) { support++; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 39cb22e04..81320574d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -620,6 +620,9 @@ public class IrisDimension extends IrisRegistrant { if (biome == null) { continue; } + if (biome.isCompatExcluded()) { + continue; + } String loadKey = biome.getLoadKey(); if (loadKey == null || loadKey.isBlank() || biomes.containsKey(loadKey)) { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 2b6f39d5e..67fca364d 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -240,23 +240,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return new Location(world, 0.5D, y, 0.5D); } - private int hydrologyTileSize() { - Engine activeEngine = engine; - if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) { - return 0; - } - return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize(); - } - - /** - * Starts planning the hydrology tiles around the initial spawn as soon as the generator is injected - * into a world whose spawn chunk does not exist yet. The first teleport there makes the chunk system - * generate chunks a few hundred blocks around the spawn, and each of those chunks' mantle windows - * reaches further still, so the area planned ahead is half a tile in every direction: every tile a - * cold entry can touch starts planning at once instead of one generation thread at a time finding - * it cold. A world that already generated its spawn (every existing world at boot, a hotloaded live - * world) skips it. - */ + /** Starts nearest-first hydrology planning around a new world's initial spawn. */ private void prefetchSpawnHydrology(Engine engine, World world) { if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { return; @@ -278,6 +262,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun ); } + private int hydrologyTileSize() { + Engine activeEngine = engine; + if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) { + return 0; + } + return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize(); + } + private void updateSpawnLocation(World world) { try { Location initialSpawn = getInitialSpawnLocation(world); diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java index bd7971a00..5ac1e2ede 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java @@ -62,6 +62,10 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu return cache.getMaxSize(); } + public void setMaximumChunks(int maximumChunks) { + cache.setMaximumChunks(maximumChunks); + } + @Override public boolean isClosed() { return engine.isClosed(); diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java index 2adfd8f2e..94955198a 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/IrisPregeneratorInitTest.java @@ -9,6 +9,9 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -41,6 +44,32 @@ public class IrisPregeneratorInitTest { } } + @Test + public void pregenPreparationRunsBeforeTheFirstChunkSubmission() { + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + TrackingPregeneratorMethod method = new TrackingPregeneratorMethod(); + PregenTask task = PregenTask.builder() + .center(new Position2(128, 128)) + .radiusX(1) + .radiusZ(1) + .build(); + try { + IrisPregenerator pregenerator = new IrisPregenerator(task, method, new NoOpPregenListener()); + + pregenerator.start(); + + int boundsIndex = method.lifecycle.indexOf("bounds"); + int startIndex = method.lifecycle.indexOf("start:128,128"); + int chunkIndex = method.lifecycle.indexOf("chunk"); + assertTrue(boundsIndex >= 0); + assertTrue(startIndex > boundsIndex); + assertTrue(chunkIndex > startIndex); + } finally { + IrisSettings.settings = previousSettings; + } + } + @Test public void completionSummaryWaitsForAsyncCloseDrain() { String output = runCompletionOnClose(false); @@ -84,6 +113,33 @@ public class IrisPregeneratorInitTest { } } + @Test + public void shutdownConsumesCancellationInterruptBetweenCleanupSteps() { + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + AtomicBoolean listenerObservedInterrupt = new AtomicBoolean(); + TrackingPregeneratorMethod method = new TrackingPregeneratorMethod(true); + PregenTask task = PregenTask.builder() + .center(new Position2(0, 0)) + .radiusX(1) + .radiusZ(1) + .build(); + try { + IrisPregenerator pregenerator = new IrisPregenerator( + task, + method, + new NoOpPregenListener(listenerObservedInterrupt)); + + pregenerator.start(); + + assertFalse(listenerObservedInterrupt.get()); + assertFalse(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + IrisSettings.settings = previousSettings; + } + } + private String runCompletionOnClose(boolean failLast) { IrisSettings previousSettings = IrisSettings.settings; PrintStream previousOut = System.out; @@ -111,6 +167,16 @@ public class IrisPregeneratorInitTest { private static final class TrackingPregeneratorMethod implements PregeneratorMethod { private final AtomicInteger initCalls = new AtomicInteger(); private final AtomicInteger saveCalls = new AtomicInteger(); + private final List lifecycle = new ArrayList<>(); + private final boolean interruptOnClose; + + private TrackingPregeneratorMethod() { + this(false); + } + + private TrackingPregeneratorMethod(boolean interruptOnClose) { + this.interruptOnClose = interruptOnClose; + } @Override public void init() { @@ -119,6 +185,9 @@ public class IrisPregeneratorInitTest { @Override public void close() { + if (interruptOnClose) { + Thread.currentThread().interrupt(); + } } @Override @@ -142,6 +211,17 @@ public class IrisPregeneratorInitTest { @Override public void generateChunk(int x, int z, PregenListener listener) { + lifecycle.add("chunk"); + } + + @Override + public void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { + lifecycle.add("bounds"); + } + + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { + lifecycle.add("start:" + centerBlockX + "," + centerBlockZ); } @Override @@ -254,6 +334,16 @@ public class IrisPregeneratorInitTest { } private static final class NoOpPregenListener implements PregenListener { + private final AtomicBoolean closeInterrupted; + + private NoOpPregenListener() { + this(null); + } + + private NoOpPregenListener(AtomicBoolean closeInterrupted) { + this.closeInterrupted = closeInterrupted; + } + @Override public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) { } @@ -304,6 +394,9 @@ public class IrisPregeneratorInitTest { @Override public void onClose() { + if (closeInterrupted != null) { + closeInterrupted.set(Thread.currentThread().isInterrupted()); + } } @Override diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java index 454464481..3e43c9d0b 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenPerformanceProfileTest.java @@ -5,19 +5,20 @@ import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream2D; import org.junit.Test; -import static org.junit.Assert.assertFalse; -import art.arcane.iris.util.common.parallel.MultiBurst; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; 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; @@ -84,15 +85,47 @@ public class PregenPerformanceProfileTest { } } + @Test + public void liveHeightCacheGrowsWithoutRebuildingTheEngine() { + IrisSettings previousSettings = IrisSettings.settings; + String previousFastCache = System.getProperty("iris.cache.fast"); + IrisSettings.settings = new IrisSettings(); + System.setProperty("iris.cache.fast", "true"); + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + CachedDoubleStream2D heightStream = mock(CachedDoubleStream2D.class); + CachedDoubleStream2D naturalHeightStream = mock(CachedDoubleStream2D.class); + CachedStream2D caveBiomeStream = mock(CachedStream2D.class); + PlatformChunkGenerator generator = generatorFor(engine); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHeightStream()).thenReturn(heightStream); + when(complex.getNaturalHeightStream()).thenReturn(naturalHeightStream); + when(complex.getCaveBiomeStream()).thenReturn(caveBiomeStream); + when(caveBiomeStream.usesFastCache()).thenReturn(true); + try { + PregenPerformanceProfile.applyToGenerator(generator); + + verify(heightStream, times(1)).setMaximumChunks(4_096); + verify(naturalHeightStream, times(1)).setMaximumChunks(4_096); + verify(generator, never()).hotloadComplexAsync(30L, TimeUnit.SECONDS); + verify(engine, never()).hotloadComplex(); + } finally { + restore(previousSettings, previousFastCache); + } + } + @SuppressWarnings("unchecked") private static Engine engineWithProfile(int cacheSize, boolean fastCache) { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); CachedDoubleStream2D heightStream = mock(CachedDoubleStream2D.class); + CachedDoubleStream2D naturalHeightStream = mock(CachedDoubleStream2D.class); CachedStream2D caveBiomeStream = mock(CachedStream2D.class); when(heightStream.getMaxSize()).thenReturn((long) cacheSize * 256L); + when(naturalHeightStream.getMaxSize()).thenReturn((long) cacheSize * 256L); when(caveBiomeStream.usesFastCache()).thenReturn(fastCache); when(complex.getHeightStream()).thenReturn(heightStream); + when(complex.getNaturalHeightStream()).thenReturn(naturalHeightStream); when(complex.getCaveBiomeStream()).thenReturn(caveBiomeStream); when(engine.getComplex()).thenReturn(complex); return engine; diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java index 8b84e49f3..12197b78d 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskLatticeOrderTest.java @@ -13,8 +13,8 @@ import static org.junit.Assert.assertTrue; public class PregenTaskLatticeOrderTest { @Test public void regionOrderVisitsEveryChunkOnceInStrideSpacedLattices() { - KList order = new KList<>(); - PregenTask.iterateRegion(0, 0, (x, z) -> order.add(new Position2(x, z))); + KList pulled = regionChunks(); + KList order = PregenTask.latticeOrder(pulled); assertEquals(1024, order.size()); Set unique = new HashSet<>(); @@ -47,4 +47,40 @@ public class PregenTaskLatticeOrderTest { assertTrue("consecutive chunks " + previous + " and " + current, distance >= PregenTask.CHUNK_LATTICE_STRIDE); } } + + @Test + public void centerRegionStartsAtThePullAndVisitsEveryChunk() { + KList order = new KList<>(); + PregenTask.iterateRegion(0, 0, (x, z) -> order.add(new Position2(x, z))); + + assertEquals(1024, order.size()); + assertEquals(new Position2(0, 0), order.get(0)); + Set unique = new HashSet<>(); + for (Position2 position : order) { + assertTrue(unique.add(((long) position.getX() << 32) ^ (position.getZ() & 0xffffffffL))); + } + } + + @Test + public void taskCenterDeterminesTheContiguousRegionPull() { + PregenTask task = PregenTask.builder() + .center(new Position2(1_616, 80)) + .radiusX(64) + .radiusZ(64) + .build(); + KList order = new KList<>(); + task.iterateChunks(3, 0, (x, z) -> order.add(new Position2(x, z))); + + assertEquals(new Position2(101, 5), order.get(0)); + } + + private KList regionChunks() { + KList chunks = new KList<>(); + for (int x = 0; x < 32; x++) { + for (int z = 0; z < 32; z++) { + chunks.add(new Position2(x, z)); + } + } + return chunks; + } } diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java index 011c02bd7..a95d0209e 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethodConcurrencyCapTest.java @@ -56,6 +56,21 @@ public class AsyncPregenMethodConcurrencyCapTest { assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(0, false)); } + @Test + public void coldStartMatchesAvailableWorkersBeforeAdaptiveGrowth() { + assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(1, 16)); + assertEquals(4, AsyncPregenMethod.computeInitialInFlightLimit(128, 1)); + assertEquals(16, AsyncPregenMethod.computeInitialInFlightLimit(128, 16)); + assertEquals(6, AsyncPregenMethod.computeInitialInFlightLimit(6, 16)); + } + + @Test + public void adaptiveRecoveryGrowsWithoutSubmittingColdWaves() { + assertEquals(17, AsyncPregenMethod.nextAdaptiveInFlightLimit(16, 128)); + assertEquals(128, AsyncPregenMethod.nextAdaptiveInFlightLimit(128, 128)); + assertEquals(1, AsyncPregenMethod.nextAdaptiveInFlightLimit(0, 0)); + } + @Test public void slowRequestObservationDoesNotCompleteOrReplacePendingFuture() { CompletableFuture request = new CompletableFuture<>(); diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java index aaeb3eb87..f079cc7d0 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/CachedPregenMethodCompletionTest.java @@ -8,8 +8,10 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.Position2; import org.junit.Before; import org.junit.Test; +import org.bukkit.World; import java.io.File; +import java.lang.reflect.Constructor; import java.nio.file.Files; import java.util.ArrayList; import java.util.List; @@ -92,9 +94,31 @@ public class CachedPregenMethodCompletionTest { assertEquals(expected.size(), listener.generatedCached.get()); } + @Test + public void pregenStartReachesTheSelectedMethodThroughEveryWrapper() throws Exception { + Constructor selectorConstructor = AsyncOrMedievalPregenMethod.class + .getDeclaredConstructor(PregeneratorMethod.class); + selectorConstructor.setAccessible(true); + AsyncOrMedievalPregenMethod selector = selectorConstructor.newInstance(underlying); + Constructor hybridConstructor = HybridPregenMethod.class + .getDeclaredConstructor(World.class, PregeneratorMethod.class); + hybridConstructor.setAccessible(true); + HybridPregenMethod hybrid = hybridConstructor.newInstance(null, selector); + CachedPregenMethod wrapped = new CachedPregenMethod(hybrid, cache, task); + + wrapped.onPregenStart(123, -456); + + assertEquals(1, underlying.pregenStartCalls.get()); + assertEquals(123, underlying.centerBlockX.get()); + assertEquals(-456, underlying.centerBlockZ.get()); + } + private static final class CapturingMethod implements PregeneratorMethod { private final AtomicInteger generateChunkCalls = new AtomicInteger(); private final AtomicInteger generateRegionCalls = new AtomicInteger(); + private final AtomicInteger pregenStartCalls = new AtomicInteger(); + private final AtomicInteger centerBlockX = new AtomicInteger(); + private final AtomicInteger centerBlockZ = new AtomicInteger(); private final AtomicReference capturedListener = new AtomicReference<>(); @Override @@ -130,6 +154,13 @@ public class CachedPregenMethodCompletionTest { capturedListener.set(listener); } + @Override + public void onPregenStart(int centerBlockX, int centerBlockZ) { + pregenStartCalls.incrementAndGet(); + this.centerBlockX.set(centerBlockX); + this.centerBlockZ.set(centerBlockZ); + } + @Override public Mantle getMantle() { return null; diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java index 0276a65a6..fc4d18c93 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java @@ -33,6 +33,69 @@ public class PregenMantleCleanupContractTest { assertFalse(cleanup.contains("forceCleanupChunk")); } + @Test + public void finalChunkFlushUsesTargetedPersistenceWithoutFullWorldSave() throws IOException { + String source = read("art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java"); + String flush = method(source, "private void flushAllRemainingChunks()"); + String medievalSource = read("art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java"); + String medievalFlush = method(medievalSource, "private void unloadAndSaveAllChunks("); + + assertTrue(flush.contains("evictRegion(regionKey)")); + assertTrue(flush.contains("INMS.get().flushChunkIO(world)")); + assertFalse(flush.contains("world.save()")); + assertTrue(medievalFlush.contains("i.unload(true)")); + assertTrue(medievalFlush.contains("INMS.get().flushChunkIO(world)")); + assertFalse(medievalFlush.contains("world.save()")); + } + + @Test + public void foliaFinalEvictionUsesAwaitableRegionOwnedTasks() throws IOException { + String source = read("art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java"); + String eviction = method(source, "private CompletableFuture evictFoliaRegion("); + + assertTrue(eviction.contains("J.runRegionFuture(")); + assertTrue(eviction.contains("CompletableFuture.allOf(")); + assertFalse(eviction.contains("J.runRegion(")); + } + + @Test + public void shutdownReclaimsMantleBeforeReleasingTheJob() throws IOException { + String source = read("art/arcane/iris/core/pregenerator/IrisPregenerator.java"); + String shutdown = method(source, "private void shutdown()"); + int generator = shutdown.indexOf("shutdownStep(\"generator\""); + int interruptBoundary = shutdown.indexOf("Thread.interrupted()", generator); + int mantle = shutdown.indexOf("shutdownStep(\"mantle\""); + int protocol = shutdown.indexOf("shutdownStep(\"protocol\""); + int listener = shutdown.indexOf("shutdownStep(\"listener\""); + + assertTrue(generator >= 0); + assertTrue(interruptBoundary > generator); + assertTrue(mantle > interruptBoundary); + assertTrue(protocol > mantle); + assertTrue(listener > protocol); + } + + @Test + public void directStopDoesNotReleaseTheActiveJobBeforeWorkerCleanup() throws IOException { + String source = read("art/arcane/iris/core/gui/PregeneratorJob.java"); + String stop = method(source, "public void stop()"); + String close = method(source, "public void onClose()"); + + assertTrue(stop.contains("requestStop()")); + assertFalse(stop.contains("instance.compareAndSet")); + assertTrue(close.contains("instance.compareAndSet(this, null)")); + } + + @Test + public void performanceProfilePreparationRunsOnThePregenWorker() throws IOException { + String source = read("art/arcane/iris/core/gui/PregeneratorJob.java"); + String constructor = method(source, "public PregeneratorJob(Configuration configuration)"); + String worker = method(source, "private void runWorker(Runnable preparation)"); + + assertTrue(constructor.contains("runWorker(configuration.preparation())")); + assertBefore(worker, "preparation.run()", "pregenerator.start()"); + } + private static String read(String relativePath) throws IOException { Path current; try { @@ -71,4 +134,11 @@ public class PregenMantleCleanupContractTest { } throw new IllegalArgumentException("Unclosed source contract method: " + signature); } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue(firstIndex >= 0); + assertTrue(secondIndex > firstIndex); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java new file mode 100644 index 000000000..a1b748297 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java @@ -0,0 +1,22 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisComplexStudioCacheTest { + @Test + public void studioUsesWideNoiseCacheWithoutChangingConfiguredLiveWorldSize() { + Engine engine = mock(Engine.class); + + when(engine.isStudio()).thenReturn(false); + assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024)); + + when(engine.isStudio()).thenReturn(true); + assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024)); + assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java index 2a908b337..cd8ae2ad2 100644 --- a/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/SharedCornerBoundsTest.java @@ -86,4 +86,5 @@ public class SharedCornerBoundsTest { } assertFalse(torn.get()); } + } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java index 309a268e6..2ebec9b66 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -1014,14 +1014,20 @@ public class HydrologyFootprintCompilerTest { List.of(surface, cave) ); HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + AtomicInteger terrainSamples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + terrainSamples.incrementAndGet(); + return terrain; + }; HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler( HydrologyPlannerSettings.defaults(), - (int x, int z) -> terrain, + sampler, request -> request.minimum() ); HydrologyFootprintCompiler.ValidationRaster first = compiler.compileValidation(List.of(course)); HydrologyFootprintCompiler.ValidationRaster retry = compiler.compileValidation(List.of(course)); + int validationSamples = terrainSamples.get(); assertEquals(0, compiler.fullMaterializationCount()); assertEquals(first.columns(), retry.columns()); @@ -1030,8 +1036,15 @@ public class HydrologyFootprintCompilerTest { ).anyMatch((HydrologyColumnLayer layer) -> layer.feature().segmentId() == cave.id())); RiverFootprint footprint = compiler.compile(List.of(course)); + RiverFootprint freshFootprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + (int x, int z) -> terrain, + request -> request.minimum() + ).compile(List.of(course)); assertEquals(1, compiler.fullMaterializationCount()); + assertEquals(validationSamples, terrainSamples.get()); + assertEquals(freshFootprint, footprint); for (HydrologyColumnSample sample : footprint.columns().values()) { assertEquals( "planned surface at " + sample.x() + "," + sample.z(), diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java index eb9699da5..d2b2b6144 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -1860,6 +1860,33 @@ public class HydrologyPlannerTest { assertEquals(firstOne, secondOne); } + @Test + public void reusedFinalTilesPreserveAdjacentPlanOutput() { + HydrologyPlannerSettings settings = standardSettings(3D, 2D, true, false, List.of()); + HydrologyTerrainSampler terrain = rollingCoast(112); + HydrologyPlanner baselinePlanner = new HydrologyPlanner(99122L, settings, terrain); + List reusedKeys = List.of( + new HydrologyTileKey(-1, -1), + new HydrologyTileKey(0, -1), + new HydrologyTileKey(-1, 0), + new HydrologyTileKey(0, 0) + ); + ArrayList reusedTiles = new ArrayList<>(reusedKeys.size()); + for (HydrologyTileKey key : reusedKeys) { + reusedTiles.add(baselinePlanner.plan(key)); + } + HydrologyTileKey adjacentKey = new HydrologyTileKey(1, 0); + HydrologyTile baseline = baselinePlanner.plan(adjacentKey); + HydrologyPlanner reusedPlanner = new HydrologyPlanner(99122L, settings, terrain); + for (HydrologyTile tile : reusedTiles) { + reusedPlanner.reuseResolvedTile(tile); + } + + HydrologyTile reused = reusedPlanner.plan(adjacentKey); + + assertEquals(baseline, reused); + } + private HydrologyPlanner planner( long seed, HydrologyPlannerSettings settings, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index 94b540160..50df39205 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.util.common.parallel.MultiBurst; import org.junit.Test; import java.util.ArrayList; @@ -7,14 +8,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; 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; @@ -26,9 +30,93 @@ 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.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Test + public void equivalentStudioRuntimesReuseCompletedTiles() { + HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class); + HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(2, -3); + HydrologyTileCache.SharedCacheScope scope = sharedScope(); + when(firstPlanner.plan(key)).thenReturn(tile); + when(secondPlanner.settings()).thenReturn(emptySettings()); + when(tile.key()).thenReturn(key); + when(tile.worldSeed()).thenReturn(scope.worldSeed()); + when(tile.settingsFingerprint()).thenReturn(scope.settingsFingerprint()); + when(tile.tileSize()).thenReturn(64); + HydrologyTileCache first = new HydrologyTileCache(firstPlanner, 4, null, null, scope); + HydrologyTileCache second = new HydrologyTileCache(secondPlanner, 4, null, null, scope); + + assertSame(tile, first.get(key)); + assertSame(tile, second.get(key)); + + verify(firstPlanner, times(1)).plan(key); + verify(secondPlanner, never()).plan(key); + verify(secondPlanner, times(1)).reuseResolvedTile(tile); + } + + @Test + public void failedPlansDoNotEnterTheSharedStudioCache() { + HydrologyPlanner failingPlanner = mock(HydrologyPlanner.class); + HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class); + HydrologyTile emptyTile = mock(HydrologyTile.class); + HydrologyTile plannedTile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(-4, 5); + HydrologyTileCache.SharedCacheScope scope = sharedScope(); + when(failingPlanner.plan(key)).thenThrow(new IllegalStateException("test failure")); + when(failingPlanner.emptyTile(key)).thenReturn(emptyTile); + when(succeedingPlanner.plan(key)).thenReturn(plannedTile); + HydrologyTileCache first = new HydrologyTileCache(failingPlanner, 4, null, null, scope); + HydrologyTileCache second = new HydrologyTileCache(succeedingPlanner, 4, null, null, scope); + + assertSame(emptyTile, first.get(key)); + assertSame(plannedTile, second.get(key)); + + verify(succeedingPlanner, times(1)).plan(key); + } + + @Test + public void planCompletingAfterCloseDoesNotEnterTheSharedStudioCache() throws Exception { + HydrologyPlanner closingPlanner = mock(HydrologyPlanner.class); + HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class); + HydrologyTile lateTile = mock(HydrologyTile.class); + HydrologyTile plannedTile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(6, -2); + HydrologyTileCache.SharedCacheScope scope = sharedScope(); + CountDownLatch planningStarted = new CountDownLatch(1); + CountDownLatch allowCompletion = new CountDownLatch(1); + when(closingPlanner.plan(key)).thenAnswer(invocation -> { + planningStarted.countDown(); + assertTrue(allowCompletion.await(10L, TimeUnit.SECONDS)); + return lateTile; + }); + when(succeedingPlanner.plan(key)).thenReturn(plannedTile); + when(succeedingPlanner.settings()).thenReturn(emptySettings()); + when(lateTile.key()).thenReturn(key); + when(lateTile.worldSeed()).thenReturn(scope.worldSeed()); + when(lateTile.settingsFingerprint()).thenReturn(scope.settingsFingerprint()); + when(lateTile.tileSize()).thenReturn(64); + HydrologyTileCache closing = new HydrologyTileCache(closingPlanner, 4, null, null, scope); + HydrologyTileCache succeeding = new HydrologyTileCache(succeedingPlanner, 4, null, null, scope); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future late = executor.submit(() -> closing.get(key)); + assertTrue(planningStarted.await(10L, TimeUnit.SECONDS)); + closing.close(); + allowCompletion.countDown(); + assertSame(lateTile, late.get(10L, TimeUnit.SECONDS)); + assertSame(plannedTile, succeeding.get(key)); + verify(succeedingPlanner, times(1)).plan(key); + } finally { + allowCompletion.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS)); + } + } + @Test public void chunkPreparationUsesHeightFillColumnOrder() { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -268,6 +356,15 @@ public class HydrologyTileCacheTest { ); } + private HydrologyTileCache.SharedCacheScope sharedScope() { + return new HydrologyTileCache.SharedCacheScope( + UUID.randomUUID().toString(), + 91L, + 384, + "overworld", + 17L); + } + private HydrologyPlannerSettings featureSettings() { HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( true, @@ -408,6 +505,53 @@ public class HydrologyTileCacheTest { assertEquals((width + 2) * (height + 2), eager.size()); } + @Test + public void disabledNeighbourPrefetchPlansOnlyRequiredTiles() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = emptySettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run); + cache.setNeighbourPrefetchEnabled(false); + + cache.prepareChunkColumns(32, 48); + + int tileSize = settings.routing().tileSize(); + int radius = settings.publicationRadius(); + int width = Math.floorDiv(32 + 15 + radius, tileSize) - Math.floorDiv(32 - radius, tileSize) + 1; + int height = Math.floorDiv(48 + 15 + radius, tileSize) - Math.floorDiv(48 - radius, tileSize) + 1; + assertEquals(width * height, cache.size()); + } + + @Test + public void pregenPreparationStartsAtTheNearestUncachedForwardTile() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = emptySettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + List order = new ArrayList<>(); + doAnswer(invocation -> { + order.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run); + cache.get(new HydrologyTileKey(-1, -1)); + cache.get(new HydrologyTileKey(0, -1)); + cache.get(new HydrologyTileKey(-1, 0)); + cache.get(new HydrologyTileKey(0, 0)); + order.clear(); + cache.setNeighbourPrefetchEnabled(false); + + cache.preparePregeneration(0, 0); + + assertEquals(new HydrologyTileKey(1, -1), order.getFirst()); + assertTrue(order.contains(new HydrologyTileKey(1, 0))); + assertTrue(order.contains(new HydrologyTileKey(0, 1))); + } + @Test public void areaPrefetchPlansEveryTouchingTileNearestTheCentreFirst() { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -601,7 +745,7 @@ public class HydrologyTileCacheTest { } } @Test - public void forkJoinWorkersPlanOnTheirOwnThreadInsteadOfTheExecutor() throws Exception { + public void unrelatedForkJoinWorkersUseTheHydrologyExecutor() throws Exception { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyTile tile = mock(HydrologyTile.class); when(planner.settings()).thenReturn(emptySettings()); @@ -611,7 +755,7 @@ public class HydrologyTileCacheTest { return tile; }).when(planner).plan(any(HydrologyTileKey.class)); ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "hydrology-planning-executor")); - java.util.concurrent.ForkJoinPool pool = new java.util.concurrent.ForkJoinPool(1); + ForkJoinPool pool = new ForkJoinPool(1); try { HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); @@ -621,12 +765,93 @@ public class HydrologyTileCacheTest { assertSame(tile, planned); assertEquals(2, batch.size()); assertEquals(3, planningThreads.size()); - assertTrue(planningThreads.toString(), planningThreads.stream().allMatch(name -> name.startsWith("ForkJoinPool-"))); + assertEquals(List.of( + "hydrology-planning-executor", + "hydrology-planning-executor", + "hydrology-planning-executor" + ), planningThreads); } finally { pool.shutdownNow(); executor.shutdownNow(); } } + + @Test + public void hydrologyForkJoinWorkersPlanInlineInTheirOwnPool() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + List planningThreads = java.util.Collections.synchronizedList(new ArrayList<>()); + doAnswer(invocation -> { + planningThreads.add(Thread.currentThread().getName()); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ForkJoinPool executor = new ForkJoinPool(1); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + + HydrologyTile planned = executor.submit(() -> cache.get(new HydrologyTileKey(9, 9))) + .get(5, TimeUnit.SECONDS); + + assertSame(tile, planned); + assertEquals(1, planningThreads.size()); + assertTrue(planningThreads.getFirst().startsWith("ForkJoinPool-")); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void multiBurstWorkersPlanInlineInTheirOwnPool() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + AtomicReference planningThread = new AtomicReference<>(); + doAnswer(invocation -> { + planningThread.set(Thread.currentThread()); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + MultiBurst executor = new MultiBurst("Hydrology cache test", () -> 1); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + + Boolean plannedInline = executor.submit(() -> { + Thread caller = Thread.currentThread(); + HydrologyTile planned = cache.get(new HydrologyTileKey(9, 9)); + return planned == tile && planningThread.get() == caller; + }).get(5, TimeUnit.SECONDS); + + assertTrue(plannedInline); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void areaPrefetchSerializesSpeculativeRootsInsideTheCacheBound() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + List queued = new ArrayList<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, queued::add); + int tileSize = emptySettings().routing().tileSize(); + + cache.prefetchArea(0, 0, tileSize * 8 - 1, tileSize - 1, 0, 0); + + assertEquals(1, queued.size()); + verify(planner, org.mockito.Mockito.never()).plan(any(HydrologyTileKey.class)); + + while (!queued.isEmpty()) { + Runnable task = queued.removeFirst(); + task.run(); + } + cache.prefetchArea(0, 0, tileSize * 8 - 1, tileSize - 1, 0, 0); + + assertTrue(queued.isEmpty()); + assertEquals(4, cache.size()); + verify(planner, times(4)).plan(any(HydrologyTileKey.class)); + } @Test public void plannedCheckNeverPlansItselfButAsksThePrefetchExecutorForMissingTiles() { HydrologyPlanner planner = mock(HydrologyPlanner.class); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java new file mode 100644 index 000000000..f3895d6fd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java @@ -0,0 +1,172 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class StudioHydrologyTileStoreTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void roundTripsValidatedEntryTile() throws Exception { + HydrologyTile original = tile(); + StudioHydrologyTileStore store = store(); + + store.save(original); + HydrologyTile restored = store.load(original.key()).orElseThrow(); + + assertEquals(original.key(), restored.key()); + assertEquals(original.worldSeed(), restored.worldSeed()); + assertEquals(original.settingsFingerprint(), restored.settingsFingerprint()); + assertEquals(original.nodes(), restored.nodes()); + assertEquals(original.edges(), restored.edges()); + assertEquals(original.outlets(), restored.outlets()); + assertEquals(original.courses(), restored.courses()); + assertEquals(original.cavePlans(), restored.cavePlans()); + assertEquals(original.diagnosticCandidates(), restored.diagnosticCandidates()); + assertEquals(original.footprint(), restored.footprint()); + } + + @Test + public void corruptEntryFallsBackToCacheMiss() throws Exception { + HydrologyTile original = tile(); + StudioHydrologyTileStore store = store(); + store.save(original); + Path file = store.file(original.key()); + Files.writeString(file, "invalid", StandardCharsets.UTF_8); + + assertTrue(store.load(original.key()).isEmpty()); + } + + @Test + public void mismatchedTileSizeFallsBackToCacheMiss() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + HydrologyTileCache.SharedCacheScope scope = scope(); + HydrologyTile original = tile(); + StudioHydrologyTileStore matching = new StudioHydrologyTileStore(root, scope, 64); + matching.save(original); + StudioHydrologyTileStore mismatched = new StudioHydrologyTileStore(root, scope, 128); + + assertTrue(mismatched.load(original.key()).isEmpty()); + } + + private StudioHydrologyTileStore store() throws Exception { + return new StudioHydrologyTileStore( + temporaryFolder.newFolder().toPath(), + scope(), + 64 + ); + } + + private HydrologyTileCache.SharedCacheScope scope() { + return new HydrologyTileCache.SharedCacheScope( + "runtime-identity", + 91L, + 384, + "overworld", + 17L + ); + } + + private HydrologyTile tile() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains"); + DrainageNode node = new DrainageNode(1L, 0, 0, terrain, 10D, 2L); + RiverOutlet outlet = new RiverOutlet( + 2L, + HydrologyFeatureType.MOUTH, + 1L, + new HydrologyPoint(0, 80, 0), + new HydrologyPoint(1, 63, 0), + 63, + true + ); + HydraulicSegment segment = new HydraulicSegment( + 3L, + 4L, + HydrologyFeatureType.SURFACE_POOL, + 72, + 72, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 72, 0)) + ); + RiverCourse course = new RiverCourse( + 4L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "default", + 1, + List.of(), + List.of(segment) + ); + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 5L, + HydrologyFeatureType.SURFACE_POOL, + 4L, + 3L, + 0, + 72, + 0, + 1, + 0, + true + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 70, + 72, + 72, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "default", + "plains", + "plains", + "plains", + "plains", + "plains" + ); + HydrologyColumnSample column = new HydrologyColumnSample( + 0, + 0, + 80, + 63, + false, + "plains", + List.of(layer) + ); + return new HydrologyTile( + new HydrologyTileKey(0, 0), + 91L, + 17L, + 64, + List.of(node), + List.of(), + List.of(outlet), + List.of(course), + List.of(), + List.of(), + new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index 74964a2ee..95613026d 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -111,6 +111,30 @@ public class IrisHydrologyRoutingTerrainSamplerTest { } } + @Test + public void slopeFreePointBasisAvoidsNeighbourHeightSamples() { + AtomicInteger heightCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + heightCalls.incrementAndGet(); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64) + ); + + HydrologyTerrainSample slopeFree = sampler.sampleBasisWithoutSlope(4, 8); + assertEquals(1, heightCalls.get()); + + HydrologyTerrainSample sloped = sampler.sampleBasis(4, 8); + assertEquals(3, heightCalls.get()); + assertTrue(sloped.slope() > 0D); + assertEquals(sloped, slopeFree.withSlope(sloped.slope())); + } + @Test public void adjacentGridsShareBasesAndProduceIdenticalOverlap() { AtomicInteger calls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 86ea66868..2e6f48593 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -26,9 +26,11 @@ import org.junit.Test; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; @@ -49,6 +51,9 @@ public class IrisCaveCarver3DNearParityTest { private static Method sampleDensityMethod; private static Method aquiferCandidateMethod; + private static Method aquiferCupSupportMethod; + private static Method deepAdaptivePlaneMethod; + private static Field baseDensityField; private static Field engineField; private static Field dataField; private static Field profileField; @@ -65,6 +70,24 @@ public class IrisCaveCarver3DNearParityTest { sampleDensityMethod.setAccessible(true); aquiferCandidateMethod = IrisCaveCarver3D.class.getDeclaredMethod("isAquiferCandidate", int.class, int.class, int.class, double.class); aquiferCandidateMethod.setAccessible(true); + aquiferCupSupportMethod = IrisCaveCarver3D.class.getDeclaredMethod( + "hasAquiferCupSupport", CaveCarveScratch.class, int.class, int.class, int.class, double.class); + aquiferCupSupportMethod.setAccessible(true); + deepAdaptivePlaneMethod = IrisCaveCarver3D.class.getDeclaredMethod( + "classifyDeepAdaptivePlaneFromSamples", + CaveCarveScratch.class, + int[].class, + double[].class, + int.class, + boolean[].class, + int.class, + double[].class, + int.class, + int.class + ); + deepAdaptivePlaneMethod.setAccessible(true); + baseDensityField = IrisCaveCarver3D.class.getDeclaredField("baseDensity"); + baseDensityField.setAccessible(true); engineField = IrisCaveCarver3D.class.getDeclaredField("engine"); engineField.setAccessible(true); dataField = IrisCaveCarver3D.class.getDeclaredField("data"); @@ -200,6 +223,145 @@ public class IrisCaveCarver3DNearParityTest { assertExactParity(true, true, false); } + @Test + public void exactPathMatchesNaiveReferenceWhenProfileCeilingExceedsTerrain() throws Exception { + Engine engine = createEngine(256, 64); + double[] columnWeights = fullWeights(); + int[] precomputedSurfaceHeights = filledHeights(64); + IrisCaveProfile optimizedProfile = createProfile(true, true) + .setVerticalRange(new IrisRange(0D, 220D)) + .setAllowSurfaceBreak(false) + .setAdaptiveSampling(false); + IrisCaveCarver3D optimizedCarver = new IrisCaveCarver3D(engine, optimizedProfile); + WriterCapture optimizedCapture = createWriterCapture(256); + int optimizedCarved = optimizedCarver.carve( + optimizedCapture.writer, 5, -1, columnWeights, 0D, 0D, null, precomputedSurfaceHeights); + + IrisCaveProfile referenceProfile = createProfile(true, true) + .setVerticalRange(new IrisRange(0D, 220D)) + .setAllowSurfaceBreak(false) + .setAdaptiveSampling(false); + IrisCaveCarver3D referenceCarver = new IrisCaveCarver3D(engine, referenceProfile); + WriterCapture referenceCapture = createWriterCapture(256); + int referenceCarved = carveNaiveExact( + referenceCarver, referenceCapture.writer, 5, -1, columnWeights, null, precomputedSurfaceHeights); + + assertEquals(referenceCarved, optimizedCarved); + assertEquals(referenceCapture.carvedCells, optimizedCapture.carvedCells); + assertEquals(referenceCapture.carvedLiquids, optimizedCapture.carvedLiquids); + } + + @Test + public void deepAdaptiveSampleClassifierMatchesLegacyDecisionTree() throws Exception { + IrisCaveProfile profile = createProfile(true, true); + IrisCaveCarver3D carver = new IrisCaveCarver3D(createEngine(128, 92), profile); + CaveCarveScratch scratch = new CaveCarveScratch(); + int adaptiveSampleStep = 8; + int axisCells = 2; + int axisSamples = 3; + int[] planeColumnIndices = new int[256]; + double[] planeThresholdLimit = new double[256]; + boolean[] planeCarve = new boolean[256]; + double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity; + for (int index = 0; index < planeColumnIndices.length; index++) { + planeColumnIndices[index] = index; + planeThresholdLimit[index] = ((index % 17) - 8) * 0.11D; + planeCarve[index] = (index & 1) == 0; + } + for (int index = 0; index < axisSamples * axisSamples; index++) { + adaptivePlaneDensity[index] = ((index * 13) % 19 - 9) * 0.14D; + } + + deepAdaptivePlaneMethod.invoke( + carver, + scratch, + planeColumnIndices, + planeThresholdLimit, + planeColumnIndices.length, + planeCarve, + adaptiveSampleStep, + adaptivePlaneDensity, + axisCells, + axisSamples + ); + + double normalization = Math.abs(profile.getBaseWeight()) + Math.abs(profile.getDetailWeight()); + for (IrisCaveFieldModule module : profile.getModules()) { + normalization += Math.abs(module.getWeight()); + } + double inverseNormalization = 1D / normalization; + double adaptiveThresholdMargin = 0.19D; + for (int columnIndex = 0; columnIndex < planeColumnIndices.length; columnIndex++) { + int localX = columnIndex >> 4; + int localZ = columnIndex & 15; + int cellX = Math.min(localX / adaptiveSampleStep, axisCells - 1); + int cellZ = Math.min(localZ / adaptiveSampleStep, axisCells - 1); + int x0 = cellX * adaptiveSampleStep; + int z0 = cellZ * adaptiveSampleStep; + int x1 = Math.min(x0 + adaptiveSampleStep, 16); + int z1 = Math.min(z0 + adaptiveSampleStep, 16); + double tx = x1 == x0 ? 0D : (localX - x0) / (double) (x1 - x0); + double tz = z1 == z0 ? 0D : (localZ - z0) / (double) (z1 - z0); + int row0 = cellX * axisSamples; + int row1 = (cellX + 1) * axisSamples; + double d00 = adaptivePlaneDensity[row0 + cellZ]; + double d01 = adaptivePlaneDensity[row0 + cellZ + 1]; + double d10 = adaptivePlaneDensity[row1 + cellZ]; + double d11 = adaptivePlaneDensity[row1 + cellZ + 1]; + double dx0 = d00 + ((d10 - d00) * tx); + double dx1 = d01 + ((d11 - d01) * tx); + double predictedDensity = dx0 + ((dx1 - dx0) * tz); + double threshold = planeThresholdLimit[columnIndex] * inverseNormalization; + double minDensity = Math.min(Math.min(d00, d01), Math.min(d10, d11)); + double maxDensity = Math.max(Math.max(d00, d01), Math.max(d10, d11)); + double ambiguityMargin = adaptiveThresholdMargin + ((maxDensity - minDensity) * 0.125D); + boolean expected; + if (localX % adaptiveSampleStep == 0 && localZ % adaptiveSampleStep == 0) { + expected = predictedDensity <= threshold; + } else if (predictedDensity <= threshold - ambiguityMargin) { + expected = true; + } else if (predictedDensity > threshold + ambiguityMargin) { + expected = false; + } else { + expected = predictedDensity <= threshold; + } + assertEquals("column " + columnIndex, expected, planeCarve[columnIndex]); + } + } + + @Test + public void aquiferCupSupportStopsAsSoonAsTheResultIsKnown() throws Exception { + Engine engine = createEngine(128, 92); + IrisCaveProfile profile = createProfile(false, false) + .setBaseWeight(1D) + .setDetailWeight(0D) + .setFluidRequiresFloor(true); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + Map rejectedDensity = new HashMap<>(); + rejectedDensity.put("11:10:10", -1D); + rejectedDensity.put("9:10:10", -1D); + CoordinateDensityCNG rejectedNoise = new CoordinateDensityCNG(rejectedDensity); + baseDensityField.set(carver, rejectedNoise); + + boolean rejected = (boolean) aquiferCupSupportMethod.invoke( + carver, new CaveCarveScratch(), 10, 10, 10, 0D); + + assertFalse(rejected); + assertEquals(List.of("10:9:10", "10:8:10", "11:10:10", "9:10:10"), rejectedNoise.sampledCoordinates); + + CoordinateDensityCNG acceptedNoise = new CoordinateDensityCNG(Map.of()); + baseDensityField.set(carver, acceptedNoise); + + boolean accepted = (boolean) aquiferCupSupportMethod.invoke( + carver, new CaveCarveScratch(), 10, 10, 10, 0D); + + assertTrue(accepted); + assertEquals( + List.of("10:9:10", "10:8:10", "11:10:10", "9:10:10", "10:10:11", "10:10:9"), + acceptedNoise.sampledCoordinates + ); + } + @Test public void legacySampleStepTwoMatchesExactReference() throws Exception { Engine engine = createEngine(96, 90); @@ -1154,6 +1316,23 @@ public class IrisCaveCarver3DNearParityTest { return min; } + private static final class CoordinateDensityCNG extends CNG { + private final Map densityByCoordinate; + private final List sampledCoordinates = new ArrayList<>(); + + private CoordinateDensityCNG(Map densityByCoordinate) { + super(new RNG(82_991L)); + this.densityByCoordinate = densityByCoordinate; + } + + @Override + public double noiseFastSigned3D(double x, double y, double z) { + String coordinate = (int) x + ":" + (int) y + ":" + (int) z; + sampledCoordinates.add(coordinate); + return densityByCoordinate.getOrDefault(coordinate, 1D); + } + } + private static final class WriterCapture { private final MantleWriter writer; private final Set carvedCells; diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java index b2c573e33..0f2906e41 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java @@ -9,6 +9,7 @@ import java.util.Set; import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -130,6 +131,34 @@ public class IrisDimensionReachableBiomesTest { verify(biomeLoader, times(1)).load("d"); } + @Test + @SuppressWarnings("unchecked") + public void excludesUnavailableCavePoolFromReachableClosure() { + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable")); + IrisRegion reachable = new IrisRegion() + .setLandBiomes(new KList<>("plains")) + .setCaveBiomes(new KList<>("carving/sulfur")); + IrisBiome plains = biome("plains"); + IrisBiome sulfur = CompatFixtures.excludeBlock(biome("carving/sulfur")) + .setChildren(new KList<>("carving/sulfur-hollows")); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("reachable")).thenReturn(reachable); + when(biomeLoader.load("plains")).thenReturn(plains); + when(biomeLoader.load("carving/sulfur")).thenReturn(sulfur); + + Set keys = dimension.getReachableBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey) + .collect(Collectors.toSet()); + + assertEquals(Set.of("plains"), keys); + verify(biomeLoader, never()).load("carving/sulfur-hollows"); + } + @Test @SuppressWarnings("unchecked") public void includesEveryBiomeReferencedByEnabledPolicyHierarchy() {