CORRECTIONS

- this is the corrections from the afoemrnioned commits
This commit is contained in:
Brian Neumann-Fopiano
2026-09-04 00:02:43 -04:00
parent a771fa6856
commit 366033a535
46 changed files with 2425 additions and 256 deletions
@@ -20,6 +20,11 @@ import org.junit.Test;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; 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 java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -107,6 +112,61 @@ public class NativeStructureVolumeIndexTest {
assertTrue(unreachable.resolve(null, 0, 0, 15, 15).isEmpty()); 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<KList<NativeStructureVolume>> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15));
assertTrue(resolver.awaitFirstEntry());
Future<KList<NativeStructureVolume>> 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<KList<NativeStructureVolume>> first = executor.submit(() -> index.resolve(null, 0, 0, 15, 15));
assertTrue(resolver.awaitFirstEntry());
Future<KList<NativeStructureVolume>> 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<NativeStructureVolume> 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 @Test
public void volumeResolutionNeverReadsChunkOrOwnershipState() throws Exception { public void volumeResolutionNeverReadsChunkOrOwnershipState() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.nativeStructureVolumeIndexSource"))); 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))); 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 static final class CountingResolver implements NativeStructureVolumeIndex.OriginResolver {
private final AtomicInteger resolutions = new AtomicInteger(); private final AtomicInteger resolutions = new AtomicInteger();
private final int originChunkX; private final int originChunkX;
@@ -157,4 +227,56 @@ public class NativeStructureVolumeIndexTest {
return NativeStructureVolumeIndex.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); 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<NativeStructureVolume> 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<NativeStructureVolume> volumesAt(Engine engine, int chunkX, int chunkZ) {
return new KList<>(new NativeStructureVolume(
chunkX + ":" + chunkZ,
0, 0, 0,
15, 255, 15));
}
}
} }
@@ -37,6 +37,7 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.event.world.WorldSaveEvent;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -61,7 +62,8 @@ import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener { public final class PendingWorldReplacementManager implements Listener {
private static final WorldSlotKey OVERWORLD_KEY = WorldSlotKey.minecraft("overworld"); 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 Iris plugin;
private final Set<UUID> cleanupInFlight = new HashSet<>(); private final Set<UUID> cleanupInFlight = new HashSet<>();
@@ -70,6 +72,7 @@ public final class PendingWorldReplacementManager implements Listener {
private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard; private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard;
private volatile Path overworldEntryWorldDirectory; private volatile Path overworldEntryWorldDirectory;
private volatile CompletableFuture<Location> overworldSafeEntry; private volatile CompletableFuture<Location> overworldSafeEntry;
private volatile CompletableFuture<Void> overworldSpawnPersistence;
private volatile UUID overworldEntryAwaitingVerification; private volatile UUID overworldEntryAwaitingVerification;
private volatile World overworldEntryWorld; private volatile World overworldEntryWorld;
private volatile boolean paperEntryListenerRegistered; private volatile boolean paperEntryListenerRegistered;
@@ -377,22 +380,23 @@ public final class PendingWorldReplacementManager implements Listener {
int chunkX = anchor.getBlockX() >> 4; int chunkX = anchor.getBlockX() >> 4;
int chunkZ = anchor.getBlockZ() >> 4; int chunkZ = anchor.getBlockZ() >> 4;
WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); WorldRuntimeControlService runtime = WorldRuntimeControlService.get();
CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true, true);
if (chunkFuture == null) { if (chunkFuture == null) {
throw new IOException("The replacement spawn chunk request was not accepted."); throw new IOException("The replacement spawn chunk request was not accepted.");
} }
chunkFuture chunkFuture
.thenCompose(chunk -> runtime.resolveSafeEntry(world, anchor)) .thenCompose(chunk -> runtime.resolveSafeEntry(world, anchor))
.thenCompose(this::applyOverworldSpawn) .thenCompose(this::applyOverworldSpawn)
.thenCompose(this::persistOverworldSpawn)
.whenComplete((safeEntry, failure) -> { .whenComplete((safeEntry, failure) -> {
if (failure != null) { if (failure != null) {
targetFuture.completeExceptionally(failure); targetFuture.completeExceptionally(failure);
Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure); Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure);
return; return;
} }
CompletableFuture<Void> persistence = new CompletableFuture<>();
overworldSpawnPersistence = persistence;
persistence.thenRun(() -> retireOverworldEntryIfComplete(guard.transactionId()));
targetFuture.complete(safeEntry.clone()); targetFuture.complete(safeEntry.clone());
retireOverworldEntryIfComplete(guard.transactionId());
}); });
} catch (Throwable failure) { } catch (Throwable failure) {
targetFuture.completeExceptionally(failure); targetFuture.completeExceptionally(failure);
@@ -422,35 +426,19 @@ public final class PendingWorldReplacementManager implements Listener {
return applied; return applied;
} }
private CompletableFuture<Location> persistOverworldSpawn(Location safeEntry) {
Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone();
World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world");
CompletableFuture<Location> 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<Boolean> inspectLoginCollision(Location location) { private CompletableFuture<Boolean> inspectLoginCollision(Location location) {
Location requiredLocation = Objects.requireNonNull(location, "location").clone(); Location requiredLocation = Objects.requireNonNull(location, "location").clone();
World world = Objects.requireNonNull(requiredLocation.getWorld(), "location.world"); World world = Objects.requireNonNull(requiredLocation.getWorld(), "location.world");
int chunkX = requiredLocation.getBlockX() >> 4; int chunkX = requiredLocation.getBlockX() >> 4;
int chunkZ = requiredLocation.getBlockZ() >> 4; int chunkZ = requiredLocation.getBlockZ() >> 4;
WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); WorldRuntimeControlService runtime = WorldRuntimeControlService.get();
CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); CompletableFuture<Chunk> chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, false, true);
if (chunkFuture == null) { if (chunkFuture == null) {
return CompletableFuture.failedFuture(new IOException("The saved login chunk request was not accepted.")); 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<Boolean> inspectLoadedLoginCollision(Location location) { private CompletableFuture<Boolean> inspectLoadedLoginCollision(Location location) {
@@ -528,14 +516,14 @@ public final class PendingWorldReplacementManager implements Listener {
private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId) { private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId) {
WorldReplacementEntryGuard.Entry current = overworldEntryGuard; WorldReplacementEntryGuard.Entry current = overworldEntryGuard;
Path worldDirectory = overworldEntryWorldDirectory; Path worldDirectory = overworldEntryWorldDirectory;
CompletableFuture<Location> safeEntry = overworldSafeEntry; CompletableFuture<Void> persistence = overworldSpawnPersistence;
if (current == null if (current == null
|| worldDirectory == null || worldDirectory == null
|| !current.transactionId().equals(transactionId) || !current.transactionId().equals(transactionId)
|| !current.pendingPlayers().isEmpty() || !current.pendingPlayers().isEmpty()
|| safeEntry == null || persistence == null
|| !safeEntry.isDone() || !persistence.isDone()
|| safeEntry.isCompletedExceptionally()) { || persistence.isCompletedExceptionally()) {
return; return;
} }
try { try {
@@ -545,6 +533,7 @@ public final class PendingWorldReplacementManager implements Listener {
overworldEntryGuard = null; overworldEntryGuard = null;
overworldEntryWorldDirectory = null; overworldEntryWorldDirectory = null;
overworldSafeEntry = null; overworldSafeEntry = null;
overworldSpawnPersistence = null;
overworldEntryWorld = null; overworldEntryWorld = null;
pendingEntryAcknowledgements.entrySet().removeIf(entry -> entry.getValue().equals(transactionId)); pendingEntryAcknowledgements.entrySet().removeIf(entry -> entry.getValue().equals(transactionId));
} catch (Throwable failure) { } catch (Throwable failure) {
@@ -581,6 +570,22 @@ public final class PendingWorldReplacementManager implements Listener {
J.a(() -> discoverLoadedWorldTransaction(worldKey)); J.a(() -> discoverLoadedWorldTransaction(worldKey));
} }
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldSave(WorldSaveEvent event) {
World replacementWorld = overworldEntryWorld;
CompletableFuture<Location> safeEntry = overworldSafeEntry;
CompletableFuture<Void> 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) ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer)
throws IOException, InterruptedException, ExecutionException, TimeoutException { throws IOException, InterruptedException, ExecutionException, TimeoutException {
WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; WorldReplacementEntryGuard.Entry guard = overworldEntryGuard;
@@ -603,12 +608,22 @@ public final class PendingWorldReplacementManager implements Listener {
return null; return null;
} }
if (!newPlayer) { if (!newPlayer) {
boolean collisionSafe = inspectLoginCollision(requiredSavedLocation) SavedLocationInspection inspection = awaitSavedLocationInspection(
.get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS); inspectLoginCollision(requiredSavedLocation),
if (collisionSafe) { SAVED_LOCATION_INSPECTION_TIMEOUT_SECONDS,
TimeUnit.SECONDS
);
if (inspection.safe()) {
completeOverworldEntry(playerId, guard.transactionId()); completeOverworldEntry(playerId, guard.transactionId());
return null; 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<Location> safeEntry = overworldSafeEntry; CompletableFuture<Location> safeEntry = overworldSafeEntry;
if (safeEntry == null) { if (safeEntry == null) {
@@ -620,6 +635,26 @@ public final class PendingWorldReplacementManager implements Listener {
return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer); return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer);
} }
static SavedLocationInspection awaitSavedLocationInspection(
CompletableFuture<Boolean> inspection,
long timeout,
TimeUnit timeUnit
) throws InterruptedException {
CompletableFuture<Boolean> 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 { void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId) throws IOException {
WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; WorldReplacementEntryGuard.Entry guard = overworldEntryGuard;
if (guard == null if (guard == null
@@ -999,6 +1034,7 @@ public final class PendingWorldReplacementManager implements Listener {
overworldEntryGuard = loaded.orElse(null); overworldEntryGuard = loaded.orElse(null);
overworldEntryWorldDirectory = loaded.isPresent() ? target.worldDirectory() : null; overworldEntryWorldDirectory = loaded.isPresent() ? target.worldDirectory() : null;
overworldSafeEntry = null; overworldSafeEntry = null;
overworldSpawnPersistence = null;
overworldEntryAwaitingVerification = null; overworldEntryAwaitingVerification = null;
} }
@@ -1006,6 +1042,7 @@ public final class PendingWorldReplacementManager implements Listener {
overworldEntryGuard = null; overworldEntryGuard = null;
overworldEntryWorldDirectory = null; overworldEntryWorldDirectory = null;
overworldSafeEntry = null; overworldSafeEntry = null;
overworldSpawnPersistence = null;
overworldEntryAwaitingVerification = null; overworldEntryAwaitingVerification = null;
overworldEntryWorld = null; overworldEntryWorld = null;
pendingEntryAcknowledgements.clear(); 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 static final class RestartBoundaryRequired extends IOException {
private RestartBoundaryRequired(String message) { private RestartBoundaryRequired(String message) {
super(message); super(message);
@@ -13,9 +13,16 @@ import org.mockito.MockedStatic;
import java.io.IOException; import java.io.IOException;
import java.util.List; 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.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.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; 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) { private record EnvironmentExpectation(SlotKind slotKind, IrisEnvironment environment) {
} }
} }
@@ -219,7 +219,7 @@ public class PendingWorldReplacementThreadAffinityTest {
String listenerSource = Files.readString(Path.of( String listenerSource = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")).replace("\r\n", "\n"); "src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")).replace("\r\n", "\n");
String preparation = method(managerSource, "private void prepareOverworldEntry(World world)"); String preparation = method(managerSource, "private void prepareOverworldEntry(World world)");
String persistence = method(managerSource, "private CompletableFuture<Location> persistOverworldSpawn(Location safeEntry)"); String persistence = method(managerSource, "public void onWorldSave(WorldSaveEvent event)");
String retirement = method( String retirement = method(
managerSource, managerSource,
"private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId)" "private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId)"
@@ -230,18 +230,27 @@ public class PendingWorldReplacementThreadAffinityTest {
); );
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))); String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
assertBefore(preparation, ".thenCompose(this::applyOverworldSpawn)", assertBefore(preparation, "overworldSpawnPersistence = persistence",
".thenCompose(this::persistOverworldSpawn)"); "targetFuture.complete(safeEntry.clone())");
assertBefore(preparation, "targetFuture.complete(safeEntry.clone())", assertTrue(persistence.contains("event.getWorld() != replacementWorld"));
"retireOverworldEntryIfComplete(guard.transactionId())"); assertTrue(persistence.contains("persistence.complete(null)"));
assertTrue(persistence.contains("world.save()"));
assertTrue(retirement.contains("!current.pendingPlayers().isEmpty()")); assertTrue(retirement.contains("!current.pendingPlayers().isEmpty()"));
assertTrue(retirement.contains("!safeEntry.isDone()")); assertTrue(retirement.contains("!persistence.isDone()"));
assertTrue(retirement.contains("safeEntry.isCompletedExceptionally()")); assertTrue(retirement.contains("persistence.isCompletedExceptionally()"));
assertTrue(listener.contains("event.isNewPlayer()")); assertTrue(listener.contains("event.isNewPlayer()"));
assertTrue(generatorSource.contains("world.getHighestBlockYAt(initialSpawn) + 1")); 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<Boolean> inspectLoginCollision(Location location)");
assertTrue(inspection.contains("requestChunkAsync(world, chunkX, chunkZ, false, true)"));
assertBefore(inspection, "chunk == null", "inspectLoadedLoginCollision(requiredLocation)");
}
private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState( private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState(
WorldSlotKey worldKey, WorldSlotKey worldKey,
long seed, long seed,
@@ -51,7 +51,7 @@ public class BukkitEngineLifecycleContractTest {
String waitedShutdown = method(jobSource, String waitedShutdown = method(jobSource,
"private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs)"); "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()")); assertTrue(waitedShutdown.contains("inst.worker.isAlive()"));
String hooksSource = Files.readString(Path.of(System.getProperty("iris.bukkitEnginePlatformHooksSource"))); String hooksSource = Files.readString(Path.of(System.getProperty("iris.bukkitEnginePlatformHooksSource")));
@@ -36,6 +36,7 @@ import java.util.Set;
import java.util.WeakHashMap; import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.IntBinaryOperator; import java.util.function.IntBinaryOperator;
import java.util.function.Supplier; import java.util.function.Supplier;
@@ -49,6 +50,8 @@ import java.util.function.Supplier;
*/ */
public final class NativeStructureVolumeIndex { public final class NativeStructureVolumeIndex {
private static final int ORIGIN_REACH_CHUNKS = NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS; 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_ORIGIN_CHUNKS = 16_384;
private static final int MAX_CACHED_QUERY_CHUNKS = 4_096; private static final int MAX_CACHED_QUERY_CHUNKS = 4_096;
private static final Map<Engine, NativeStructureVolumeIndex> INDEXES = private static final Map<Engine, NativeStructureVolumeIndex> INDEXES =
@@ -63,6 +66,7 @@ public final class NativeStructureVolumeIndex {
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, CompletableFuture<KList<NativeStructureVolume>>> queryBuilds = private final ConcurrentHashMap<Long, CompletableFuture<KList<NativeStructureVolume>>> queryBuilds =
new ConcurrentHashMap<>(); new ConcurrentHashMap<>();
private final ReentrantLock[] originWindowStripes = createOriginWindowStripes();
private NativeStructureVolumeIndex(Context context) { private NativeStructureVolumeIndex(Context context) {
this.context = Objects.requireNonNull(context, "Native structure volume context must not be null"); this.context = Objects.requireNonNull(context, "Native structure volume context must not be null");
@@ -128,6 +132,16 @@ public final class NativeStructureVolumeIndex {
} }
private KList<NativeStructureVolume> buildChunkVolumes(Engine engine, int chunkX, int chunkZ) { private KList<NativeStructureVolume> 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<NativeStructureVolume> buildChunkVolumesCoordinated(Engine engine, int chunkX, int chunkZ) {
int minX = chunkX << 4; int minX = chunkX << 4;
int minZ = chunkZ << 4; int minZ = chunkZ << 4;
int maxX = minX + 15; int maxX = minX + 15;
@@ -149,6 +163,20 @@ public final class NativeStructureVolumeIndex {
return volumes == null ? NativeStructureVolume.NONE : volumes; 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<NativeStructureVolume> originVolumes(Engine engine, int chunkX, int chunkZ) { KList<NativeStructureVolume> originVolumes(Engine engine, int chunkX, int chunkZ) {
return cached(originCache, originBuilds, chunkKey(chunkX, chunkZ), return cached(originCache, originBuilds, chunkKey(chunkX, chunkZ),
() -> origins.volumesAt(engine, chunkX, chunkZ)); () -> origins.volumesAt(engine, chunkX, chunkZ));
@@ -369,6 +397,42 @@ public final class NativeStructureVolumeIndex {
return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); 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<Long, KList<NativeStructureVolume>> lru(int capacity) { private static Map<Long, KList<NativeStructureVolume>> lru(int capacity) {
return new LinkedHashMap<>(16, 0.75F, true) { return new LinkedHashMap<>(16, 0.75F, true) {
@Override @Override
@@ -61,7 +61,6 @@ public final class ModdedPregenJob {
.radiusX(radiusBlocks) .radiusX(radiusBlocks)
.radiusZ(radiusBlocks) .radiusZ(radiusBlocks)
.build(); .build();
PregenPerformanceProfile.apply(engine);
ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync); ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync);
PregeneratorMethod method = moddedMethod; PregeneratorMethod method = moddedMethod;
if (cached) { if (cached) {
@@ -71,7 +70,11 @@ public final class ModdedPregenJob {
ACTIVE.set(active); ACTIVE.set(active);
dimension = level.dimension().identifier().toString(); dimension = level.dimension().identifier().toString();
try { try {
PregeneratorJob job = new PregeneratorJob(task, method, engine); PregeneratorJob job = new PregeneratorJob(new PregeneratorJob.Configuration(
task,
method,
engine,
() -> PregenPerformanceProfile.apply(engine)));
job.whenDone(() -> { job.whenDone(() -> {
if (!moddedMethod.hasPendingFinalSave()) { if (!moddedMethod.hasPendingFinalSave()) {
ACTIVE.compareAndSet(active, null); ACTIVE.compareAndSet(active, null);
@@ -43,6 +43,7 @@ import art.arcane.iris.util.common.scheduling.J;
import java.awt.Color; import java.awt.Color;
import java.awt.EventQueue; import java.awt.EventQueue;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -69,6 +70,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private final MemoryMonitor monitor; private final MemoryMonitor monitor;
private final PregenTask task; private final PregenTask task;
private final AtomicBoolean saving; private final AtomicBoolean saving;
private final AtomicBoolean stopRequested;
private final List<Consumer<Double>> onProgress = new CopyOnWriteArrayList<>(); private final List<Consumer<Double>> onProgress = new CopyOnWriteArrayList<>();
private final List<Runnable> whenDone = new CopyOnWriteArrayList<>(); private final List<Runnable> whenDone = new CopyOnWriteArrayList<>();
private final IrisPregenerator pregenerator; private final IrisPregenerator pregenerator;
@@ -91,10 +93,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private volatile long lastElapsed = 0L; private volatile long lastElapsed = 0L;
private volatile String lastMethod = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING); 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; this.engine = engine;
monitor = new MemoryMonitor(50); monitor = new MemoryMonitor(50);
saving = new AtomicBoolean(false); saving = new AtomicBoolean(false);
stopRequested = new AtomicBoolean(false);
info = new String[]{IrisLanguage.plain(DesktopUiMessages.PREGEN_INITIALIZING)}; info = new String[]{IrisLanguage.plain(DesktopUiMessages.PREGEN_INITIALIZING)};
this.task = task; this.task = task;
this.pregenerator = new IrisPregenerator(task, method, this); this.pregenerator = new IrisPregenerator(task, method, this);
@@ -122,11 +128,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
} }
} }
worker = new Thread(() -> { worker = new Thread(() -> runWorker(configuration.preparation()), "Iris Pregenerator");
J.sleep(1000);
computeBounds();
this.pregenerator.start();
}, "Iris Pregenerator");
worker.setPriority(Thread.MIN_PRIORITY); worker.setPriority(Thread.MIN_PRIORITY);
worker.setDaemon(true); worker.setDaemon(true);
worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex)); 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() { private void computeBounds() {
task.iterateAllChunks((xx, zz) -> { task.iterateAllChunks((xx, zz) -> {
min.setX(Math.min(xx, min.getX())); min.setX(Math.min(xx, min.getX()));
@@ -178,10 +200,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
return false; return false;
} }
J.a(() -> { inst.requestStop();
inst.pregenerator.close();
inst.worker.interrupt();
});
return true; return true;
} }
@@ -204,8 +223,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
} }
private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs) { private static boolean shutdownAndWait(PregeneratorJob inst, long timeoutMs) {
inst.pregenerator.close(); inst.requestStop();
inst.worker.interrupt();
try { try {
inst.worker.join(Math.max(1L, timeoutMs)); inst.worker.join(Math.max(1L, timeoutMs));
} catch (InterruptedException e) { } catch (InterruptedException e) {
@@ -357,12 +375,16 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
} }
public void stop() { public void stop() {
J.a(() -> { requestStop();
pregenerator.close(); close();
worker.interrupt(); }
close();
instance.compareAndSet(this, null); private void requestStop() {
}); if (!stopRequested.compareAndSet(false, true)) {
return;
}
pregenerator.close();
worker.interrupt();
} }
public void close() { public void close() {
@@ -583,4 +605,17 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
public String[] progress() { public String[] progress() {
return info; 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");
}
}
} }
@@ -214,6 +214,7 @@ public class IrisPregenerator {
checkRegions(); checkRegions();
int[] regionBounds = task.regionBounds(); int[] regionBounds = task.regionBounds();
generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]); 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, true));
task.iterateRegions((x, z) -> visitRegion(x, z, false)); task.iterateRegions((x, z) -> visitRegion(x, z, false));
completed = true; completed = true;
@@ -288,10 +289,17 @@ public class IrisPregenerator {
} }
private void shutdown() { private void shutdown() {
Thread.interrupted();
shutdownStep("saving", listener::onSaving); shutdownStep("saving", listener::onSaving);
shutdownStep("generator", generator::close); shutdownStep("generator", generator::close);
Thread.interrupted();
shutdownStep("ticker", ticker::interrupt); shutdownStep("ticker", ticker::interrupt);
shutdownStep("listener", listener::onClose); shutdownStep("mantle", () -> {
Mantle mantle = getMantle();
if (mantle != null) {
reclaimTectonicPlates(mantle);
}
});
shutdownStep("protocol", () -> { shutdownStep("protocol", () -> {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class); IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer != null) { if (protocolServer != null) {
@@ -299,12 +307,7 @@ public class IrisPregenerator {
protocolServer.pregenEnd(jobId, total > 0 && generated.get() >= total); protocolServer.pregenEnd(jobId, total > 0 && generated.get() >= total);
} }
}); });
shutdownStep("mantle", () -> { shutdownStep("listener", listener::onClose);
Mantle mantle = getMantle();
if (mantle != null) {
reclaimTectonicPlates(mantle);
}
});
} }
private void shutdownStep(String step, Runnable action) { private void shutdownStep(String step, Runnable action) {
@@ -18,16 +18,17 @@
package art.arcane.iris.core.pregenerator; package art.arcane.iris.core.pregenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.MeteredCache; import art.arcane.iris.engine.framework.MeteredCache;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.project.stream.utility.CachedStream2D;
import art.arcane.iris.util.common.parallel.MultiBurst; 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.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -68,7 +69,7 @@ public final class PregenPerformanceProfile {
public static void apply(Engine engine) { public static void apply(Engine engine) {
apply(); apply();
if (requiresNoiseCacheRefresh(engine)) { if (!prepareNoiseCacheInPlace(engine)) {
engine.hotloadComplex(); engine.hotloadComplex();
logApplied(); logApplied();
} }
@@ -77,35 +78,44 @@ public final class PregenPerformanceProfile {
public static void applyToGenerator(PlatformChunkGenerator generator) { public static void applyToGenerator(PlatformChunkGenerator generator) {
apply(); apply();
Engine engine = generator == null ? null : generator.getEngine(); Engine engine = generator == null ? null : generator.getEngine();
if (requiresNoiseCacheRefresh(engine)) { if (!prepareNoiseCacheInPlace(engine)) {
generator.hotloadComplexAsync(HOTLOAD_ACQUISITION_TIMEOUT_SECONDS, TimeUnit.SECONDS).join(); generator.hotloadComplexAsync(HOTLOAD_ACQUISITION_TIMEOUT_SECONDS, TimeUnit.SECONDS).join();
logApplied(); logApplied();
} }
} }
/** Two workers per core: enough to keep cores busy while a share of the pool sits in waits. */
static int pregenBurstParallelism(int availableProcessors) { static int pregenBurstParallelism(int availableProcessors) {
return Math.max(4, availableProcessors * 2); return Math.max(4, availableProcessors * 2);
} }
private static boolean requiresNoiseCacheRefresh(Engine engine) { private static boolean prepareNoiseCacheInPlace(Engine engine) {
if (engine == null) { if (engine == null) {
return false; return true;
} }
IrisComplex complex = engine.getComplex(); IrisComplex complex = engine.getComplex();
if (complex == null) { if (complex == null) {
return true; return false;
}
ProceduralStream<Double> heightStream = complex.getHeightStream();
if (!(heightStream instanceof MeteredCache cache)) {
return true;
} }
ProceduralStream<IrisBiome> caveBiomeStream = complex.getCaveBiomeStream(); ProceduralStream<IrisBiome> caveBiomeStream = complex.getCaveBiomeStream();
if (!(caveBiomeStream instanceof CachedStream2D<?> cachedStream) || !cachedStream.usesFastCache()) { 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<Double> stream, int configuredChunks) {
if (stream instanceof CachedDoubleStream2D cachedStream) {
cachedStream.setMaximumChunks(configuredChunks);
return true; return true;
} }
long configuredMaxSize = (long) IrisSettings.get().getPerformance().getNoiseCacheSize() * 256L; if (!(stream instanceof MeteredCache cache)) {
return cache.getMaxSize() != configuredMaxSize; return false;
}
return cache.getMaxSize() == (long) configuredChunks * 256L;
} }
private static void logApplied() { private static void logApplied() {
@@ -128,6 +128,10 @@ public class PregenTask {
private static int[] computeOrder(long key) { private static int[] computeOrder(long key) {
int pullX = (int) (key >> 32); int pullX = (int) (key >> 32);
int pullZ = (int) key; int pullZ = (int) key;
if (pullX >= 0 && pullX <= 31 && pullZ >= 0 && pullZ <= 31) {
return packOrder(contiguousOrder(pullX, pullZ));
}
Position2 pull = new Position2(pullX, pullZ); Position2 pull = new Position2(pullX, pullZ);
KList<Position2> p = new KList<>(); KList<Position2> p = new KList<>();
new Spiraler(33, 33, (x, z) -> { new Spiraler(33, 33, (x, z) -> {
@@ -140,7 +144,10 @@ public class PregenTask {
p.add(new Position2(xx, zz)); p.add(new Position2(xx, zz));
}).drain(); }).drain();
p.sort(Comparator.comparing((i) -> i.distance(pull))); p.sort(Comparator.comparing((i) -> i.distance(pull)));
KList<Position2> ordered = latticeOrder(p); return packOrder(latticeOrder(p));
}
private static int[] packOrder(KList<Position2> ordered) {
int[] packed = new int[ordered.size()]; int[] packed = new int[ordered.size()];
for (int index = 0; index < ordered.size(); index++) { for (int index = 0; index < ordered.size(); index++) {
Position2 position = ordered.get(index); Position2 position = ordered.get(index);
@@ -150,6 +157,16 @@ public class PregenTask {
return packed; return packed;
} }
private static KList<Position2> contiguousOrder(int pullX, int pullZ) {
KList<Position2> 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 * 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 * 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) { public void iterateChunks(int rX, int rZ, Spiraled s) {
Bound bound = bounds.chunk(); 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) -> { iterateRegion(rX, rZ, ((x, z) -> {
if (bound.check(x, z)) s.on(x, z); if (bound.check(x, z)) s.on(x, z);
})); }), pullX, pullZ);
} }
public int[] regionBounds() { public int[] regionBounds() {
@@ -83,6 +83,9 @@ public interface PregeneratorMethod {
default void onRegionBounds(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) { 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) { default void onRegionSubmitted(int regionX, int regionZ) {
} }
@@ -87,6 +87,11 @@ public class AsyncOrMedievalPregenMethod implements PregeneratorMethod {
method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ);
} }
@Override
public void onPregenStart(int centerBlockX, int centerBlockZ) {
method.onPregenStart(centerBlockX, centerBlockZ);
}
@Override @Override
public void onRegionSubmitted(int regionX, int regionZ) { public void onRegionSubmitted(int regionX, int regionZ) {
method.onRegionSubmitted(regionX, regionZ); method.onRegionSubmitted(regionX, regionZ);
@@ -88,6 +88,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final boolean urgent; private final boolean urgent;
private final ConcurrentHashMap<Long, AtomicInteger> regionPending; private final ConcurrentHashMap<Long, AtomicInteger> regionPending;
private final ConcurrentHashMap<Long, Queue<Chunk>> regionChunks; private final ConcurrentHashMap<Long, Queue<Chunk>> regionChunks;
private final Queue<CompletableFuture<Void>> pendingEvictions;
private final KSet<Long> drainedRegions; private final KSet<Long> drainedRegions;
private final KSet<Long> evictedRegions; private final KSet<Long> evictedRegions;
private volatile int evictionWindowRegions; private volatile int evictionWindowRegions;
@@ -166,6 +167,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.urgent = false; this.urgent = false;
this.regionPending = new ConcurrentHashMap<>(); this.regionPending = new ConcurrentHashMap<>();
this.regionChunks = new ConcurrentHashMap<>(); this.regionChunks = new ConcurrentHashMap<>();
this.pendingEvictions = new ConcurrentLinkedQueue<>();
this.drainedRegions = new KSet<>(); this.drainedRegions = new KSet<>();
this.evictedRegions = new KSet<>(); this.evictedRegions = new KSet<>();
this.evictionWindowRegions = -1; this.evictionWindowRegions = -1;
@@ -173,7 +175,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.boundsMinRegionZ = Integer.MIN_VALUE; this.boundsMinRegionZ = Integer.MIN_VALUE;
this.boundsMaxRegionX = Integer.MAX_VALUE; this.boundsMaxRegionX = Integer.MAX_VALUE;
this.boundsMaxRegionZ = 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))); this.adaptiveMinInFlightLimit = Math.max(4, Math.min(16, Math.max(1, this.threads / 4)));
int pregenWorldHeight = world.getMaxHeight() - world.getMinHeight(); int pregenWorldHeight = world.getMaxHeight() - world.getMinHeight();
this.backpressure = new PregenMantleBackpressure( this.backpressure = new PregenMantleBackpressure(
@@ -280,30 +284,15 @@ public class AsyncPregenMethod implements PregeneratorMethod {
boundsMinRegionZ = minRegionZ; boundsMinRegionZ = minRegionZ;
boundsMaxRegionX = maxRegionX; boundsMaxRegionX = maxRegionX;
boundsMaxRegionZ = maxRegionZ; boundsMaxRegionZ = maxRegionZ;
prefetchHydrology(minRegionX, minRegionZ, maxRegionX, maxRegionZ);
} }
/** @Override
* Plans the hydrology tiles of the whole area up front, nearest the centre first, so the spiral public void onPregenStart(int centerBlockX, int centerBlockZ) {
* finds its tiles ready instead of stalling on each cold plan.
*/
private void prefetchHydrology(int minRegionX, int minRegionZ, int maxRegionX, int maxRegionZ) {
Engine engine = resolveMetricsEngine(); Engine engine = resolveMetricsEngine();
if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return; return;
} }
int minimumBlockX = minRegionX << 9; engine.getComplex().getHydrologyRuntime().preparePregeneration(centerBlockX, centerBlockZ);
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
);
} }
private boolean inBounds(int rx, int rz) { private boolean inBounds(int rx, int rz) {
@@ -367,47 +356,22 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return true; return true;
} }
private void evictRegion(long c) { private CompletableFuture<Void> evictRegion(long c) {
if (!evictedRegions.add(c)) { if (!evictedRegions.add(c)) {
return; return CompletableFuture.completedFuture(null);
} }
regionPending.remove(c); regionPending.remove(c);
Queue<Chunk> chunks = regionChunks.remove(c); Queue<Chunk> chunks = regionChunks.remove(c);
if (chunks == null || chunks.isEmpty()) { if (chunks == null || chunks.isEmpty()) {
return; return CompletableFuture.completedFuture(null);
} }
try { CompletableFuture<Void> eviction;
if (foliaRuntime) { if (foliaRuntime) {
Chunk anchor = null; eviction = evictFoliaRegion(chunks);
for (Chunk chunk : chunks) { } else {
if (chunk == null) { eviction = J.sfut(() -> {
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(() -> {
for (Chunk chunk : chunks) { for (Chunk chunk : chunks) {
if (chunk != null) { if (chunk != null) {
unloadChunkSafely(chunk.getX(), chunk.getZ()); unloadChunkSafely(chunk.getX(), chunk.getZ());
@@ -416,9 +380,49 @@ public class AsyncPregenMethod implements PregeneratorMethod {
INMS.get().flushChunkIO(world); INMS.get().flushChunkIO(world);
}); });
} catch (Throwable e) {
IrisLogging.reportError(e);
} }
return trackEviction(eviction);
}
private CompletableFuture<Void> evictFoliaRegion(Queue<Chunk> chunks) {
List<CompletableFuture<Void>> 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<Void> 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<Void> trackEviction(CompletableFuture<Void> 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) { private void unloadChunkSafely(int cx, int cz) {
@@ -438,36 +442,18 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private void flushAllRemainingChunks() { private void flushAllRemainingChunks() {
List<Long> keys = new ArrayList<>(regionChunks.keySet()); List<Long> keys = new ArrayList<>(regionChunks.keySet());
List<CompletableFuture<Void>> evictions = new ArrayList<>(keys.size() + pendingEvictions.size());
if (foliaRuntime) { for (Long regionKey : keys) {
for (Long rk : keys) { evictions.add(evictRegion(regionKey));
evictRegion(rk);
}
return;
} }
evictions.addAll(pendingEvictions);
CompletableFuture<Void> flush = J.sfut(() -> { CompletableFuture<Void> evictionsComplete = CompletableFuture.allOf(
for (Long rk : keys) { evictions.toArray(CompletableFuture[]::new));
if (!evictedRegions.add(rk)) { CompletableFuture<Void> settledEvictions = evictionsComplete.handle((ignored, failure) -> null);
continue; CompletableFuture<Void> flush = foliaRuntime
} ? settledEvictions
: settledEvictions.thenCompose(ignored -> J.sfut(() -> INMS.get().flushChunkIO(world)));
regionPending.remove(rk);
Queue<Chunk> 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);
});
try { try {
flush.get(FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS); flush.get(FLUSH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
@@ -585,9 +571,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return; return;
} }
int deficit = threads - current; int next = nextAdaptiveInFlightLimit(current, threads);
int step = deficit > (threads / 2) ? Math.max(2, threads / 8) : 1;
int next = Math.min(threads, current + step);
if (adaptiveInFlightLimit.compareAndSet(current, next)) { if (adaptiveInFlightLimit.compareAndSet(current, next)) {
logAdaptiveLimit("increase", next); logAdaptiveLimit("increase", next);
notifyPermitWaiters(); notifyPermitWaiters();
@@ -628,6 +612,15 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return strictSerial ? 1 : Math.max(1, recommendedCap); 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) { static int resolvePaperLikeConcurrencyWorkerThreads(int detectedWorkerPoolThreads, int detectedCpuThreads, int configuredWorldGenThreads) {
int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads); int provisionedWorkerThreads = Math.max(1, configuredWorldGenThreads);
if (detectedWorkerPoolThreads > 0) { if (detectedWorkerPoolThreads > 0) {
@@ -175,6 +175,11 @@ public class CachedPregenMethod implements PregeneratorMethod {
method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); method.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ);
} }
@Override
public void onPregenStart(int centerBlockX, int centerBlockZ) {
method.onPregenStart(centerBlockX, centerBlockZ);
}
@Override @Override
public void onRegionSubmitted(int regionX, int regionZ) { public void onRegionSubmitted(int regionX, int regionZ) {
method.onRegionSubmitted(regionX, regionZ); method.onRegionSubmitted(regionX, regionZ);
@@ -80,6 +80,11 @@ public class HybridPregenMethod implements PregeneratorMethod {
inWorld.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ); inWorld.onRegionBounds(minRegionX, minRegionZ, maxRegionX, maxRegionZ);
} }
@Override
public void onPregenStart(int centerBlockX, int centerBlockZ) {
inWorld.onPregenStart(centerBlockX, centerBlockZ);
}
@Override @Override
public void onRegionSubmitted(int regionX, int regionZ) { public void onRegionSubmitted(int regionX, int regionZ) {
inWorld.onRegionSubmitted(regionX, regionZ); inWorld.onRegionSubmitted(regionX, regionZ);
@@ -19,6 +19,7 @@
package art.arcane.iris.core.pregenerator.methods; package art.arcane.iris.core.pregenerator.methods;
import art.arcane.iris.core.IrisSettings; 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.PregenListener;
import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.pregenerator.PregeneratorMethod;
import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.IrisToolbelt;
@@ -123,7 +124,7 @@ public class MedievalPregenMethod implements PregeneratorMethod {
futures.clear(); futures.clear();
} }
private void unloadAndSaveAllChunks(boolean saveWorld) { private void unloadAndSaveAllChunks(boolean flushChunkIo) {
if (J.isFolia()) { if (J.isFolia()) {
lastUse.clear(); lastUse.clear();
return; return;
@@ -138,12 +139,12 @@ public class MedievalPregenMethod implements PregeneratorMethod {
for (Chunk i : new ArrayList<>(lastUse.keySet())) { for (Chunk i : new ArrayList<>(lastUse.keySet())) {
Long lastUseTime = lastUse.get(i); Long lastUseTime = lastUse.get(i);
if (lastUseTime != null && M.ms() - lastUseTime >= 10) { if (lastUseTime != null && M.ms() - lastUseTime >= 10) {
i.unload(); i.unload(true);
lastUse.remove(i); lastUse.remove(i);
} }
} }
if (saveWorld) { if (flushChunkIo) {
world.save(); INMS.get().flushChunkIO(world);
} }
}); });
@@ -311,13 +311,19 @@ public class IrisToolbelt {
if (PregeneratorJob.getInstance() != null) { if (PregeneratorJob.getInstance() != null) {
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop."); throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop.");
} }
applyPregenPerformanceProfile(engine);
boolean useCachedWrapper = false; boolean useCachedWrapper = false;
if (cached && engine != null) { if (cached && engine != null) {
IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen());
useCachedWrapper = runtimeSchedulerMode != IrisRuntimeSchedulerMode.FOLIA; 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) { private static PregenCache resolvePregenCache(IrisWorld world) {
@@ -78,6 +78,7 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.nio.file.Path;
import java.util.function.BiFunction; import java.util.function.BiFunction;
@Data @Data
@@ -87,6 +88,7 @@ public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
private static final int GRID_BOUNDS_CACHE_SIZE = 8192; 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. */ /** 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 SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20;
private static final int HEIGHT_BOUNDS_GRID = 4; private static final int HEIGHT_BOUNDS_GRID = 4;
@@ -170,7 +172,7 @@ public class IrisComplex implements DataProvider {
} }
public IrisComplex(Engine engine, boolean simple) { 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); IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE);
UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes());
this.rng = new RNG(engine.getSeedManager().getComplex()); this.rng = new RNG(engine.getSeedManager().getComplex());
@@ -320,6 +322,7 @@ public class IrisComplex implements DataProvider {
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint), footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint),
() -> engine.getPlatformHooks().isMainThread() () -> engine.getPlatformHooks().isMainThread()
)); ));
hydrologyRuntime.setNeighbourPrefetchEnabled(!engine.isStudio());
} }
heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z))
.cache2DDouble("heightStream", engine, cacheSize); .cache2DDouble("heightStream", engine, cacheSize);
@@ -373,6 +376,16 @@ public class IrisComplex implements DataProvider {
//@done //@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( static InferredType resolveNaturalInferredType(
ProceduralStream<InferredType> bridgeStream, ProceduralStream<InferredType> bridgeStream,
IrisBiome focusBiome, IrisBiome focusBiome,
@@ -958,7 +971,7 @@ public class IrisComplex implements DataProvider {
// catches the same corner reached by another thread or by hydrology planning earlier. // catches the same corner reached by another thread or by hydrology planning earlier.
SharedCornerBounds shared = sharedCornerBounds(); SharedCornerBounds shared = sharedCornerBounds();
long sharedKey = SharedCornerBounds.key(gx, gz, interpolatorIndex); 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) { if (packed == Long.MIN_VALUE) {
packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz); packed = computePackedBounds(cache, engine, interpolator, generators, gx, gz);
if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) { if (!Double.isFinite(boundsLow(packed)) || !Double.isFinite(boundsHigh(packed))) {
@@ -40,6 +40,7 @@ import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.engine.object.IrisEngineData;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.core.compat.PackCompatReport; 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.PackValidationResult;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -70,6 +71,7 @@ import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.io.File; import java.io.File;
import java.nio.file.Path;
import java.util.HashSet; import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@@ -224,6 +226,7 @@ public class IrisEngine implements Engine {
getData().registerEngine(this); getData().registerEngine(this);
_t0 = M.ms(); _t0 = M.ms();
long phaseStartedAt = System.nanoTime(); long phaseStartedAt = System.nanoTime();
String studioCacheIdentity = currentStudioCacheIdentity();
getData().loadPrefetch(this); getData().loadPrefetch(this);
IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms");
logStudioInitializationPhase("load_prefetch", phaseStartedAt, false); logStudioInitializationPhase("load_prefetch", phaseStartedAt, false);
@@ -236,6 +239,7 @@ public class IrisEngine implements Engine {
_t0 = M.ms(); _t0 = M.ms();
phaseStartedAt = System.nanoTime(); phaseStartedAt = System.nanoTime();
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
enableStableStudioCache(initialRuntime, studioCacheIdentity);
runtimeBuilder.publishRuntime(initialRuntime, null); runtimeBuilder.publishRuntime(initialRuntime, null);
IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
logPackCompatSummary(); logPackCompatSummary();
@@ -318,6 +322,47 @@ public class IrisEngine implements Engine {
Boolean.toString(skipped)); 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) { private void startGenerationCacheWarm(long phaseStartedAtNanos) {
if (!backgroundTasks.scheduleTrackedTask(() -> warmGenerationCaches(phaseStartedAtNanos))) { if (!backgroundTasks.scheduleTrackedTask(() -> warmGenerationCaches(phaseStartedAtNanos))) {
throw new IllegalStateException("Iris background task admission closed before generation cache warming."); throw new IllegalStateException("Iris background task admission closed before generation cache warming.");
@@ -19,8 +19,8 @@ import java.util.Objects;
final class HydrologyFootprintCompiler { final class HydrologyFootprintCompiler {
private static final int FEATURE_ROLE_COUNT = 7; private static final int FEATURE_ROLE_COUNT = 7;
private static final int COURSE_FOOTPRINT_CACHE_SIZE = 8; private static final int COURSE_FOOTPRINT_CACHE_SIZE = 32;
private static final int VALIDATION_RASTER_CACHE_SIZE = 8; private static final int VALIDATION_RASTER_CACHE_SIZE = 32;
private static final int[][] HORIZONTAL_NEIGHBORS = { private static final int[][] HORIZONTAL_NEIGHBORS = {
{1, 0}, {-1, 0}, {0, 1}, {0, -1} {1, 0}, {-1, 0}, {0, 1}, {0, -1}
}; };
@@ -2,4 +2,8 @@ package art.arcane.iris.engine.hydrology;
public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler { public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler {
HydrologyTerrainSample sampleBasis(int blockX, int blockZ); HydrologyTerrainSample sampleBasis(int blockX, int blockZ);
default HydrologyTerrainSample sampleBasisWithoutSlope(int blockX, int blockZ) {
return sampleBasis(blockX, blockZ);
}
} }
@@ -320,9 +320,33 @@ public final class HydrologyPlanner {
resolvedOwners.invalidateAll(); 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( private HydrologyOwnerDraft compileOwnerDraft(
HydrologyTileKey key, HydrologyTileKey key,
CrossTileDraftAdmission crossTileAdmission CrossTileDraftAdmission crossTileAdmission,
boolean retainFootprintCompiler
) { ) {
PlanningSamples previous = planningSamples.get(); PlanningSamples previous = planningSamples.get();
if (previous == null) { if (previous == null) {
@@ -333,7 +357,11 @@ public final class HydrologyPlanner {
draftProfiles.set(profile); draftProfiles.set(profile);
long started = System.nanoTime(); long started = System.nanoTime();
try { try {
HydrologyOwnerDraft draft = compileScopedOwnerDraft(key, crossTileAdmission); HydrologyOwnerDraft draft = compileScopedOwnerDraft(
key,
crossTileAdmission,
retainFootprintCompiler
);
profile.log(key, ownerColorRank(key), System.nanoTime() - started); profile.log(key, ownerColorRank(key), System.nanoTime() - started);
return draft; return draft;
} finally { } finally {
@@ -350,7 +378,8 @@ public final class HydrologyPlanner {
private HydrologyOwnerDraft compileScopedOwnerDraft( private HydrologyOwnerDraft compileScopedOwnerDraft(
HydrologyTileKey key, HydrologyTileKey key,
CrossTileDraftAdmission crossTileAdmission CrossTileDraftAdmission crossTileAdmission,
boolean retainFootprintCompiler
) { ) {
Objects.requireNonNull(key, "key"); Objects.requireNonNull(key, "key");
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>(); ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
@@ -560,7 +589,12 @@ public final class HydrologyPlanner {
} }
HydrologyCaveCourseFilter.Result result = publication.result(); HydrologyCaveCourseFilter.Result result = publication.result();
List<HydrologyDiagnosticCandidate> uniqueDiagnostics = uniqueDiagnostics(diagnostics); List<HydrologyDiagnosticCandidate> uniqueDiagnostics = uniqueDiagnostics(diagnostics);
HydrologyOwnerDraft draft = new HydrologyOwnerDraft(key, result, uniqueDiagnostics); HydrologyOwnerDraft draft = new HydrologyOwnerDraft(
key,
result,
uniqueDiagnostics,
retainFootprintCompiler ? footprintCompiler : null
);
validateOwnerDraftReach(draft); validateOwnerDraftReach(draft);
return draft; return draft;
} }
@@ -576,14 +610,17 @@ public final class HydrologyPlanner {
rejection.winnerSourceId() rejection.winnerSourceId()
)); ));
} }
HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( HydrologyFootprintCompiler footprintCompiler = resolution.draft().footprintCompiler();
settings, if (footprintCompiler == null) {
new HydrologyFootprintCompiler.Sampling( footprintCompiler = new HydrologyFootprintCompiler(
sampler, settings,
geometrySampler, new HydrologyFootprintCompiler.Sampling(
naturalSampler sampler,
) geometrySampler,
); naturalSampler
)
);
}
MaterializedHydrology materialized = materializeFinalHydrology( MaterializedHydrology materialized = materializeFinalHydrology(
result, result,
diagnostics, diagnostics,
@@ -743,11 +780,14 @@ public final class HydrologyPlanner {
try { try {
int ownerRank = ownerColorRank(key); int ownerRank = ownerColorRank(key);
ColorRankedDraftAdmission admission = new ColorRankedDraftAdmission(key, ownerRank, context); ColorRankedDraftAdmission admission = new ColorRankedDraftAdmission(key, ownerRank, context);
HydrologyOwnerDraft draft = compileOwnerDraft(key, admission); HydrologyOwnerDraft draft = compileOwnerDraft(
validateOwnerDraftReach(draft); key,
admission,
key.equals(context.root())
);
CrossTileResolvedOwner resolved = new CrossTileResolvedOwner(draft, admission.observedRejections()); CrossTileResolvedOwner resolved = new CrossTileResolvedOwner(draft, admission.observedRejections());
context.remember(key, resolved); context.remember(key, resolved);
resolvedOwners.put(key, resolved); resolvedOwners.put(key, resolved.withoutFootprintCompiler());
owned.complete(resolved); owned.complete(resolved);
return resolved; return resolved;
} catch (Throwable failure) { } catch (Throwable failure) {
@@ -3955,7 +3995,7 @@ public final class HydrologyPlanner {
int lowest = center.naturalHeight(); int lowest = center.naturalHeight();
for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) { for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) {
for (double direction = -1D; direction <= 1D; direction += 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.x() + normalX * distance * direction),
(int) StrictMath.round(point.z() + normalZ * distance * direction)); (int) StrictMath.round(point.z() + normalZ * distance * direction));
if (side != null) { if (side != null) {
@@ -4597,8 +4637,8 @@ public final class HydrologyPlanner {
private boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) { private boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) {
List<HydrologyPoint> crossing = rasterLine(start, end); List<HydrologyPoint> crossing = rasterLine(start, end);
HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); HydrologyTerrainSample startTerrain = sampleLandBasisWithoutSlope(start.x(), start.z());
HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); HydrologyTerrainSample endTerrain = sampleLandBasisWithoutSlope(end.x(), end.z());
if (startTerrain == null || endTerrain == null) { if (startTerrain == null || endTerrain == null) {
return false; return false;
} }
@@ -4606,7 +4646,7 @@ public final class HydrologyPlanner {
int threshold = settings.hydraulics().waterfallMinimumDrop(); int threshold = settings.hydraulics().waterfallMinimumDrop();
for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) {
HydrologyPoint point = crossing.get(pointIndex); HydrologyPoint point = crossing.get(pointIndex);
HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); HydrologyTerrainSample terrain = sampleLandBasisWithoutSlope(point.x(), point.z());
if (terrain == null if (terrain == null
|| !terrain.transitAllowed() || !terrain.transitAllowed()
|| boundaryHeight - terrain.naturalHeight() >= threshold) { || boundaryHeight - terrain.naturalHeight() >= threshold) {
@@ -4679,7 +4719,7 @@ public final class HydrologyPlanner {
List<HydrologyPoint> crossing = rasterLine(start, end); List<HydrologyPoint> crossing = rasterLine(start, end);
for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) {
HydrologyPoint point = crossing.get(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()) { if (terrain == null || terrain.ocean() || !terrain.transitAllowed()) {
return false; return false;
} }
@@ -6531,7 +6571,7 @@ public final class HydrologyPlanner {
HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance); HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance);
int bankX = bankPoint.x(); int bankX = bankPoint.x();
int bankZ = bankPoint.z(); int bankZ = bankPoint.z();
return sampleLandBasis(bankX, bankZ); return sampleLandBasisWithoutSlope(bankX, bankZ);
} }
private HydrologyPoint surfaceBankPoint(HydrologyPoint point, Direction tangent, int signedDistance) { private HydrologyPoint surfaceBankPoint(HydrologyPoint point, Direction tangent, int signedDistance) {
@@ -7721,6 +7761,33 @@ public final class HydrologyPlanner {
return terrain == null || terrain.ocean() ? null : terrain; 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) { private HydrologyTerrainSample sampleDetailed(int blockX, int blockZ) {
PlanningSamples samples = planningSamples.get(); PlanningSamples samples = planningSamples.get();
if (samples == null) { if (samples == null) {
@@ -7790,6 +7857,7 @@ public final class HydrologyPlanner {
private static final class PlanningSamples { private static final class PlanningSamples {
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basis; private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basis;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> basisWithoutSlope;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> detailed; private final Long2ObjectOpenHashMap<HydrologyTerrainSample> detailed;
private final Long2ObjectOpenHashMap<HydrologyRoutingTerrainSampler.NaturalClassification> classifications; private final Long2ObjectOpenHashMap<HydrologyRoutingTerrainSampler.NaturalClassification> classifications;
private final HashMap<HydrologyGeometrySampler.Request, Integer> geometry; private final HashMap<HydrologyGeometrySampler.Request, Integer> geometry;
@@ -7802,6 +7870,7 @@ public final class HydrologyPlanner {
private PlanningSamples() { private PlanningSamples() {
this.basis = new Long2ObjectOpenHashMap<>(); this.basis = new Long2ObjectOpenHashMap<>();
this.basisWithoutSlope = new Long2ObjectOpenHashMap<>();
this.detailed = new Long2ObjectOpenHashMap<>(); this.detailed = new Long2ObjectOpenHashMap<>();
this.classifications = new Long2ObjectOpenHashMap<>(); this.classifications = new Long2ObjectOpenHashMap<>();
this.geometry = new HashMap<>(); this.geometry = new HashMap<>();
@@ -8295,8 +8364,15 @@ public final class HydrologyPlanner {
private record HydrologyOwnerDraft( private record HydrologyOwnerDraft(
HydrologyTileKey key, HydrologyTileKey key,
HydrologyCaveCourseFilter.Result result, HydrologyCaveCourseFilter.Result result,
List<HydrologyDiagnosticCandidate> diagnostics List<HydrologyDiagnosticCandidate> diagnostics,
HydrologyFootprintCompiler footprintCompiler
) { ) {
private HydrologyOwnerDraft withoutFootprintCompiler() {
if (footprintCompiler == null) {
return this;
}
return new HydrologyOwnerDraft(key, result, diagnostics, null);
}
} }
private record MaterializedHydrology( private record MaterializedHydrology(
@@ -8528,7 +8604,8 @@ public final class HydrologyPlanner {
throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound."); throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound.");
} }
CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner); 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."); throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results.");
} }
if (resolved.size() > maximumOwners) { if (resolved.size() > maximumOwners) {
@@ -8555,6 +8632,10 @@ public final class HydrologyPlanner {
private int ownerCount() { private int ownerCount() {
return resolved.size(); return resolved.size();
} }
private HydrologyTileKey root() {
return root;
}
} }
private record CrossTileResolvedOwner( private record CrossTileResolvedOwner(
@@ -8565,6 +8646,13 @@ public final class HydrologyPlanner {
Objects.requireNonNull(draft, "draft"); Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections")); 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) { private record CrossTileRejectionKey(long courseId, long winnerCourseId) {
@@ -1,49 +1,70 @@
package art.arcane.iris.engine.hydrology; package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey; import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Caffeine;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread; 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.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
public final class HydrologyTileCache implements AutoCloseable { public final class HydrologyTileCache implements AutoCloseable {
private static final int DEFAULT_MAXIMUM_ENTRIES = 64; 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 MAXIMUM_COMPOSED_CHUNKS = 256;
private static final int CHUNK_SIZE = 16; private static final int CHUNK_SIZE = 16;
private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE; private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE;
private static final Cache<SharedTileKey, HydrologyTile> SHARED_TILES = Caffeine.newBuilder()
.maximumSize(MAXIMUM_SHARED_ENTRIES)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build();
private final HydrologyPlanner planner; private final HydrologyPlanner planner;
private final int maximumEntries; private final int maximumEntries;
private final Cache<HydrologyTileKey, HydrologyTile> tiles; private final Cache<HydrologyTileKey, HydrologyTile> tiles;
private final Cache<Long, ChunkColumns> composedChunks; private final Cache<Long, ChunkColumns> composedChunks;
private final AtomicLong cacheEpoch; private final AtomicLong cacheEpoch;
private final AtomicBoolean closed;
private final ThreadLocal<LocalChunkColumns> localChunkColumns; private final ThreadLocal<LocalChunkColumns> localChunkColumns;
private final Executor prefetchExecutor; private final Executor prefetchExecutor;
private final ConcurrentLinkedQueue<HydrologyTileKey> prefetchQueue;
private final Set<HydrologyTileKey> queuedPrefetches;
private final AtomicBoolean prefetchActive;
private final ConcurrentHashMap<HydrologyTileKey, CompletableFuture<HydrologyTile>> planning; private final ConcurrentHashMap<HydrologyTileKey, CompletableFuture<HydrologyTile>> planning;
private final BooleanSupplier waitingForbidden; private final BooleanSupplier waitingForbidden;
private volatile SharedCacheScope sharedCacheScope;
private volatile StudioHydrologyTileStore persistentStore;
private volatile boolean neighbourPrefetchEnabled;
public HydrologyTileCache(HydrologyPlanner planner) { public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES); this(planner, DEFAULT_MAXIMUM_ENTRIES, null, null, null);
} }
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) { public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) {
this(planner, maximumEntries, null); this(planner, maximumEntries, null, null, null);
} }
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) { 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, int maximumEntries,
Executor prefetchExecutor, Executor prefetchExecutor,
BooleanSupplier waitingForbidden 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.planner = Objects.requireNonNull(planner, "planner");
this.prefetchExecutor = prefetchExecutor; this.prefetchExecutor = prefetchExecutor;
this.waitingForbidden = waitingForbidden; this.waitingForbidden = waitingForbidden;
this.sharedCacheScope = sharedCacheScope;
this.persistentStore = null;
this.planning = new ConcurrentHashMap<>(); this.planning = new ConcurrentHashMap<>();
if (maximumEntries < 1) { if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive."); throw new IllegalArgumentException("maximumEntries must be positive.");
} }
this.maximumEntries = maximumEntries; this.maximumEntries = maximumEntries;
this.prefetchQueue = new ConcurrentLinkedQueue<>();
this.queuedPrefetches = ConcurrentHashMap.newKeySet();
this.prefetchActive = new AtomicBoolean();
this.tiles = Caffeine.newBuilder() this.tiles = Caffeine.newBuilder()
.maximumSize(maximumEntries) .maximumSize(maximumEntries)
.build(); .build();
@@ -74,7 +110,9 @@ public final class HydrologyTileCache implements AutoCloseable {
.maximumSize(MAXIMUM_COMPOSED_CHUNKS) .maximumSize(MAXIMUM_COMPOSED_CHUNKS)
.build(); .build();
this.cacheEpoch = new AtomicLong(); this.cacheEpoch = new AtomicLong();
this.closed = new AtomicBoolean();
this.localChunkColumns = new ThreadLocal<>(); this.localChunkColumns = new ThreadLocal<>();
this.neighbourPrefetchEnabled = true;
} }
/** /**
@@ -87,17 +125,16 @@ public final class HydrologyTileCache implements AutoCloseable {
if (prefetchExecutor == null || plansInline()) { if (prefetchExecutor == null || plansInline()) {
return planInline(key); return planInline(key);
} }
return awaitTile(planAsync(key)); return awaitTile(planDemandAsync(key));
} }
/** private boolean plansInline() {
* A pool worker plans on its own thread. Parking the workers of one pool on tasks of another is how if (prefetchExecutor instanceof MultiBurst burst) {
* two saturated pools deadlock each other (generation workers waiting for planning that waits for return burst.ownsCurrentThread();
* 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. return prefetchExecutor instanceof ForkJoinPool pool
*/ && Thread.currentThread() instanceof ForkJoinWorkerThread worker
private static boolean plansInline() { && worker.getPool() == pool;
return Thread.currentThread() instanceof ForkJoinWorkerThread;
} }
private HydrologyTile planInline(HydrologyTileKey key) { private HydrologyTile planInline(HydrologyTileKey key) {
@@ -116,8 +153,45 @@ public final class HydrologyTileCache implements AutoCloseable {
* cached so the next request plans it again. * cached so the next request plans it again.
*/ */
private HydrologyTile planOrEmpty(HydrologyTileKey key) { 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 { 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) { } catch (RuntimeException failure) {
if (interrupted(failure)) { if (interrupted(failure)) {
throw failure; throw failure;
@@ -205,6 +279,10 @@ public final class HydrologyTileCache implements AutoCloseable {
public void clear() { public void clear() {
cacheEpoch.incrementAndGet(); cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts(); planner.clearOwnerDrafts();
synchronized (prefetchQueue) {
prefetchQueue.clear();
queuedPrefetches.clear();
}
tiles.invalidateAll(); tiles.invalidateAll();
composedChunks.invalidateAll(); composedChunks.invalidateAll();
localChunkColumns.remove(); localChunkColumns.remove();
@@ -219,11 +297,56 @@ public final class HydrologyTileCache implements AutoCloseable {
return maximumEntries; 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 @Override
public void close() { public void close() {
closed.set(true);
clear(); 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) { private ChunkColumns chunkColumns(int blockX, int blockZ) {
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, 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. * needs before its prefetch finishes simply joins that computation.
*/ */
private void prefetchNeighbours(int minimumTileX, int maximumTileX, int minimumTileZ, int maximumTileZ) { private void prefetchNeighbours(int minimumTileX, int maximumTileX, int minimumTileZ, int maximumTileZ) {
if (prefetchExecutor == null) { if (prefetchExecutor == null || !neighbourPrefetchEnabled) {
return; return;
} }
for (int tileZ = minimumTileZ - 1; tileZ <= maximumTileZ + 1; tileZ++) { for (int tileZ = minimumTileZ - 1; tileZ <= maximumTileZ + 1; tileZ++) {
@@ -319,11 +442,7 @@ public final class HydrologyTileCache implements AutoCloseable {
} }
} }
/** /** Plans tiles that touch the block area in nearest-first order without flooding the planner. */
* 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.
*/
public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) { public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) {
if (prefetchExecutor == null) { if (prefetchExecutor == null) {
return; return;
@@ -342,16 +461,70 @@ public final class HydrologyTileCache implements AutoCloseable {
keys.add(new HydrologyTileKey(tileX, tileZ)); 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::tileZ)
.thenComparingInt(HydrologyTileKey::tileX)); .thenComparingInt(HydrologyTileKey::tileX));
for (HydrologyTileKey key : keys) { for (HydrologyTileKey key : keys) {
prefetch(key); if (!prefetch(key) && speculativeEntryCount() >= maximumEntries) {
break;
}
} }
} }
private void prefetch(HydrologyTileKey key) { private boolean prefetch(HydrologyTileKey key) {
planAsync(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); int end = Math.min(loaded.length, start + batch);
futures.clear(); futures.clear();
for (int index = start; index < end; index++) { 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++) { for (int index = start; index < end; index++) {
loaded[index] = awaitTile(futures.get(index - start)); loaded[index] = awaitTile(futures.get(index - start));
@@ -416,6 +589,11 @@ public final class HydrologyTileCache implements AutoCloseable {
return future; return future;
} }
private CompletableFuture<HydrologyTile> planDemandAsync(HydrologyTileKey key) {
queuedPrefetches.remove(key);
return planAsync(key);
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) { private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
try { try {
return future.join(); return future.join();
@@ -518,4 +696,27 @@ public final class HydrologyTileCache implements AutoCloseable {
private record LocalChunkColumns(long epoch, ChunkColumns columns) { 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) {
}
} }
@@ -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<HydrologyTile> 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<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
List<PersistedCourse> courses,
List<PersistedCavePlan> cavePlans,
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
List<HydrologyColumnSample> columns
) {
private static PersistedTile from(HydrologyTile tile) {
ArrayList<PersistedCourse> courses = new ArrayList<>(tile.courses().size());
for (RiverCourse course : tile.courses()) {
courses.add(PersistedCourse.from(course));
}
ArrayList<PersistedCavePlan> 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<RiverCourse> restoredCourses = new ArrayList<>(courses.size());
for (PersistedCourse course : courses) {
restoredCourses.add(Objects.requireNonNull(course, "course").toCourse());
}
ArrayList<HydrologyCavePlan> restoredCavePlans = new ArrayList<>(cavePlans.size());
for (PersistedCavePlan cavePlan : cavePlans) {
restoredCavePlans.add(Objects.requireNonNull(cavePlan, "cavePlan").toPlan());
}
HashMap<Long, HydrologyColumnSample> 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<DrainageEdge> drainageEdges,
List<HydraulicSegment> 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<PersistedCaveCell> cells,
Long arbitrationWinnerSourceId
) {
private static PersistedCavePlan from(HydrologyCavePlan plan) {
ArrayList<PersistedCaveCell> 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<CavePosition, HydrologyCaveAction> actions = new LinkedHashMap<>();
LinkedHashMap<CavePosition, CaveVoxelPrecondition> 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.");
}
}
}
}
@@ -111,6 +111,11 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
return basis.terrain().withSlope(localSlope(blockX, blockZ, basis.naturalHeight())); 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) { TerrainBasis basis(int blockX, int blockZ) {
long packed = pack(blockX, blockZ); long packed = pack(blockX, blockZ);
synchronized (lock) { synchronized (lock) {
@@ -69,6 +69,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.IntConsumer; import java.util.function.IntConsumer;
@@ -168,11 +169,28 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
return cache.columnAt(blockX, blockZ); 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) { public void prefetchArea(int minimumBlockX, int minimumBlockZ, int maximumBlockX, int maximumBlockZ, int centreBlockX, int centreBlockZ) {
cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, 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) { public void prepareChunkColumns(int blockX, int blockZ) {
cache.prepareChunkColumns(blockX, blockZ); cache.prepareChunkColumns(blockX, blockZ);
} }
@@ -447,6 +447,7 @@ public class IrisCaveCarver3D {
int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnIndices = scratch.activeColumnIndices;
int[] activeColumnTopY = scratch.activeColumnTopY; int[] activeColumnTopY = scratch.activeColumnTopY;
int activeColumnCount = 0; int activeColumnCount = 0;
int activeMaxY = Integer.MIN_VALUE;
for (int index = 0; index < 256; index++) { for (int index = 0; index < 256; index++) {
double columnWeight = clampedWeights[index]; double columnWeight = clampedWeights[index];
@@ -458,6 +459,7 @@ public class IrisCaveCarver3D {
passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty);
activeColumnIndices[activeColumnCount] = index; activeColumnIndices[activeColumnCount] = index;
activeColumnTopY[activeColumnCount] = columnMaxY[index]; activeColumnTopY[activeColumnCount] = columnMaxY[index];
activeMaxY = Math.max(activeMaxY, columnMaxY[index]);
activeColumnCount++; activeColumnCount++;
} }
@@ -469,11 +471,12 @@ public class IrisCaveCarver3D {
double[] planeThresholdLimit = scratch.planeThresholdLimit; double[] planeThresholdLimit = scratch.planeThresholdLimit;
boolean[] planeCarve = scratch.planeCarve; boolean[] planeCarve = scratch.planeCarve;
int minSection = PowerOfTwoCoordinates.floorDivPow2(minY, 4); 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++) { for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(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<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex); MatterSlice<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex);
for (int y = sectionMinY; y <= sectionMaxY; y++) { for (int y = sectionMinY; y <= sectionMaxY; y++) {
@@ -579,6 +582,7 @@ public class IrisCaveCarver3D {
int[] activeColumnIndices = scratch.activeColumnIndices; int[] activeColumnIndices = scratch.activeColumnIndices;
int[] activeColumnTopY = scratch.activeColumnTopY; int[] activeColumnTopY = scratch.activeColumnTopY;
int activeColumnCount = 0; int activeColumnCount = 0;
int activeMaxY = Integer.MIN_VALUE;
for (int index = 0; index < 256; index++) { for (int index = 0; index < 256; index++) {
double columnWeight = clampedWeights[index]; double columnWeight = clampedWeights[index];
@@ -590,6 +594,7 @@ public class IrisCaveCarver3D {
passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty); passThreshold[index] = columnThreshold[index] + thresholdBoost - ((1D - columnWeight) * thresholdPenalty);
activeColumnIndices[activeColumnCount] = index; activeColumnIndices[activeColumnCount] = index;
activeColumnTopY[activeColumnCount] = columnMaxY[index]; activeColumnTopY[activeColumnCount] = columnMaxY[index];
activeMaxY = Math.max(activeMaxY, columnMaxY[index]);
activeColumnCount++; activeColumnCount++;
} }
@@ -601,7 +606,8 @@ public class IrisCaveCarver3D {
double[] planeThresholdLimit = scratch.planeThresholdLimit; double[] planeThresholdLimit = scratch.planeThresholdLimit;
boolean[] planeCarve = scratch.planeCarve; boolean[] planeCarve = scratch.planeCarve;
int minSection = PowerOfTwoCoordinates.floorDivPow2(minY, 4); 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); int effectiveAdaptiveSampleStep = Math.max(adaptiveSampleStep, ADAPTIVE_DEEP_SAMPLE_STEP);
double effectiveAdaptiveThresholdMargin = resolveAdaptivePlaneThresholdMargin( double effectiveAdaptiveThresholdMargin = resolveAdaptivePlaneThresholdMargin(
adaptiveThresholdMargin, adaptiveThresholdMargin,
@@ -611,7 +617,7 @@ public class IrisCaveCarver3D {
for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(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<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex); MatterSlice<MatterCavern> cavernSlice = resolveCavernSlice(scratch, chunk, sectionIndex);
for (int y = sectionMinY; y <= sectionMaxY; y++) { 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( private void classifyAdaptivePlaneColumnsWarpOnly(
CaveCarveScratch scratch, CaveCarveScratch scratch,
int x0, int x0,
@@ -1615,6 +1656,21 @@ public class IrisCaveCarver3D {
double[] remainingMin, double[] remainingMin,
double[] remainingMax double[] remainingMax
) { ) {
if (adaptiveSampleStep >= ADAPTIVE_DEEP_SAMPLE_STEP) {
classifyDeepAdaptivePlaneFromSamples(
scratch,
planeColumnIndices,
planeThresholdLimit,
planeCount,
planeCarve,
adaptiveSampleStep,
adaptivePlaneDensity,
axisCells,
axisSamples
);
return;
}
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction; double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity; double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
prepareAdaptivePlaneColumns( prepareAdaptivePlaneColumns(
@@ -2235,17 +2291,29 @@ public class IrisCaveCarver3D {
} }
int support = 0; int support = 0;
int misses = 0;
if (isDensitySolid(scratch, x + 1, y, z, threshold)) { if (isDensitySolid(scratch, x + 1, y, z, threshold)) {
support++; support++;
} else {
misses++;
} }
if (isDensitySolid(scratch, x - 1, y, z, threshold)) { if (isDensitySolid(scratch, x - 1, y, z, threshold)) {
support++; support++;
} else if (++misses >= 2) {
return false;
} }
if (isDensitySolid(scratch, x, y, z + 1, threshold)) { if (isDensitySolid(scratch, x, y, z + 1, threshold)) {
support++; support++;
} else if (++misses >= 2) {
return false;
} }
if (isDensitySolid(scratch, x, y, z - 1, threshold)) { if (isDensitySolid(scratch, x, y, z - 1, threshold)) {
support++; support++;
if (support >= 4) {
return true;
}
} else if (++misses >= 2) {
return false;
} }
if (isDensitySolid(scratch, x, aboveY, z, threshold)) { if (isDensitySolid(scratch, x, aboveY, z, threshold)) {
support++; support++;
@@ -620,6 +620,9 @@ public class IrisDimension extends IrisRegistrant {
if (biome == null) { if (biome == null) {
continue; continue;
} }
if (biome.isCompatExcluded()) {
continue;
}
String loadKey = biome.getLoadKey(); String loadKey = biome.getLoadKey();
if (loadKey == null || loadKey.isBlank() || biomes.containsKey(loadKey)) { if (loadKey == null || loadKey.isBlank() || biomes.containsKey(loadKey)) {
@@ -240,23 +240,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return new Location(world, 0.5D, y, 0.5D); return new Location(world, 0.5D, y, 0.5D);
} }
private int hydrologyTileSize() { /** Starts nearest-first hydrology planning around a new world's initial spawn. */
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.
*/
private void prefetchSpawnHydrology(Engine engine, World world) { private void prefetchSpawnHydrology(Engine engine, World world) {
if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return; 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) { private void updateSpawnLocation(World world) {
try { try {
Location initialSpawn = getInitialSpawnLocation(world); Location initialSpawn = getInitialSpawnLocation(world);
@@ -62,6 +62,10 @@ public class CachedDoubleStream2D extends BasicStream<Double> implements Procedu
return cache.getMaxSize(); return cache.getMaxSize();
} }
public void setMaximumChunks(int maximumChunks) {
cache.setMaximumChunks(maximumChunks);
}
@Override @Override
public boolean isClosed() { public boolean isClosed() {
return engine.isClosed(); return engine.isClosed();
@@ -9,6 +9,9 @@ import java.io.ByteArrayOutputStream;
import java.io.PrintStream; import java.io.PrintStream;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets; 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.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; 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 @Test
public void completionSummaryWaitsForAsyncCloseDrain() { public void completionSummaryWaitsForAsyncCloseDrain() {
String output = runCompletionOnClose(false); 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) { private String runCompletionOnClose(boolean failLast) {
IrisSettings previousSettings = IrisSettings.settings; IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousOut = System.out; PrintStream previousOut = System.out;
@@ -111,6 +167,16 @@ public class IrisPregeneratorInitTest {
private static final class TrackingPregeneratorMethod implements PregeneratorMethod { private static final class TrackingPregeneratorMethod implements PregeneratorMethod {
private final AtomicInteger initCalls = new AtomicInteger(); private final AtomicInteger initCalls = new AtomicInteger();
private final AtomicInteger saveCalls = new AtomicInteger(); private final AtomicInteger saveCalls = new AtomicInteger();
private final List<String> lifecycle = new ArrayList<>();
private final boolean interruptOnClose;
private TrackingPregeneratorMethod() {
this(false);
}
private TrackingPregeneratorMethod(boolean interruptOnClose) {
this.interruptOnClose = interruptOnClose;
}
@Override @Override
public void init() { public void init() {
@@ -119,6 +185,9 @@ public class IrisPregeneratorInitTest {
@Override @Override
public void close() { public void close() {
if (interruptOnClose) {
Thread.currentThread().interrupt();
}
} }
@Override @Override
@@ -142,6 +211,17 @@ public class IrisPregeneratorInitTest {
@Override @Override
public void generateChunk(int x, int z, PregenListener listener) { 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 @Override
@@ -254,6 +334,16 @@ public class IrisPregeneratorInitTest {
} }
private static final class NoOpPregenListener implements PregenListener { private static final class NoOpPregenListener implements PregenListener {
private final AtomicBoolean closeInterrupted;
private NoOpPregenListener() {
this(null);
}
private NoOpPregenListener(AtomicBoolean closeInterrupted) {
this.closeInterrupted = closeInterrupted;
}
@Override @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) { 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 @Override
public void onClose() { public void onClose() {
if (closeInterrupted != null) {
closeInterrupted.set(Thread.currentThread().isInterrupted());
}
} }
@Override @Override
@@ -5,19 +5,20 @@ import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; 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.CachedDoubleStream2D;
import art.arcane.iris.util.project.stream.utility.CachedStream2D; import art.arcane.iris.util.project.stream.utility.CachedStream2D;
import org.junit.Test; 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.CompletableFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; 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<IrisBiome> 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") @SuppressWarnings("unchecked")
private static Engine engineWithProfile(int cacheSize, boolean fastCache) { private static Engine engineWithProfile(int cacheSize, boolean fastCache) {
Engine engine = mock(Engine.class); Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class); IrisComplex complex = mock(IrisComplex.class);
CachedDoubleStream2D heightStream = mock(CachedDoubleStream2D.class); CachedDoubleStream2D heightStream = mock(CachedDoubleStream2D.class);
CachedDoubleStream2D naturalHeightStream = mock(CachedDoubleStream2D.class);
CachedStream2D<IrisBiome> caveBiomeStream = mock(CachedStream2D.class); CachedStream2D<IrisBiome> caveBiomeStream = mock(CachedStream2D.class);
when(heightStream.getMaxSize()).thenReturn((long) cacheSize * 256L); when(heightStream.getMaxSize()).thenReturn((long) cacheSize * 256L);
when(naturalHeightStream.getMaxSize()).thenReturn((long) cacheSize * 256L);
when(caveBiomeStream.usesFastCache()).thenReturn(fastCache); when(caveBiomeStream.usesFastCache()).thenReturn(fastCache);
when(complex.getHeightStream()).thenReturn(heightStream); when(complex.getHeightStream()).thenReturn(heightStream);
when(complex.getNaturalHeightStream()).thenReturn(naturalHeightStream);
when(complex.getCaveBiomeStream()).thenReturn(caveBiomeStream); when(complex.getCaveBiomeStream()).thenReturn(caveBiomeStream);
when(engine.getComplex()).thenReturn(complex); when(engine.getComplex()).thenReturn(complex);
return engine; return engine;
@@ -13,8 +13,8 @@ import static org.junit.Assert.assertTrue;
public class PregenTaskLatticeOrderTest { public class PregenTaskLatticeOrderTest {
@Test @Test
public void regionOrderVisitsEveryChunkOnceInStrideSpacedLattices() { public void regionOrderVisitsEveryChunkOnceInStrideSpacedLattices() {
KList<Position2> order = new KList<>(); KList<Position2> pulled = regionChunks();
PregenTask.iterateRegion(0, 0, (x, z) -> order.add(new Position2(x, z))); KList<Position2> order = PregenTask.latticeOrder(pulled);
assertEquals(1024, order.size()); assertEquals(1024, order.size());
Set<Long> unique = new HashSet<>(); Set<Long> unique = new HashSet<>();
@@ -47,4 +47,40 @@ public class PregenTaskLatticeOrderTest {
assertTrue("consecutive chunks " + previous + " and " + current, distance >= PregenTask.CHUNK_LATTICE_STRIDE); assertTrue("consecutive chunks " + previous + " and " + current, distance >= PregenTask.CHUNK_LATTICE_STRIDE);
} }
} }
@Test
public void centerRegionStartsAtThePullAndVisitsEveryChunk() {
KList<Position2> 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<Long> 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<Position2> 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<Position2> regionChunks() {
KList<Position2> 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;
}
} }
@@ -56,6 +56,21 @@ public class AsyncPregenMethodConcurrencyCapTest {
assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(0, false)); 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 @Test
public void slowRequestObservationDoesNotCompleteOrReplacePendingFuture() { public void slowRequestObservationDoesNotCompleteOrReplacePendingFuture() {
CompletableFuture<String> request = new CompletableFuture<>(); CompletableFuture<String> request = new CompletableFuture<>();
@@ -8,8 +8,10 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.Position2;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.bukkit.World;
import java.io.File; import java.io.File;
import java.lang.reflect.Constructor;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -92,9 +94,31 @@ public class CachedPregenMethodCompletionTest {
assertEquals(expected.size(), listener.generatedCached.get()); assertEquals(expected.size(), listener.generatedCached.get());
} }
@Test
public void pregenStartReachesTheSelectedMethodThroughEveryWrapper() throws Exception {
Constructor<AsyncOrMedievalPregenMethod> selectorConstructor = AsyncOrMedievalPregenMethod.class
.getDeclaredConstructor(PregeneratorMethod.class);
selectorConstructor.setAccessible(true);
AsyncOrMedievalPregenMethod selector = selectorConstructor.newInstance(underlying);
Constructor<HybridPregenMethod> 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 static final class CapturingMethod implements PregeneratorMethod {
private final AtomicInteger generateChunkCalls = new AtomicInteger(); private final AtomicInteger generateChunkCalls = new AtomicInteger();
private final AtomicInteger generateRegionCalls = 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<PregenListener> capturedListener = new AtomicReference<>(); private final AtomicReference<PregenListener> capturedListener = new AtomicReference<>();
@Override @Override
@@ -130,6 +154,13 @@ public class CachedPregenMethodCompletionTest {
capturedListener.set(listener); capturedListener.set(listener);
} }
@Override
public void onPregenStart(int centerBlockX, int centerBlockZ) {
pregenStartCalls.incrementAndGet();
this.centerBlockX.set(centerBlockX);
this.centerBlockZ.set(centerBlockZ);
}
@Override @Override
public Mantle getMantle() { public Mantle getMantle() {
return null; return null;
@@ -33,6 +33,69 @@ public class PregenMantleCleanupContractTest {
assertFalse(cleanup.contains("forceCleanupChunk")); 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<Void> 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 { private static String read(String relativePath) throws IOException {
Path current; Path current;
try { try {
@@ -71,4 +134,11 @@ public class PregenMantleCleanupContractTest {
} }
throw new IllegalArgumentException("Unclosed source contract method: " + signature); 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);
}
} }
@@ -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));
}
}
@@ -86,4 +86,5 @@ public class SharedCornerBoundsTest {
} }
assertFalse(torn.get()); assertFalse(torn.get());
} }
} }
@@ -1014,14 +1014,20 @@ public class HydrologyFootprintCompilerTest {
List.of(surface, cave) List.of(surface, cave)
); );
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); 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( HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(
HydrologyPlannerSettings.defaults(), HydrologyPlannerSettings.defaults(),
(int x, int z) -> terrain, sampler,
request -> request.minimum() request -> request.minimum()
); );
HydrologyFootprintCompiler.ValidationRaster first = compiler.compileValidation(List.of(course)); HydrologyFootprintCompiler.ValidationRaster first = compiler.compileValidation(List.of(course));
HydrologyFootprintCompiler.ValidationRaster retry = compiler.compileValidation(List.of(course)); HydrologyFootprintCompiler.ValidationRaster retry = compiler.compileValidation(List.of(course));
int validationSamples = terrainSamples.get();
assertEquals(0, compiler.fullMaterializationCount()); assertEquals(0, compiler.fullMaterializationCount());
assertEquals(first.columns(), retry.columns()); assertEquals(first.columns(), retry.columns());
@@ -1030,8 +1036,15 @@ public class HydrologyFootprintCompilerTest {
).anyMatch((HydrologyColumnLayer layer) -> layer.feature().segmentId() == cave.id())); ).anyMatch((HydrologyColumnLayer layer) -> layer.feature().segmentId() == cave.id()));
RiverFootprint footprint = compiler.compile(List.of(course)); 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(1, compiler.fullMaterializationCount());
assertEquals(validationSamples, terrainSamples.get());
assertEquals(freshFootprint, footprint);
for (HydrologyColumnSample sample : footprint.columns().values()) { for (HydrologyColumnSample sample : footprint.columns().values()) {
assertEquals( assertEquals(
"planned surface at " + sample.x() + "," + sample.z(), "planned surface at " + sample.x() + "," + sample.z(),
@@ -1860,6 +1860,33 @@ public class HydrologyPlannerTest {
assertEquals(firstOne, secondOne); 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<HydrologyTileKey> reusedKeys = List.of(
new HydrologyTileKey(-1, -1),
new HydrologyTileKey(0, -1),
new HydrologyTileKey(-1, 0),
new HydrologyTileKey(0, 0)
);
ArrayList<HydrologyTile> 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( private HydrologyPlanner planner(
long seed, long seed,
HydrologyPlannerSettings settings, HydrologyPlannerSettings settings,
@@ -1,5 +1,6 @@
package art.arcane.iris.engine.hydrology; package art.arcane.iris.engine.hydrology;
import art.arcane.iris.util.common.parallel.MultiBurst;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
@@ -7,14 +8,17 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; 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.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class HydrologyTileCacheTest { 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<HydrologyTile> 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 @Test
public void chunkPreparationUsesHeightFillColumnOrder() { public void chunkPreparationUsesHeightFillColumnOrder() {
HydrologyPlanner planner = mock(HydrologyPlanner.class); 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() { private HydrologyPlannerSettings featureSettings() {
HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source(
true, true,
@@ -408,6 +505,53 @@ public class HydrologyTileCacheTest {
assertEquals((width + 2) * (height + 2), eager.size()); 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<HydrologyTileKey> 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 @Test
public void areaPrefetchPlansEveryTouchingTileNearestTheCentreFirst() { public void areaPrefetchPlansEveryTouchingTileNearestTheCentreFirst() {
HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -601,7 +745,7 @@ public class HydrologyTileCacheTest {
} }
} }
@Test @Test
public void forkJoinWorkersPlanOnTheirOwnThreadInsteadOfTheExecutor() throws Exception { public void unrelatedForkJoinWorkersUseTheHydrologyExecutor() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class); HydrologyTile tile = mock(HydrologyTile.class);
when(planner.settings()).thenReturn(emptySettings()); when(planner.settings()).thenReturn(emptySettings());
@@ -611,7 +755,7 @@ public class HydrologyTileCacheTest {
return tile; return tile;
}).when(planner).plan(any(HydrologyTileKey.class)); }).when(planner).plan(any(HydrologyTileKey.class));
ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "hydrology-planning-executor")); 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 { try {
HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor);
@@ -621,12 +765,93 @@ public class HydrologyTileCacheTest {
assertSame(tile, planned); assertSame(tile, planned);
assertEquals(2, batch.size()); assertEquals(2, batch.size());
assertEquals(3, planningThreads.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 { } finally {
pool.shutdownNow(); pool.shutdownNow();
executor.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<String> 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<Thread> 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<Runnable> 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 @Test
public void plannedCheckNeverPlansItselfButAsksThePrefetchExecutorForMissingTiles() { public void plannedCheckNeverPlansItselfButAsksThePrefetchExecutorForMissingTiles() {
HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -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))
);
}
}
@@ -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 @Test
public void adjacentGridsShareBasesAndProduceIdenticalOverlap() { public void adjacentGridsShareBasesAndProduceIdenticalOverlap() {
AtomicInteger calls = new AtomicInteger(); AtomicInteger calls = new AtomicInteger();
@@ -26,9 +26,11 @@ import org.junit.Test;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -49,6 +51,9 @@ public class IrisCaveCarver3DNearParityTest {
private static Method sampleDensityMethod; private static Method sampleDensityMethod;
private static Method aquiferCandidateMethod; private static Method aquiferCandidateMethod;
private static Method aquiferCupSupportMethod;
private static Method deepAdaptivePlaneMethod;
private static Field baseDensityField;
private static Field engineField; private static Field engineField;
private static Field dataField; private static Field dataField;
private static Field profileField; private static Field profileField;
@@ -65,6 +70,24 @@ public class IrisCaveCarver3DNearParityTest {
sampleDensityMethod.setAccessible(true); sampleDensityMethod.setAccessible(true);
aquiferCandidateMethod = IrisCaveCarver3D.class.getDeclaredMethod("isAquiferCandidate", int.class, int.class, int.class, double.class); aquiferCandidateMethod = IrisCaveCarver3D.class.getDeclaredMethod("isAquiferCandidate", int.class, int.class, int.class, double.class);
aquiferCandidateMethod.setAccessible(true); 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 = IrisCaveCarver3D.class.getDeclaredField("engine");
engineField.setAccessible(true); engineField.setAccessible(true);
dataField = IrisCaveCarver3D.class.getDeclaredField("data"); dataField = IrisCaveCarver3D.class.getDeclaredField("data");
@@ -200,6 +223,145 @@ public class IrisCaveCarver3DNearParityTest {
assertExactParity(true, true, false); 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<String, Double> 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 @Test
public void legacySampleStepTwoMatchesExactReference() throws Exception { public void legacySampleStepTwoMatchesExactReference() throws Exception {
Engine engine = createEngine(96, 90); Engine engine = createEngine(96, 90);
@@ -1154,6 +1316,23 @@ public class IrisCaveCarver3DNearParityTest {
return min; return min;
} }
private static final class CoordinateDensityCNG extends CNG {
private final Map<String, Double> densityByCoordinate;
private final List<String> sampledCoordinates = new ArrayList<>();
private CoordinateDensityCNG(Map<String, Double> 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 static final class WriterCapture {
private final MantleWriter writer; private final MantleWriter writer;
private final Set<String> carvedCells; private final Set<String> carvedCells;
@@ -9,6 +9,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -130,6 +131,34 @@ public class IrisDimensionReachableBiomesTest {
verify(biomeLoader, times(1)).load("d"); 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<IrisRegion> regionLoader = mock(ResourceLoader.class);
ResourceLoader<IrisBiome> 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<String> keys = dimension.getReachableBiomes(() -> data).stream()
.map(IrisBiome::getLoadKey)
.collect(Collectors.toSet());
assertEquals(Set.of("plains"), keys);
verify(biomeLoader, never()).load("carving/sulfur-hollows");
}
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void includesEveryBiomeReferencedByEnabledPolicyHierarchy() { public void includesEveryBiomeReferencedByEnabledPolicyHierarchy() {