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