Stabilized shutdown and Studio

- Shutdown protection initializes before enable and drains inline when retention fails
- Cleanup preserves class loader retention and closes native providers in order
- Error reports retain context and handle empty stack traces
- Studio maps retain partial coverage and suspend hidden views
- Hydrology previews refresh when plans and rasters complete
- Terrain scratch hashing spreads lattice samples across cache slots
- Plugin and probe artifacts include LWJGL libraries and native resources
- Regression tests cover shutdown, error reporting, map progress, and terrain scratch
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 23:29:21 -04:00
1 parent f64a7c6753
commit 3dc958edcf
31 files changed
+1973 -217

No files matched your search

+10 -7
View File
@@ -108,6 +108,10 @@ dependencies {
compileOnly(libs.log4j.api)
compileOnly(libs.log4j.core)
compileOnly(libs.guava)
compileOnly(libs.lwjgl)
compileOnly(libs.lwjgl.opengl)
compileOnly(libs.lwjgl.egl)
compileOnly(libs.lwjgl.jawt)
// Third Party Integrations
compileOnly(libs.nexo)
@@ -136,13 +140,6 @@ dependencies {
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false
}
// Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar.
implementation(libs.lwjgl)
implementation(libs.lwjgl.opengl)
implementation(libs.lwjgl.egl)
implementation(libs.lwjgl.jawt)
runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
// Dynamically Loaded
slim(libs.gson)
slim(libs.lru)
@@ -183,6 +180,12 @@ dependencies {
testImplementation(libs.multiverseCore)
testImplementation(libs.annotations)
testImplementation(libs.mythic)
testRuntimeOnly(libs.lwjgl)
testRuntimeOnly(libs.lwjgl.opengl)
testRuntimeOnly(libs.lwjgl.egl)
testRuntimeOnly(libs.lwjgl.jawt)
testRuntimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
testRuntimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
}
tasks.named('test').configure {
@@ -47,6 +47,7 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.function.IntBinaryOperator;
@@ -79,6 +80,7 @@ public final class HydrologyRuntime {
private final HydrologyBinding binding;
private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores;
private final boolean caveFluidPolicies;
private final AtomicLong rasterRevision = new AtomicLong();
private volatile PreparedHydrologyCacheIdentity storeIdentity;
private boolean storeOpen;
private volatile boolean closed;
@@ -210,6 +212,10 @@ public final class HydrologyRuntime {
return settings;
}
public long previewRevision() {
return cache == null ? 0L : cache.readyRevision() + rasterRevision.get();
}
public PreviewScope preview() {
return new PreviewScope(cache == null ? null : last.get());
}
@@ -696,7 +702,9 @@ public final class HydrologyRuntime {
private ChunkWaterRaster remember(long key, ChunkWaterRaster raster) {
synchronized (rasters) {
rasters.put(key, raster);
if (rasters.put(key, raster) == null) {
rasterRevision.incrementAndGet();
}
}
last.get().remember(key, raster);
return raster;
@@ -57,6 +57,7 @@ public final class HydrologyPlanCache {
private final AtomicLong demands = new AtomicLong();
private final AtomicLong speculations = new AtomicLong();
private final AtomicLong previewStamps = new AtomicLong(Long.MIN_VALUE);
private final AtomicLong readyRevision = new AtomicLong();
private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion);
private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow);
private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>();
@@ -112,6 +113,10 @@ public final class HydrologyPlanCache {
return windows.evictions.get();
}
public long readyRevision() {
return readyRevision.get();
}
public RegionPlan region(RegionKey key) {
Objects.requireNonNull(key, "key");
requireOffMainThread("region " + key.rx() + "," + key.rz());
@@ -759,7 +764,9 @@ public final class HydrologyPlanCache {
private void succeed(V value) {
release();
future.complete(value);
if (future.complete(value)) {
readyRevision.incrementAndGet();
}
shelf.evict();
}
@@ -78,8 +78,10 @@ public final class TerrainBatchScratch {
int lattice(double x, double z) {
long xBits = Double.doubleToRawLongBits(x);
long zBits = Double.doubleToRawLongBits(z);
long mixed = xBits * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(zBits * 0xC2B2AE3D27D4EB4FL, 31);
mixed ^= mixed >>> 29;
long mixed = xBits ^ Long.rotateLeft(zBits, 32);
mixed = (mixed ^ (mixed >>> 33)) * 0xFF51AFD7ED558CCDL;
mixed = (mixed ^ (mixed >>> 33)) * 0xC4CEB9FE1A85EC53L;
mixed ^= mixed >>> 33;
int mask = latticeCapacity - 1;
int slot = (int) mixed & mask;
while (true) {
@@ -1668,7 +1668,6 @@ public class IrisEngine implements Engine {
} catch (GenerationSessionException e) {
throw e;
} catch (Throwable e) {
IrisLogging.reportError(e);
fail("Failed to generate " + x + ", " + z, e);
if (e instanceof WrongEngineBroException wrongEngine) {
throw wrongEngine;
@@ -365,4 +365,10 @@ public final class BukkitPlatform implements IrisPlatform {
public void reportError(Throwable error) {
bridge().errorSink().accept(error);
}
@Override
public void reportError(String context, Throwable error) {
log(LogLevel.ERROR, context == null || context.isBlank() ? "Unhandled Iris failure." : context);
reportError(error);
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.platform.bukkit.nms;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.generation.runtime.EngineTarget;
import art.arcane.iris.spi.IrisLogging;
@@ -8,6 +9,7 @@ import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
import art.arcane.volmlib.nativelib.terrain.NativeWorldLifecyclePolicy;
import org.bukkit.generator.ChunkGenerator;
import java.io.IOException;
import java.lang.instrument.Instrumentation;
import java.util.Objects;
@@ -41,7 +43,23 @@ public enum BukkitWorldLifecyclePolicy implements NativeWorldLifecyclePolicy {
@Override
public void releaseClassLoader(ClassLoader loader) {
Agent.releaseClassLoader(loader);
Throwable providerFailure = null;
try {
SlimJar.closeNativeProviders();
} catch (IOException | RuntimeException | Error failure) {
providerFailure = failure;
}
try {
Agent.releaseClassLoader(loader);
} catch (RuntimeException | Error failure) {
if (providerFailure != null) {
failure.addSuppressed(providerFailure);
}
throw failure;
}
if (providerFailure != null) {
throw new IllegalStateException("Failed to close Iris native providers", providerFailure);
}
}
@Override
@@ -290,6 +290,10 @@ public interface INMSBinding extends NativeTerrainAccess {
boolean missingDimensionTypes(String... keys);
default void initializeLifecycle(ClassLoader loader) {
throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
}
default boolean injectBukkit() {
return true;
}
@@ -301,8 +305,8 @@ public interface INMSBinding extends NativeTerrainAccess {
default void uninjectBukkit() {
}
default boolean deferPluginClassLoaderClose() {
return false;
default void deferPluginClassLoaderClose() {
throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
}
default void releasePluginClassLoaderClose() {
@@ -276,9 +276,21 @@ final class MapCanvas extends JPanel {
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
void setActive(boolean nextActive) {
if (closed || active == nextActive) {
return;
}
active = nextActive;
if (active) {
markerTimer.start();
provisionalTimer.start();
requestRender();
} else {
controller.suspend();
hoverTimer.stop();
legendTimer.stop();
markerTimer.stop();
provisionalTimer.stop();
clearHover();
}
}
@@ -1021,6 +1033,9 @@ final class MapCanvas extends JPanel {
}
private void onTilesChanged() {
if (closed || !active) {
return;
}
tileLayerStale = true;
repaint();
refreshStatus();
@@ -112,6 +112,7 @@ public final class MapWindow implements MapCanvas.Listener {
private double worldCenterX;
private double worldCenterZ;
private MapView currentView;
private boolean windowVisible = true;
private double paletteCenterX;
private double paletteCenterZ;
@@ -190,11 +191,55 @@ public final class MapWindow implements MapCanvas.Listener {
frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() {
@Override
public void windowIconified(WindowEvent event) {
updateWindowVisibility();
}
@Override
public void windowDeiconified(WindowEvent event) {
updateWindowVisibility();
}
@Override
public void windowClosed(WindowEvent event) {
close();
}
});
frame.addComponentListener(new ComponentAdapter() {
@Override
public void componentShown(ComponentEvent event) {
updateWindowVisibility();
}
@Override
public void componentHidden(ComponentEvent event) {
updateWindowVisibility();
}
});
}
private void updateWindowVisibility() {
boolean visible = isWindowVisible();
boolean wasVisible = windowVisible;
windowVisible = visible;
canvas.setActive(visible && currentView != null && currentView.isTileMap());
if (currentView == MapView.WORLD_3D && worldExplorer != null) {
if (visible && !wasVisible) {
worldExplorer.show(worldCenterX, worldCenterZ, false);
} else if (!visible) {
worldExplorer.hide();
}
}
if (visible) {
resourceUsage.start();
} else {
resourceUsage.stop();
}
}
private boolean isWindowVisible() {
return frame.isShowing() && (frame.getExtendedState() & JFrame.ICONIFIED) == 0;
}
private JToolBar buildToolbar() {
@@ -265,6 +310,7 @@ public final class MapWindow implements MapCanvas.Listener {
}
frame.getRootPane().revalidate();
frame.getRootPane().repaint();
updateWindowVisibility();
}
private void showMap(MapView previousView, MapView view) {
@@ -272,7 +318,7 @@ public final class MapWindow implements MapCanvas.Listener {
canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
}
canvas.showView(view);
canvas.setActive(true);
canvas.setActive(isWindowVisible());
showControls(mapControls);
waterToggle.setVisible(view == MapView.BIOMES);
if (view == MapView.REGIONS) {
@@ -19,7 +19,6 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.MapLabels;
import art.arcane.iris.studio.map.components.MapLayer;
@@ -39,6 +38,7 @@ import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
@@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.function.LongSupplier;
/**
* Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn.
@@ -67,6 +68,7 @@ final class VisionRenderController implements AutoCloseable {
private static final long CACHE_BYTES = 256L * 1024L * 1024L;
private final Runnable listener;
private final LongSupplier nanoTime;
private final int workerCount;
private final ThreadPoolExecutor renderExecutor;
private final ThreadPoolExecutor probeExecutor;
@@ -79,9 +81,15 @@ final class VisionRenderController implements AutoCloseable {
private final AtomicBoolean publicationQueued = new AtomicBoolean();
private final AtomicBoolean publicationDirty = new AtomicBoolean();
private volatile Frame currentFrame;
private Future<?> activeProbe;
VisionRenderController(Runnable listener) {
this(listener, System::nanoTime);
}
VisionRenderController(Runnable listener, LongSupplier nanoTime) {
this.listener = Objects.requireNonNull(listener, "listener");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
this.workerCount = Math.max(1, Math.min(6, processors / 2));
AtomicInteger threadSequence = new AtomicInteger();
@@ -123,11 +131,10 @@ final class VisionRenderController implements AutoCloseable {
List<VisibleTile> tiles = visibleTiles(spec, renderScale);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
for (VisibleTile tile : tiles) {
tile.setTile(cachedTileServing(tile.key()));
tile.state = cachedTileServing(tile.key());
}
currentFrame = frame;
probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
cancelProbe();
startWorkers();
publish();
return frame;
@@ -141,25 +148,41 @@ final class VisionRenderController implements AutoCloseable {
startWorkers();
}
synchronized void suspend() {
currentFrame = null;
cancelProbe();
}
/**
* A tile from another zoom level covering the given one, to draw while the real tile renders.
*/
Fallback fallback(Frame frame, VisibleTile tile) {
MapTile withoutHeights = cache.get(tile.key().withHeights(false));
Fallback complete = fallback(tile.key(), false);
return complete != null || tile.tile() != null ? complete : fallback(tile.key(), true);
}
private Fallback fallback(TileKey key, boolean provisional) {
MapTile withoutHeights = fallbackTile(key.withHeights(false), provisional);
if (withoutHeights != null) {
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
}
Fallback coarser = coarserFallback(frame, tile);
return coarser != null ? coarser : finerFallback(frame, tile);
Fallback coarser = coarserFallback(key, provisional);
return coarser != null ? coarser : finerFallback(key, provisional);
}
// A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing.
private MapTile cachedTileServing(TileKey key) {
MapTile exact = cache.get(key);
private TileState cachedTileServing(TileKey key) {
CacheEntry exact = cache.get(key);
if (exact != null || key.heights()) {
return exact;
return exact == null ? null : exact.state();
}
return cache.get(key.withHeights(true));
CacheEntry shaded = cache.get(key.withHeights(true));
return shaded != null && !shaded.state().provisional() ? shaded.state() : null;
}
private MapTile fallbackTile(TileKey key, boolean provisional) {
CacheEntry entry = cache.get(key);
return entry != null && entry.state().provisional() == provisional ? entry.state().tile() : null;
}
Progress progress(Frame frame) {
@@ -168,34 +191,44 @@ final class VisionRenderController implements AutoCloseable {
}
int ready = 0;
for (VisibleTile tile : frame.tiles()) {
if (tile.tile() != null && !tile.provisional) {
if (tile.tile() != null && !tile.provisional()) {
ready++;
}
}
return new Progress(frame.tiles().size(), ready, activeWorkers.get());
}
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
synchronized <T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
Objects.requireNonNull(probe, "probe");
Objects.requireNonNull(consumer, "consumer");
if (frame == null || closed.get() || currentFrame != frame) {
return;
}
long probeRevision = probeSequence.incrementAndGet();
probeExecutor.getQueue().clear();
cancelProbe();
long probeRevision = probeSequence.get();
try {
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer));
activeProbe = probeExecutor.submit(() -> runProbe(probeRevision, probe, consumer));
} catch (RejectedExecutionException ignored) {
probeExecutor.getQueue().clear();
}
}
private void cancelProbe() {
probeSequence.incrementAndGet();
if (activeProbe != null) {
activeProbe.cancel(true);
activeProbe = null;
}
probeExecutor.getQueue().clear();
}
@Override
public synchronized void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
currentFrame = null;
cancelProbe();
renderExecutor.shutdownNow();
probeExecutor.shutdownNow();
cache.clear();
@@ -285,7 +318,7 @@ final class VisionRenderController implements AutoCloseable {
private synchronized boolean hasWork() {
Frame frame = currentFrame;
return frame != null && frame.nextMissing(tilesInFlight, false) != null;
return frame != null && frame.nextMissing(tilesInFlight, false, nanoTime.getAsLong()) != null;
}
private synchronized TileJob claimNextTile() {
@@ -293,51 +326,63 @@ final class VisionRenderController implements AutoCloseable {
if (frame == null || closed.get()) {
return null;
}
VisibleTile tile = frame.nextMissing(tilesInFlight, true);
VisibleTile tile = frame.nextMissing(tilesInFlight, true, nanoTime.getAsLong());
if (tile == null) {
return null;
}
tilesInFlight.add(tile.key());
return new TileJob(frame.spec().renderer(), tile.key());
CacheEntry cached = cache.get(tile.key());
return new TileJob(frame.spec().renderer(), tile.key(), cached == null ? null : cached.work(), tile.state);
}
private synchronized void release(TileKey key) {
private synchronized void release(TileKey key, boolean cancelled) {
tilesInFlight.remove(key);
Frame frame = currentFrame;
VisibleTile visible = frame == null ? null : frame.tile(key);
if (cancelled && visible != null && visible.needsRender(nanoTime.getAsLong(), frame.spec().renderer().previewRevision())) {
frame.nextCandidate = 0;
}
}
private void render(TileJob job) {
TileKey key = job.key();
boolean cancelled = false;
try {
long previewRevision = job.renderer().previewRevision();
double tileSpan = TILE_PIXELS * key.blocksPerPixel();
long revision = ProvisionalSampling.revision();
MapTile tile = job.renderer().renderTile(
key.tileX() * tileSpan,
key.tileZ() * tileSpan,
key.blocksPerPixel(),
TILE_PIXELS,
key.layer(),
key.heights(),
() -> !isWanted(key)
);
boolean provisional = revision != ProvisionalSampling.revision();
MapTileRenderer.TileRender work = job.work();
if (work == null) {
work = job.renderer().prepareTile(key.tileX() * tileSpan, key.tileZ() * tileSpan,
key.blocksPerPixel(), TILE_PIXELS, key.layer(), key.heights());
}
MapTile tile = work.render(() -> !isWanted(key));
boolean provisional = !work.complete();
TileState previous = job.previous();
long delay = 0L;
if (provisional) {
delay = previous == null || previous.retryDelay() == 0L
? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, previous.retryDelay() * 2L);
}
long completedAt = nanoTime.getAsLong();
TileState state = new TileState(tile, provisional, delay, completedAt + delay,
completedAt + FIRST_RETRY_NANOS, previewRevision);
synchronized (this) {
if (!isWanted(key)) {
if (closed.get()) {
return;
}
VisibleTile visible = currentFrame.tile(key);
if (provisional) {
visible.markProvisional(tile, System.nanoTime());
} else {
cache.put(key, tile);
visible.setTile(tile);
cache.put(key, state, provisional ? work : null);
if (isWanted(key)) {
currentFrame.tile(key).state = state;
}
}
publish();
} catch (CancellationException ignored) {
cancelled = true;
cache.discardWork(key);
} catch (Throwable error) {
IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
} finally {
release(key);
release(key, cancelled);
}
}
@@ -346,8 +391,7 @@ final class VisionRenderController implements AutoCloseable {
return !closed.get() && frame != null && frame.tile(key) != null;
}
private Fallback coarserFallback(Frame frame, VisibleTile tile) {
TileKey key = tile.key();
private Fallback coarserFallback(TileKey key, boolean provisional) {
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
long scale = 1L << level;
int sourceSize = TILE_PIXELS >> level;
@@ -357,8 +401,8 @@ final class VisionRenderController implements AutoCloseable {
}
long parentX = Math.floorDiv(key.tileX(), scale);
long parentZ = Math.floorDiv(key.tileZ(), scale);
MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX,
parentZ));
MapTile parent = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale,
parentX, parentZ), provisional);
if (parent != null) {
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
@@ -368,8 +412,7 @@ final class VisionRenderController implements AutoCloseable {
return null;
}
private Fallback finerFallback(Frame frame, VisibleTile tile) {
TileKey key = tile.key();
private Fallback finerFallback(TileKey key, boolean provisional) {
double childScale = key.blocksPerPixel() / 2D;
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
return null;
@@ -377,8 +420,8 @@ final class VisionRenderController implements AutoCloseable {
List<FallbackPiece> pieces = new ArrayList<>(4);
for (int childZ = 0; childZ < 2; childZ++) {
for (int childX = 0; childX < 2; childX++) {
MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ));
MapTile child = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ), provisional);
if (child != null) {
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D));
}
@@ -528,10 +571,11 @@ final class VisionRenderController implements AutoCloseable {
}
// Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance) {
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance, long now) {
long previewRevision = spec.renderer().previewRevision();
for (int index = nextCandidate; index < tiles.size(); index++) {
VisibleTile tile = tiles.get(index);
if (tile.needsRender(System.nanoTime()) && !inFlight.contains(tile.key())) {
if (tile.needsRender(now, previewRevision) && !inFlight.contains(tile.key())) {
if (advance) {
nextCandidate = index + 1;
}
@@ -548,10 +592,7 @@ final class VisionRenderController implements AutoCloseable {
static final class VisibleTile {
private final TileKey key;
private final double distanceSquared;
private volatile MapTile tile;
private volatile boolean provisional;
private long retryDelay;
private long retryAt;
private volatile TileState state;
private VisibleTile(TileKey key, double distanceSquared) {
this.key = key;
@@ -575,28 +616,19 @@ final class VisionRenderController implements AutoCloseable {
}
MapTile tile() {
return tile;
TileState current = state;
return current == null ? null : current.tile();
}
boolean provisional() {
return provisional;
TileState current = state;
return current != null && current.provisional();
}
private void setTile(MapTile tile) {
this.tile = tile;
provisional = false;
retryDelay = 0L;
}
private void markProvisional(MapTile tile, long now) {
this.tile = tile;
retryDelay = retryDelay == 0L ? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, retryDelay * 2L);
retryAt = now + retryDelay;
provisional = true;
}
private boolean needsRender(long now) {
return tile == null || provisional && now - retryAt >= 0L;
private boolean needsRender(long now, long previewRevision) {
TileState current = state;
return current == null || current.provisional() && (now - current.retryAt() >= 0L
|| previewRevision != current.previewRevision() && now - current.earliestRetryAt() >= 0L);
}
}
@@ -645,7 +677,11 @@ final class VisionRenderController implements AutoCloseable {
}
}
private record TileJob(MapTileRenderer renderer, TileKey key) {
private record TileJob(MapTileRenderer renderer, TileKey key, MapTileRenderer.TileRender work, TileState previous) {
}
private record TileState(MapTile tile, boolean provisional, long retryDelay, long retryAt, long earliestRetryAt,
long previewRevision) {
}
private static final class WeightedTileCache {
@@ -658,14 +694,13 @@ final class VisionRenderController implements AutoCloseable {
this.entries = new LinkedHashMap<>(128, 0.75F, true);
}
synchronized MapTile get(TileKey key) {
CacheEntry entry = entries.get(key);
return entry == null ? null : entry.tile();
synchronized CacheEntry get(TileKey key) {
return entries.get(key);
}
synchronized void put(TileKey key, MapTile tile) {
long tileBytes = tile.bytes();
CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes));
synchronized void put(TileKey key, TileState state, MapTileRenderer.TileRender work) {
long tileBytes = state.tile().bytes() + (work == null ? 0L : work.bytes());
CacheEntry previous = entries.put(key, new CacheEntry(state, work, tileBytes));
if (previous != null) {
bytes -= previous.bytes();
}
@@ -682,8 +717,18 @@ final class VisionRenderController implements AutoCloseable {
entries.clear();
bytes = 0L;
}
synchronized void discardWork(TileKey key) {
CacheEntry entry = entries.get(key);
if (entry == null || entry.work() == null) {
return;
}
long retainedBytes = entry.state().tile().bytes();
entries.put(key, new CacheEntry(entry.state(), null, retainedBytes));
bytes -= entry.bytes() - retainedBytes;
}
}
private record CacheEntry(MapTile tile, long bytes) {
private record CacheEntry(TileState state, MapTileRenderer.TileRender work, long bytes) {
}
}
@@ -21,6 +21,7 @@ package art.arcane.iris.studio.map.components;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
@@ -30,7 +31,6 @@ import art.arcane.iris.studio.render.RenderType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.IdentityHashMap;
@@ -73,6 +73,11 @@ public final class MapTileRenderer {
return labels;
}
public long previewRevision() {
HydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
return runtime == null ? 0L : runtime.previewRevision();
}
/**
* Renders the square of {@code size} pixels whose top left pixel samples the given world position.
*
@@ -87,6 +92,13 @@ public final class MapTileRenderer {
boolean withHeights,
BooleanSupplier cancelled
) {
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
return prepareTile(startX, startZ, blocksPerPixel, size, layer, withHeights).render(cancelled);
}
public TileRender prepareTile(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer,
boolean withHeights) {
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Map tile coordinates and scale must be finite");
}
@@ -94,35 +106,7 @@ public final class MapTileRenderer {
throw new IllegalArgumentException("Map tile size must be positive");
}
Objects.requireNonNull(layer, "layer");
Objects.requireNonNull(cancelled, "cancelled");
TileType type = layer.type();
checkCancelled(cancelled);
TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled);
int[] identities = sampleIdentities(grid, layer);
double[] heights = withHeights || type == TileType.HEIGHT ? sampleHeights(grid) : null;
short[] subjects = new short[identities.length];
short[] regions = new short[identities.length];
for (int index = 0; index < identities.length; index++) {
subjects[index] = subjectOf(identities[index]);
regions[index] = regionOf(identities[index]);
}
int[] colors = switch (type) {
case HEIGHT -> null;
case RIVER -> riverColors(startX, startZ, blocksPerPixel, size, cancelled);
default -> tileColors(size, type, subjects);
};
if (layer.display() != PickDisplay.PICKS) {
drawPickNoise(colors, grid, size, layer);
}
if (layer.waterOverlay()) {
BufferedImage base = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
base.setRGB(0, 0, size, size, colors, 0, size);
BufferedImage water = hydrologyRenderer.refineStudioBiome(startX, startZ, blocksPerPixel * size, base, cancelled);
colors = water.getRGB(0, 0, size, size, null, 0, size);
}
MapLabels.Kind subjectKind = type == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights);
return new TileRender(new TileRequest(startX, startZ, blocksPerPixel, size, layer, withHeights));
}
/**
@@ -166,15 +150,15 @@ public final class MapTileRenderer {
return sampler.identities;
}
private double[] sampleHeights(TileGrid grid) {
private double[] sampleHeights(HeightLattice lattice, BooleanSupplier cancelled) {
IrisComplex complex = engine.getComplex();
IrisDimension dimension = engine.getDimension();
HydrologyRuntime water = complex.getHydrologyRuntime();
if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) {
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
return lattice.interpolatedHeights(cancelled);
}
try (HydrologyRuntime.PreviewScope ignored = water.preview()) {
return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights();
return lattice.interpolatedHeights(cancelled);
}
}
@@ -277,17 +261,6 @@ public final class MapTileRenderer {
};
}
// Rivers are drawn by the hydrology renderer the engine ships; the biomes under them stay the tile's subjects.
private int[] riverColors(double startX, double startZ, double blocksPerPixel, int size, BooleanSupplier cancelled) {
BufferedImage rivers = hydrologyRenderer.renderStudio(startX, startZ, blocksPerPixel * size, size, RenderType.RIVER,
cancelled);
int[] colors = rivers.getRGB(0, 0, size, size, null, 0, size);
for (int index = 0; index < colors.length; index++) {
colors[index] &= 0xFFFFFF;
}
return colors;
}
private int[] tileColors(int size, TileType type, short[] subjects) {
int[] colors = new int[size * size];
short previousSubject = MapLabels.NONE;
@@ -376,6 +349,124 @@ public final class MapTileRenderer {
}
}
public final class TileRender {
private final TileRequest request;
private short[] subjects;
private short[] regions;
private int[] baseColors;
private int[] finalColors;
private double[] heights;
private HeightLattice heightLattice;
private IrisRenderer.WaterTile water;
private boolean identitiesComplete;
private boolean baseComplete;
private boolean heightsComplete;
private boolean waterComplete;
private volatile boolean complete;
private volatile long bytes;
private TileRender(TileRequest request) {
this.request = request;
heightsComplete = !request.withHeights() && request.layer().type() != TileType.HEIGHT;
waterComplete = request.layer().type() != TileType.RIVER && !request.layer().waterOverlay();
}
public MapTile render(BooleanSupplier cancelled) {
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
TileGrid grid = new TileGrid(request.size() + 2, request.startX() - request.blocksPerPixel(),
request.startZ() - request.blocksPerPixel(), request.blocksPerPixel(), cancelled);
sampleSubjects(grid);
sampleBaseColors(grid);
sampleTerrain(grid, cancelled);
int[] colors = sampleWater(cancelled);
checkCancelled(cancelled);
complete = baseComplete && heightsComplete && waterComplete;
bytes = (heightLattice == null ? 0L : heightLattice.bytes()) + (water == null ? 0L : water.bytes())
+ (baseColors == null || colors == baseColors ? 0L : (long) baseColors.length * Integer.BYTES);
if (!complete) {
ProvisionalSampling.mark();
}
MapLabels.Kind kind = request.layer().type() == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME;
return new MapTile(request.size(), request.blocksPerPixel(), labels, kind, colors, defaultHeightRange(), subjects,
regions, heights);
}
public boolean complete() {
return complete;
}
public long bytes() {
return bytes;
}
private void sampleSubjects(TileGrid grid) {
if (identitiesComplete) {
return;
}
long revision = ProvisionalSampling.revision();
int[] identities = sampleIdentities(grid, request.layer());
subjects = new short[identities.length];
regions = new short[identities.length];
for (int index = 0; index < identities.length; index++) {
subjects[index] = subjectOf(identities[index]);
regions[index] = regionOf(identities[index]);
}
identitiesComplete = revision == ProvisionalSampling.revision();
}
private void sampleBaseColors(TileGrid grid) {
if (baseComplete) {
return;
}
long revision = ProvisionalSampling.revision();
baseColors = switch (request.layer().type()) {
case HEIGHT, RIVER -> null;
default -> tileColors(request.size(), request.layer().type(), subjects);
};
if (request.layer().display() != PickDisplay.PICKS) {
drawPickNoise(baseColors, grid, request.size(), request.layer());
}
baseComplete = identitiesComplete && revision == ProvisionalSampling.revision();
}
private void sampleTerrain(TileGrid grid, BooleanSupplier cancelled) {
if (heightsComplete) {
return;
}
if (heightLattice == null) {
heightLattice = new HeightLattice(grid, engine.getComplex().getHeightStream());
}
heights = sampleHeights(heightLattice, cancelled);
heightsComplete = heightLattice.complete();
if (heightsComplete) {
heightLattice = null;
}
}
private int[] sampleWater(BooleanSupplier cancelled) {
if (waterComplete) {
return finalColors == null ? baseColors : finalColors;
}
if (water == null) {
water = hydrologyRenderer.prepareStudioWater(request.startX(), request.startZ(), request.blocksPerPixel(),
request.size());
}
int[] colors = water.render(baseColors, request.layer().waterOverlay(), cancelled);
if (baseComplete && water.complete()) {
finalColors = colors;
baseColors = null;
waterComplete = true;
water = null;
}
return colors;
}
}
private record TileRequest(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer,
boolean withHeights) {
}
@FunctionalInterface
private interface IdentityShader {
int identity(double x, double z);
@@ -554,16 +645,20 @@ public final class MapTileRenderer {
private final ProceduralStream<Double> stream;
private final int pointsPerSide;
private final double[] points;
private final boolean[] resolved;
private int pending;
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
this.grid = grid;
this.stream = stream;
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
this.points = new double[pointsPerSide * pointsPerSide];
this.resolved = new boolean[points.length];
this.pending = points.length;
}
double[] interpolatedHeights() {
samplePoints();
double[] interpolatedHeights(BooleanSupplier cancelled) {
samplePoints(cancelled);
int size = grid.size();
double[] heights = new double[size * size];
for (int gridZ = 0; gridZ < size; gridZ++) {
@@ -574,16 +669,33 @@ public final class MapTileRenderer {
return heights;
}
boolean complete() {
return pending == 0;
}
long bytes() {
return (long) points.length * (Double.BYTES + 1L);
}
// Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach.
private void samplePoints() {
private void samplePoints(BooleanSupplier cancelled) {
double latticeStep = grid.step() * LATTICE_PIXELS;
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
checkCancelled(grid.cancelled());
checkCancelled(cancelled);
for (int pointX = 0; pointX < pointsPerSide; pointX++) {
points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep,
int index = pointZ * pointsPerSide + pointX;
if (resolved[index]) {
continue;
}
long revision = ProvisionalSampling.revision();
points[index] = stream.getDouble(firstX + pointX * latticeStep,
firstZ + pointZ * latticeStep);
if (revision == ProvisionalSampling.revision()) {
resolved[index] = true;
pending--;
}
}
}
}
@@ -70,6 +70,13 @@ public final class IrisRenderer {
this.renderer = Objects.requireNonNull(renderer, "renderer");
}
public WaterTile prepareStudioWater(double startX, double startZ, double step, int resolution) {
if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(step) || step <= 0D || resolution < 1) {
throw new IllegalArgumentException("Water tile coordinates, scale and resolution must be valid");
}
return new WaterTile(startX, startZ, step, resolution);
}
public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
return render(sx, sz, size, resolution, currentType, () -> false);
}
@@ -445,6 +452,71 @@ public final class IrisRenderer {
}
}
public final class WaterTile {
private final double startX;
private final double startZ;
private final double step;
private final int resolution;
private final WaterMap map;
private boolean complete;
private WaterTile(double startX, double startZ, double step, int resolution) {
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.resolution = resolution;
HydrologyRuntime runtime = renderer.getComplex().getHydrologyRuntime();
this.map = runtime == null || !runtime.enabled() || step > WaterMap.MAX_STEP
? null : new WaterMap(runtime, resolution, startX, startZ, step);
complete = map == null;
}
public int[] render(int[] base, boolean composite, BooleanSupplier cancelled) {
Objects.requireNonNull(cancelled, "cancelled");
checkCancelled(cancelled);
if (composite && (base == null || base.length != resolution * resolution)) {
throw new IllegalArgumentException("Water overlay requires matching base pixels");
}
if (!complete) {
map.advance(cancelled);
map.drawCavePools(GenerationSemanticQueries.recordedCavePools(renderer,
Math.floorDiv((int) Math.floor(startX), 16), Math.floorDiv((int) Math.floor(startZ), 16),
Math.floorDiv((int) Math.ceil(startX + resolution * step) - 1, 16),
Math.floorDiv((int) Math.ceil(startZ + resolution * step) - 1, 16)));
complete = !map.provisional();
}
int[] colors = composite ? base.clone() : new int[resolution * resolution];
if (!composite) {
Arrays.fill(colors, NO_RIVER & 0xFFFFFF);
}
if (map != null) {
for (int z = 0; z < resolution; z++) {
checkCancelled(cancelled);
for (int x = 0; x < resolution; x++) {
HydrologyFeatureType type = map.at(x, z);
if (type != null) {
int index = z * resolution + x;
int color = hydrologyFeatureColor(type);
colors[index] = (composite ? blend(colors[index], color, RIVER_OVERLAY) : color) & 0xFFFFFF;
}
}
}
}
if (!complete) {
ProvisionalSampling.mark();
}
return colors;
}
public boolean complete() {
return complete;
}
public long bytes() {
return map == null ? 0L : map.bytes();
}
}
@FunctionalInterface
private interface PixelShader {
int color(double x, double z);
@@ -36,35 +36,85 @@ final class WaterMap {
private final double startX;
private final double startZ;
private final double step;
private final BooleanSupplier cancelled;
private final int[] ranks;
private final boolean[] resolved;
private final IdentityHashMap<HydrologyTier, HydrologyFeatureType> channels = new IdentityHashMap<>();
private final Set<Long> lakes = new HashSet<>();
private BooleanSupplier cancelled;
private int[] plannedRanks;
private boolean plansComplete;
private boolean provisional;
private WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) {
WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step) {
this.runtime = runtime;
this.resolution = resolution;
this.startX = startX;
this.startZ = startZ;
this.step = step;
this.cancelled = cancelled;
this.ranks = new int[resolution * resolution];
this.resolved = step <= RASTER_STEP ? new boolean[ranks.length] : null;
Arrays.fill(ranks, DRY);
provisional = true;
}
static WaterMap draw(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) {
WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step, cancelled);
HydrologyPlanPreview plans = runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ),
(int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1);
map.provisional = !plans.complete();
if (step <= RASTER_STEP) {
map.drawRasters(plans);
} else {
map.drawPlans(plans);
}
WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step);
map.advance(cancelled);
return map;
}
void advance(BooleanSupplier cancelled) {
if (!provisional) {
return;
}
this.cancelled = cancelled;
HydrologyPlanPreview plans = null;
if (!plansComplete) {
plans = preview();
plannedRanks = null;
lakes.clear();
for (Pond lake : plans.lakes()) {
lakes.add(lake.id());
}
if (step > RASTER_STEP) {
prepareFallback(plans);
}
plansComplete = plans.complete();
}
if (step <= RASTER_STEP) {
provisional = drawRasters(plans) || !plansComplete;
} else {
prepareFallback(plans);
System.arraycopy(plannedRanks, 0, ranks, 0, ranks.length);
provisional = !plansComplete;
}
if (!provisional) {
plannedRanks = null;
lakes.clear();
}
}
private HydrologyPlanPreview preview() {
return runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ),
(int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1);
}
private void prepareFallback(HydrologyPlanPreview plans) {
if (plannedRanks != null) {
return;
}
WaterMap planned = new WaterMap(runtime, resolution, startX, startZ, step);
planned.cancelled = cancelled;
planned.drawPlans(plans == null ? preview() : plans);
plannedRanks = planned.ranks;
}
long bytes() {
return (long) ranks.length * Integer.BYTES + (resolved == null ? 0L : resolved.length)
+ (plannedRanks == null ? 0L : (long) plannedRanks.length * Integer.BYTES)
+ (long) lakes.size() * 64L;
}
HydrologyFeatureType at(int pixelX, int pixelZ) {
int rank = ranks[pixelZ * resolution + pixelX];
return rank == DRY ? null : DRAWN.get(rank);
@@ -84,37 +134,39 @@ final class WaterMap {
}
}
private void drawRasters(HydrologyPlanPreview plans) {
Set<Long> lakes = new HashSet<>();
for (Pond lake : plans.lakes()) {
lakes.add(lake.id());
}
private boolean drawRasters(HydrologyPlanPreview plans) {
Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
WaterMap planned = null;
boolean pending = false;
for (int pixelZ = 0; pixelZ < resolution; pixelZ++) {
checkCancelled();
int minimumZ = (int) Math.floor(startZ + pixelZ * step);
int maximumZ = Math.max(minimumZ, (int) Math.ceil(startZ + (pixelZ + 1) * step) - 1);
for (int pixelX = 0; pixelX < resolution; pixelX++) {
int index = pixelZ * resolution + pixelX;
if (resolved[index]) {
continue;
}
ranks[index] = DRY;
boolean ready = plansComplete;
int minimumX = (int) Math.floor(startX + pixelX * step);
int maximumX = Math.max(minimumX, (int) Math.ceil(startX + (pixelX + 1) * step) - 1);
for (int z = minimumZ; z <= maximumZ; z++) {
for (int x = minimumX; x <= maximumX; x++) {
ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4);
if (raster.provisional()) {
provisional = true;
if (planned == null) {
planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled);
planned.drawPlans(plans);
}
stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ));
pending = true;
ready = false;
prepareFallback(plans);
ranks[index] = Math.min(ranks[index], plannedRanks[index]);
} else if (!raster.empty()) {
stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes));
}
}
}
resolved[index] = ready;
}
}
return pending;
}
private ChunkWaterRaster raster(Map<Long, ChunkWaterRaster> rasters, int chunkX, int chunkZ) {
@@ -251,7 +303,7 @@ final class WaterMap {
}
private void checkCancelled() {
if (cancelled.getAsBoolean()) {
if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
throw new CancellationException("Vision render was cancelled");
}
}
@@ -70,6 +70,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -143,6 +144,95 @@ public class HydrologyRuntimeTest {
assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName())));
}
@Test(timeout = 180_000L)
public void previewRevisionAdvancesOnlyWhenPlansOrRastersBecomeAvailable() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
CountDownLatch blocker = block(pool);
CompletableFuture<Void> plans = runtime.prepareWindowsAround(0, 0, 0, 0, 1);
assertEquals(0L, runtime.previewRevision());
assertFalse(plans.isDone());
blocker.countDown();
plans.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertTrue(planned > 0L);
assertTrue(runtime.planPreview(0, 0, 15, 15).complete());
assertEquals(planned, runtime.previewRevision());
CountDownLatch rasterBlocker = block(pool);
for (int repeat = 0; repeat < 100; repeat++) {
assertTrue(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned, runtime.previewRevision());
}
rasterBlocker.countDown();
await(() -> runtime.previewRevision() == planned + 1L, "raster publication advances preview readiness");
ChunkWaterRaster ready = runtime.rasterPreview(0, 0);
assertFalse(ready.provisional());
for (int repeat = 0; repeat < 100; repeat++) {
assertSame(ready, runtime.rasterPreview(0, 0));
assertSame(ready, runtime.raster(0, 0));
assertEquals(planned + 1L, runtime.previewRevision());
}
assertFalse(runtime.raster(1, 0).provisional());
assertEquals(planned + 2L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void concurrentRasterPublicationAdvancesPreviewRevisionOnce() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
CountDownLatch gate = new CountDownLatch(1);
planners.naturalGate = gate;
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread demand = new Thread(() -> {
try {
runtime.raster(0, 0);
} catch (Throwable error) {
failure.set(error);
}
});
demand.start();
try {
await(() -> planners.naturalSamples.get() > 0, "demanded raster starts");
assertTrue(runtime.rasterPreview(0, 0).provisional());
await(() -> planners.naturalSamples.get() >= 2, "preview raster starts beside demand");
assertEquals(planned, runtime.previewRevision());
} finally {
gate.countDown();
demand.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
}
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertFalse(demand.isAlive());
assertNull(failure.get());
assertFalse(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned + 1L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void failedRasterDoesNotAdvancePreviewRevision() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyPlanCache cache = new HydrologyPlanCache(planners.settings, planners::region, planners::window, pool, () -> false);
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, (x, z) -> {
throw new IllegalStateException("terrain sampling failed");
}, () -> false, planners.ticker::get);
runtimes.add(runtime);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertThrows(IllegalStateException.class, () -> runtime.raster(0, 0));
assertEquals(planned, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void rasterCachedPerChunk() throws Exception {
FakePlanners planners = new FakePlanners(settings());
@@ -868,6 +958,7 @@ public class HydrologyRuntimeTest {
private static void assertDisabled(HydrologyRuntime runtime) {
assertFalse(runtime.enabled());
assertEquals(0L, runtime.previewRevision());
assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0));
@@ -879,6 +970,7 @@ public class HydrologyRuntimeTest {
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0));
assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511));
runtime.close();
assertEquals(0L, runtime.previewRevision());
}
private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception {
@@ -139,6 +139,73 @@ public class HydrologyPlanCacheTest {
assertEquals(KEY, results[0].plan().key());
}
@Test
public void readyRevisionCountsSuccessfulPlanPublicationsWithoutCountingReads() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = WindowPlanner.lakeRegions(KEY).getFirst();
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(region);
await(() -> gate.arrivals() == 1, "region planning starts before publication");
assertEquals(0L, cache.readyRevision());
assertFalse(pending.isDone());
gate.release();
RegionPlan ready = pending.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
assertSame(ready, cache.regionIfReady(region).orElseThrow());
assertSame(ready, cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertSame(ready, cache.region(region));
assertEquals(1L, cache.readyRevision());
cache.previewWindow(KEY).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long published = WindowPlanner.lakeRegions(KEY).size() + 1L;
assertEquals(published, cache.readyRevision());
assertTrue(cache.windowIfReady(KEY).isPresent());
cache.window(KEY);
assertEquals(published, cache.readyRevision());
}
@Test
public void failedPlansDoNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = new RegionKey(0, 0);
planners.hook = key -> {
throw new IllegalStateException("region planning failed");
};
CompletableFuture<RegionPlan> failed = cache.previewRegion(region);
assertThrows(ExecutionException.class, () -> failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertEquals(0L, cache.readyRevision());
planners.hook = key -> { };
cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
}
@Test
public void cancelledPlanCompletionDoesNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(new RegionKey(0, 0));
await(() -> gate.arrivals() == 1, "region planning starts before cancellation");
cache.close();
gate.release();
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertTrue(pending.isCompletedExceptionally());
assertEquals(0L, cache.readyRevision());
}
@Test
public void interruptedPlanIsNotCached() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
@@ -0,0 +1,69 @@
package art.arcane.iris.generation.noise;
import org.junit.Test;
import java.lang.reflect.Field;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class TerrainBatchScratchTest {
@Test
public void integerLatticesDistributeWithoutLongLinearProbeClusters() throws Exception {
TerrainBatchScratch scratch = new TerrainBatchScratch();
for (int origin : new int[]{0, -1536, 1536, 16_777_212}) {
scratch.clearLattice(4096);
for (int index = 0; index < 1024; index++) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
}
assertEquals(1024, scratch.latticeCount());
for (int index = 1023; index >= 0; index--) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
assertEquals(x, scratch.latticeX(index), 0D);
assertEquals(z, scratch.latticeZ(index), 0D);
}
assertTrue("clustered lattice at " + origin, longestCluster(scratch) < 32);
}
}
@Test
public void rawCoordinateIdentityAndInsertionOrderSurviveRehashingAndReset() {
TerrainBatchScratch scratch = new TerrainBatchScratch();
long[] bits = {0L, Long.MIN_VALUE, Double.doubleToRawLongBits(1D), Double.doubleToRawLongBits(-1D),
Double.doubleToRawLongBits(0.125D), Double.doubleToRawLongBits(-0.125D),
Double.doubleToRawLongBits(Double.POSITIVE_INFINITY), Double.doubleToRawLongBits(Double.NEGATIVE_INFINITY),
0x7FF8000000000001L, 0x7FF8000000000002L};
for (int pass = 0; pass < 2; pass++) {
scratch.clearLattice(bits.length * bits.length);
for (int x = 0; x < bits.length; x++) {
for (int z = 0; z < bits.length; z++) {
int expected = x * bits.length + z;
double worldX = Double.longBitsToDouble(bits[x]);
double worldZ = Double.longBitsToDouble(bits[z]);
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(bits[x], Double.doubleToRawLongBits(scratch.latticeX(expected)));
assertEquals(bits[z], Double.doubleToRawLongBits(scratch.latticeZ(expected)));
}
}
assertEquals(bits.length * bits.length, scratch.latticeCount());
}
}
private static int longestCluster(TerrainBatchScratch scratch) throws Exception {
Field field = TerrainBatchScratch.class.getDeclaredField("latticeSlots");
field.setAccessible(true);
int[] slots = (int[]) field.get(scratch);
int longest = 0;
int length = 0;
for (int index = 0; index < slots.length * 2; index++) {
length = slots[index % slots.length] == 0 ? 0 : length + 1;
longest = Math.max(longest, length);
}
return longest;
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.spi.IrisLogging;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisEngineFailureReportingTest {
@Test
public void failedGenerationReportsOnceAndRetainsItsCause() throws Exception {
IrisEngine engine = mock(IrisEngine.class);
IllegalStateException failure = new IllegalStateException("Hydrology raster failed");
Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks");
platformHooks.setAccessible(true);
platformHooks.set(engine, mock(EnginePlatformHooks.class));
doThrow(failure).when(engine).openGenerationHistoryCoordinateScope(-352, -144);
doCallRealMethod().when(engine).generate(-352, -144, null, null, false);
doCallRealMethod().when(engine).fail("Failed to generate -352, -144", failure);
doCallRealMethod().when(engine).hasFailed();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
WrongEngineBroException reported = assertThrows(WrongEngineBroException.class,
() -> engine.generate(-352, -144, null, null, false));
assertSame(failure, reported.getCause());
assertTrue(engine.hasFailed());
logging.verify(() -> IrisLogging.error("Failed to generate -352, -144"));
logging.verify(() -> IrisLogging.reportError(failure));
logging.verifyNoMoreInteractions();
}
}
}
@@ -0,0 +1,54 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.spi.LogLevel;
import org.junit.After;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class BukkitPlatformErrorReportingTest {
@After
public void releaseHost() {
BukkitPlatform.releaseHost();
}
@Test
public void contextualReportEmitsOneCompleteTraceThroughTheHost() {
ArrayList<LogEntry> messages = new ArrayList<>();
ArrayList<Throwable> reported = new ArrayList<>();
BukkitPlatform.hostBridge(new BukkitPlatform.HostBridge(
(level, message) -> messages.add(new LogEntry(level, message)), null,
failure -> {
reported.add(failure);
failure.printStackTrace(System.err);
}, null, null, null, null, null, null));
IllegalStateException failure = new IllegalStateException("raster failure",
new ArrayIndexOutOfBoundsException("invalid sample"));
ByteArrayOutputStream output = new ByteArrayOutputStream();
PrintStream previousError = System.err;
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8)) {
System.setErr(errors);
new BukkitPlatform().reportError("Generation failed", failure);
} finally {
System.setErr(previousError);
}
assertEquals(List.of(new LogEntry(LogLevel.ERROR, "Generation failed")), messages);
assertEquals(List.of(failure), reported);
String trace = output.toString(StandardCharsets.UTF_8);
assertEquals(1L, trace.lines().filter(line -> line.contains("raster failure")).count());
assertEquals(1L, trace.lines().filter(line -> line.contains("Caused by:")).count());
assertTrue(trace.contains("BukkitPlatformErrorReportingTest.contextualReportEmitsOneCompleteTraceThroughTheHost"));
}
private record LogEntry(LogLevel level, String message) {
}
}
@@ -14,7 +14,10 @@ import org.junit.Test;
import java.util.Locale;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -38,13 +41,23 @@ public class BukkitBindingTest {
Fixture fixture = new Fixture();
when(fixture.lifecycle.isServerStopping()).thenReturn(true);
assertTrue(fixture.binding.isServerStopping());
when(fixture.lifecycle.deferPluginClassLoaderClose()).thenReturn(true);
assertTrue(fixture.binding.deferPluginClassLoaderClose());
fixture.binding.initializeLifecycle(getClass().getClassLoader());
verify(fixture.lifecycle).initializeLifecycle(getClass().getClassLoader());
fixture.binding.deferPluginClassLoaderClose();
fixture.binding.releasePluginClassLoaderClose();
verify(fixture.lifecycle).deferPluginClassLoaderClose();
verify(fixture.lifecycle).releasePluginClassLoaderClose();
}
@Test
public void limitedBindingRejectsClassLoaderProtectionAndUsesSynchronousShutdown() {
INMSBinding binding = mock(INMSBinding.class, CALLS_REAL_METHODS);
assertFalse(binding.isServerStopping());
assertThrows(IllegalStateException.class,
() -> binding.initializeLifecycle(getClass().getClassLoader()));
assertThrows(IllegalStateException.class, binding::deferPluginClassLoaderClose);
}
@Test
public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException {
Fixture fixture = new Fixture();
@@ -71,8 +84,8 @@ public class BukkitBindingTest {
}
private static final class Fixture {
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
private final NativeWorldGeneration generation = mock(NativeWorldGeneration.class);
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
private final NativeWorldLifecycleFactory.Controller lifecycle = mock(NativeWorldLifecycleFactory.Controller.class);
private final BukkitBinding binding;
@@ -0,0 +1,65 @@
package art.arcane.iris.platform.bukkit.nms;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.testsupport.PlatformBinding;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class BukkitWorldLifecyclePolicyTest {
@ClassRule
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
@Test
public void nativeProvidersCloseBeforeThePluginClassLoaderIsReleased() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
agent.when(() -> Agent.releaseClassLoader(loader)).thenAnswer(invocation -> {
providers.verify(SlimJar::closeNativeProviders);
return null;
});
BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader);
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void nativeProviderCloseFailureStillReleasesThePluginClassLoader() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException failure = new IOException("provider close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(failure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(failure, reported.getCause());
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void providerAndLoaderCloseFailuresAreBothPreserved() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException providerFailure = new IOException("provider close failed");
IllegalStateException loaderFailure = new IllegalStateException("loader close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(providerFailure);
agent.when(() -> Agent.releaseClassLoader(loader)).thenThrow(loaderFailure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(loaderFailure, reported);
assertEquals(1, reported.getSuppressed().length);
assertSame(providerFailure, reported.getSuppressed()[0]);
}
}
}
@@ -0,0 +1,224 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapStyle;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.PickDisplay;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
import java.awt.image.BufferedImage;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MapTileProgressTest {
@Test
public void retriesOnlySampleUnresolvedHeightPointsAndReuseBiomeData() {
AtomicBoolean pending = new AtomicBoolean(true);
AtomicInteger heightSamples = new AtomicInteger();
AtomicInteger biomeSamples = new AtomicInteger();
Engine engine = engine(biomeSamples);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
heightSamples.incrementAndGet();
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-8D, -8D, 0.5D, 16, layer, true);
progress.render(() -> false);
int initialHeights = heightSamples.get();
int initialBiomes = biomeSamples.get();
assertFalse(progress.complete());
assertTrue(progress.bytes() > 0L);
progress.render(() -> false);
assertEquals(initialHeights + 1, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
pending.set(false);
MapTile settled = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
assertEquals(0L, progress.bytes());
progress.render(() -> false);
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
MapTile expected = renderer.renderTile(-8D, -8D, 0.5D, 16, layer, true, () -> false);
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), settled.heightAt(x, z), 0D);
}
}
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(settled.image(MapStyle.PLAIN.withShading(true))));
}
@Test
public void completedWaterIsNotBlendedAgainWhileHeightsWait() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (pending.get()) {
ProvisionalSampling.mark();
}
return 60D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapTileRenderer.TileRender progress = renderer.prepareTile(0D, 0D, 1D, 16, MapLayer.of(TileType.BIOME), true);
MapTile first = progress.render(() -> false);
assertFalse(progress.complete());
assertNotEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(0, 0));
MapTile second = progress.render(() -> false);
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(second.image(MapStyle.PLAIN)));
pending.set(false);
MapTile complete = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(complete.image(MapStyle.PLAIN)));
}
@Test
public void twoBlockPixelsRefineMixedWaterAndHeightsToTheSameFinalTile() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(runtime.rasterPreview(0, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : raster);
when(raster.role(anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int z = invocation.getArgument(1);
if (x == 1 && z < 4) {
return WaterRole.POND;
}
if (x == 7 && z < 8) {
return WaterRole.WATERFALL;
}
return x < 4 && z == 9 ? WaterRole.INLET : WaterRole.NONE;
});
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.of(TileType.BIOME);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 2D, 16, layer, true);
MapTile first = progress.render(() -> false);
int[] firstPixels = pixels(first.image(MapStyle.PLAIN.withShading(true)));
assertFalse(progress.complete());
assertEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(8, 8));
MapTile waiting = progress.render(() -> false);
assertFalse(progress.complete());
assertArrayEquals(firstPixels, pixels(waiting.image(MapStyle.PLAIN.withShading(true))));
pending.set(false);
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
BufferedImage colors = actual.image(MapStyle.PLAIN);
assertEquals(0xFF738249, colors.getRGB(14, 14));
assertNotEquals(0xFF738249, colors.getRGB(8, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(11, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(8, 12));
assertNotEquals(colors.getRGB(11, 8), colors.getRGB(8, 12));
assertArrayEquals(firstPixels, pixels(first.image(MapStyle.PLAIN.withShading(true))));
MapTile expected = renderer.renderTile(-16D, -16D, 2D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), actual.heightAt(x, z), 0D);
assertEquals(expected.subjectAt(x, z), actual.subjectAt(x, z));
assertEquals(expected.regionAt(x, z), actual.regionAt(x, z));
}
}
}
@Test
public void cancelledPreparationCanResumeWithoutReusingIncompleteHeightSamples() {
Engine engine = engine(new AtomicInteger());
AtomicInteger samples = new AtomicInteger();
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
samples.incrementAndGet();
return x + z + 80D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 1D, 16, layer, true);
assertThrows(CancellationException.class, () -> progress.render(() -> samples.get() >= 9));
assertFalse(progress.complete());
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(81, samples.get());
MapTile expected = renderer.renderTile(-16D, -16D, 1D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
}
private static Engine engine(AtomicInteger biomeSamples) {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = new IrisBiome();
biome.setColor("#738249");
IrisRegion region = new IrisRegion();
when(engine.getComplex()).thenReturn(complex);
when(engine.getHeight()).thenReturn(256);
when(complex.getBaseBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> {
biomeSamples.incrementAndGet();
return biome;
}, Interpolated.of(value -> 0D, value -> biome)));
when(complex.getRegionStream()).thenReturn(ProceduralStream.of((x, z) -> region,
Interpolated.of(value -> 0D, value -> region)));
return engine;
}
private static int[] pixels(BufferedImage image) {
return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
}
}
@@ -1,20 +1,23 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.TileType;
import org.junit.Test;
import java.awt.EventQueue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -22,21 +25,23 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class VisionRenderControllerTest {
@Test
public void provisionalTilesRetryWithBackoffAndConvergeWithoutMoving() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
MapTile tile = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
if (attempts.incrementAndGet() < 3) {
ProvisionalSampling.mark();
}
attempts.incrementAndGet();
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 3);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
await(() -> frame.tiles().getFirst().tile() != null);
@@ -55,30 +60,47 @@ public class VisionRenderControllerTest {
});
assertEquals(3, attempts.get());
assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void provisionalTilesAreShownButNeverReusedAsFinal() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
public void resizeZoomAndSuspendRetainProvisionalImageWorkAndBackoff() throws Exception {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
MapTile incomplete = mock(MapTile.class);
MapTile complete = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) {
ProvisionalSampling.mark();
return incomplete;
}
return complete;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
await(() -> first.tiles().getFirst().tile() != null && controller.progress(first).activeWorkers() == 0);
assertSame(incomplete, first.tiles().getFirst().tile());
VisionRenderController.Frame resized = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
assertSame(incomplete, resized.tiles().getFirst().tile());
assertTrue(resized.tiles().getFirst().provisional());
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1.15D, 48, 48, false));
assertSame(incomplete, zoomed.tiles().getFirst().tile());
controller.suspend();
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
await(() -> second.tiles().getFirst().tile() == complete);
assertSame(incomplete, second.tiles().getFirst().tile());
assertEquals(0, controller.progress(second).ready());
assertEquals(1, attempts.get());
await(() -> {
controller.retryProvisional(second);
return controller.progress(second).ready() == 1;
});
assertEquals(2, attempts.get());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
assertSame(complete, third.tiles().getFirst().tile());
assertEquals(2, attempts.get());
@@ -87,10 +109,11 @@ public class VisionRenderControllerTest {
@Test
public void contentRevisionInvalidatesFinalTiles() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = finished(tile);
MapTileRenderer renderer = renderer(work);
AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
attempts.incrementAndGet();
return tile;
@@ -109,14 +132,16 @@ public class VisionRenderControllerTest {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTile incomplete = mock(MapTile.class);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
MapTileRenderer.TileRender overviewWork = finished(overview);
MapTileRenderer.TileRender detailWork = mock(MapTileRenderer.TileRender.class);
when(detailWork.render(any())).thenReturn(incomplete);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> {
double scale = invocation.getArgument(2);
if (scale < 4D) {
ProvisionalSampling.mark();
return incomplete;
return detailWork;
}
return overview;
return overviewWork;
});
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
@@ -142,10 +167,11 @@ public class VisionRenderControllerTest {
@Test
public void closeRejectsLatePublication() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
when(work.render(any()))
.thenAnswer(invocation -> {
entered.countDown();
while (release.getCount() != 0L) {
@@ -167,7 +193,261 @@ public class VisionRenderControllerTest {
controller.close();
}
await(() -> controller.progress(frame).activeWorkers() == 0);
assertEquals(null, frame.tiles().getFirst().tile());
assertNull(frame.tiles().getFirst().tile());
}
@Test
public void resizeAndSameLevelZoomReuseFinalTiles() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = finished(tile);
MapTileRenderer renderer = renderer(work);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false));
await(() -> controller.progress(first).ready() == 1);
VisionRenderController.Frame resized = controller.request(spec(renderer, 1L));
assertSame(tile, resized.tiles().getFirst().tile());
VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 0.9D, 64, 64, false));
assertSame(tile, zoomed.tiles().getFirst().tile());
assertEquals(1, controller.progress(zoomed).ready());
verify(work).render(any());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void contentRevisionInvalidatesProvisionalWork() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
when(work.render(any())).thenReturn(tile);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
VisionRenderController.Frame second = controller.request(spec(renderer, 2L));
await(() -> second.tiles().getFirst().tile() != null);
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void missingDetailUsesProvisionalOverviewWithoutMarkingItFinal() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTileRenderer.TileRender overviewWork = mock(MapTileRenderer.TileRender.class);
when(overviewWork.render(any())).thenReturn(overview);
CountDownLatch entered = new CountDownLatch(1);
MapTileRenderer.TileRender detailWork = cancellable(entered);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 4D ? detailWork : overviewWork);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
await(() -> coarse.tiles().getFirst().tile() != null);
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertNull(fine.tiles().getFirst().tile());
VisionRenderController.Fallback fallback = controller.fallback(fine, fine.tiles().getFirst());
assertNotNull(fallback);
assertSame(overview, fallback.pieces().getFirst().tile());
assertEquals(0, controller.progress(fine).ready());
}
}
@Test
public void zoomingOutKeepsCompleteFinerTilesOverProvisionalExactTile() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile fineTile = mock(MapTile.class);
MapTile coarseTile = mock(MapTile.class);
MapTileRenderer.TileRender fineWork = finished(fineTile);
MapTileRenderer.TileRender coarseWork = mock(MapTileRenderer.TileRender.class);
when(coarseWork.render(any())).thenReturn(coarseTile);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenAnswer(invocation -> (double) invocation.getArgument(2) < 2D ? fineWork : coarseWork);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
await(() -> controller.progress(fine).ready() == 1);
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 64D, 64D, 2D, 64, 64, false));
await(() -> coarse.tiles().getFirst().tile() != null);
VisionRenderController.Fallback fallback = controller.fallback(coarse, coarse.tiles().getFirst());
assertNotNull(fallback);
assertSame(fineTile, fallback.pieces().getFirst().tile());
assertEquals(0.5D, fallback.pieces().getFirst().size(), 0D);
}
}
@Test
public void suspendedViewCancelsSamplingAndProbeButKeepsCompletedTiles() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer renderer = mock(MapTileRenderer.class);
CountDownLatch sampling = new CountDownLatch(1);
MapTileRenderer.TileRender pending = cancellable(sampling);
MapTileRenderer.TileRender complete = finished(tile);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenReturn(complete, pending);
CountDownLatch probing = new CountDownLatch(1);
CountDownLatch interrupted = new CountDownLatch(1);
AtomicInteger published = new AtomicInteger();
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> controller.progress(first).ready() == 1);
VisionRenderController.Frame next = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 96D, 32D, 1D, 64, 64, false));
assertTrue(sampling.await(5L, TimeUnit.SECONDS));
controller.submitProbe(next, () -> {
probing.countDown();
try {
new CountDownLatch(1).await();
return "late";
} catch (InterruptedException error) {
interrupted.countDown();
throw error;
}
}, value -> published.incrementAndGet());
assertTrue(probing.await(5L, TimeUnit.SECONDS));
controller.suspend();
assertTrue(interrupted.await(5L, TimeUnit.SECONDS));
await(() -> controller.progress(next).activeWorkers() == 0);
EventQueue.invokeAndWait(() -> { });
assertEquals(0, published.get());
assertNull(next.tiles().getFirst().tile());
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
assertSame(tile, resumed.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void retainedWorkCountsAgainstCacheBudgetAndIsReleasedWhenComplete() throws Exception {
MapTile tile = mock(MapTile.class);
when(tile.bytes()).thenReturn(1L);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
AtomicInteger attempts = new AtomicInteger();
when(work.render(any())).thenAnswer(invocation -> {
attempts.incrementAndGet();
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
when(work.bytes()).thenReturn(256L * 1024L * 1024L);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
VisionRenderController.Frame second = controller.request(spec(renderer, 1L));
await(() -> controller.progress(second).ready() == 1);
VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
assertSame(tile, third.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
assertEquals(2, attempts.get());
}
}
@Test
public void cancelledRetryReleasesWorkAndKeepsPublishedImage() throws Exception {
MapTile incomplete = mock(MapTile.class);
MapTile complete = mock(MapTile.class);
CountDownLatch entered = new CountDownLatch(1);
MapTileRenderer.TileRender partial = mock(MapTileRenderer.TileRender.class);
MapTileRenderer.TileRender replacement = finished(complete);
AtomicInteger attempts = new AtomicInteger();
when(partial.render(any())).thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) {
return incomplete;
}
BooleanSupplier cancelled = invocation.getArgument(0);
entered.countDown();
await(cancelled);
throw new CancellationException();
});
MapTileRenderer renderer = mock(MapTileRenderer.class);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()))
.thenReturn(partial, replacement);
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L));
await(() -> first.tiles().getFirst().tile() != null);
await(() -> {
controller.retryProvisional(first);
return entered.getCount() == 0L;
});
controller.suspend();
await(() -> controller.progress(first).activeWorkers() == 0);
assertSame(incomplete, first.tiles().getFirst().tile());
VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L));
await(() -> controller.progress(resumed).ready() == 1);
assertSame(complete, resumed.tiles().getFirst().tile());
verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
@Test
public void previewReadinessWakesBackoffAtBoundedCadenceIncludingCompletionDuringRender() throws Exception {
MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
AtomicLong time = new AtomicLong();
AtomicLong readiness = new AtomicLong();
AtomicInteger attempts = new AtomicInteger();
when(renderer.previewRevision()).thenAnswer(invocation -> readiness.get());
when(work.render(any())).thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 3) {
readiness.incrementAndGet();
}
return tile;
});
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 4);
try (VisionRenderController controller = new VisionRenderController(() -> { }, time::get)) {
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
await(() -> attempts.get() == 1 && controller.progress(frame).activeWorkers() == 0);
time.set(VisionRenderController.FIRST_RETRY_NANOS);
controller.retryProvisional(frame);
await(() -> attempts.get() == 2 && controller.progress(frame).activeWorkers() == 0);
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS);
controller.retryProvisional(frame);
assertEquals(2, attempts.get());
readiness.incrementAndGet();
controller.retryProvisional(frame);
await(() -> attempts.get() == 3 && controller.progress(frame).activeWorkers() == 0);
time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS - 1L);
controller.retryProvisional(frame);
assertEquals(3, attempts.get());
time.incrementAndGet();
controller.retryProvisional(frame);
await(() -> controller.progress(frame).ready() == 1);
assertEquals(4, attempts.get());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
}
}
private static MapTileRenderer.TileRender cancellable(CountDownLatch entered) {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
when(work.render(any())).thenAnswer(invocation -> {
BooleanSupplier cancelled = invocation.getArgument(0);
entered.countDown();
while (!cancelled.getAsBoolean() && !Thread.currentThread().isInterrupted()) {
try {
Thread.sleep(1L);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
}
throw new CancellationException();
});
return work;
}
private static MapTileRenderer renderer(MapTileRenderer.TileRender work) {
MapTileRenderer renderer = mock(MapTileRenderer.class);
when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())).thenReturn(work);
return renderer;
}
private static MapTileRenderer.TileRender finished(MapTile tile) {
MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
when(work.render(any())).thenReturn(tile);
when(work.complete()).thenReturn(true);
return work;
}
private static VisionRenderController.RenderSpec spec(MapTileRenderer renderer, long revision) {
@@ -0,0 +1,100 @@
package art.arcane.iris.studio.render;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.water.Reach;
import org.junit.Test;
import java.util.concurrent.CancellationException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WaterMapProgressTest {
@Test
public void completeDetailedRastersDoNotDrawPlanFallbacks() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
Reach reach = mock(Reach.class);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.thenReturn(new HydrologyPlanPreview(List.of(reach), List.of(), List.of(), List.of(), true));
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
WaterMap map = WaterMap.draw(runtime, 32, 0D, 0D, 1D, () -> false);
assertFalse(map.provisional());
verifyNoInteractions(reach);
}
@Test
public void completedPixelsSurviveRuntimeRasterEviction() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster pond = pond();
AtomicBoolean pending = new AtomicBoolean(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
when(runtime.rasterPreview(0, 0)).thenReturn(pond, ChunkWaterRaster.PROVISIONAL);
when(runtime.rasterPreview(1, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : pond);
WaterMap map = new WaterMap(runtime, 32, 0D, 0D, 1D);
map.advance(() -> false);
assertTrue(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertNull(map.at(17, 1));
pending.set(false);
map.advance(() -> false);
assertFalse(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertEquals(HydrologyFeatureType.POND, map.at(17, 1));
assertNull(map.at(1, 17));
assertTrue(map.bytes() < 16_384L);
verify(runtime, times(1)).rasterPreview(0, 0);
verify(runtime, times(2)).rasterPreview(1, 0);
verify(runtime, times(1)).planPreview(anyInt(), anyInt(), anyInt(), anyInt());
map.advance(() -> false);
verify(runtime, times(2)).rasterPreview(1, 0);
}
@Test
public void cancelledWaterSamplingResumesToTheSameFinalPixels() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = pond();
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
AtomicInteger rows = new AtomicInteger();
WaterMap map = new WaterMap(runtime, 32, -16.5D, -16.5D, 0.5D);
assertThrows(CancellationException.class, () -> map.advance(() -> rows.incrementAndGet() > 2));
assertTrue(map.provisional());
map.advance(() -> false);
assertFalse(map.provisional());
WaterMap expected = WaterMap.draw(runtime, 32, -16.5D, -16.5D, 0.5D, () -> false);
for (int z = 0; z < 32; z++) {
for (int x = 0; x < 32; x++) {
assertEquals(expected.at(x, z), map.at(x, z));
}
}
}
private static ChunkWaterRaster pond() {
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
return raster;
}
}