From df717cf1518294c3de9f848449bf3591d016732f Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:05:19 -0400 Subject: [PATCH] Fixed saved biome previews - Pregeneration previews wait for saved biome reads on the bounded renderer worker - Preview colors retain historical definitions through rendering - Existing chunk previews avoid biome reads while the window is hidden - Query completion handles cache eviction and capture refresh - Regression tests cover read failures, interruption, and shutdown ownership --- .../arcane/iris/core/gui/PregeneratorJob.java | 50 ++-- .../art/arcane/iris/engine/IrisEngine.java | 21 ++ .../arcane/iris/engine/framework/Engine.java | 18 +- .../engine/history/SavedBiomeRuntime.java | 121 +++++++-- .../gui/PregeneratorJobSavedBiomeTest.java | 114 +++++++++ .../iris/engine/IrisEnginePreviewTest.java | 57 +++++ .../framework/EnginePreviewColorTest.java | 49 ++++ .../engine/history/SavedBiomeRuntimeTest.java | 231 ++++++++++++++++++ 8 files changed, 617 insertions(+), 44 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobSavedBiomeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisEnginePreviewTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/EnginePreviewColorTest.java diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 0166f54b4..f7937cc05 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -33,6 +33,7 @@ import art.arcane.iris.core.pregenerator.PregenRates; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.MemoryMonitor; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -492,18 +493,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { @Override public void onChunkGenerated(int x, int z, boolean cached) { - if (renderer == null || !renderer.isVisibleFrame()) return; - if (service.isShutdown()) { - return; - } - service.execute(() -> { - if (engine != null) { - draw(x, z, engine.draw((x << 4) + 8, (z << 4) + 8)); - return; - } - - draw(x, z, COLOR_GENERATED); - }); + drawChunkPreview(x, z, COLOR_GENERATED); } @Override @@ -578,12 +568,7 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { @Override public void onChunkExistsInRegionGen(int x, int z) { - if (engine != null) { - draw(x, z, engine.draw((x << 4) + 8, (z << 4) + 8)); - return; - } - - draw(x, z, COLOR_EXISTS); + drawChunkPreview(x, z, COLOR_EXISTS); } @Override @@ -606,6 +591,35 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return info; } + private void drawChunkPreview(int x, int z, Color statusColor) { + PregenRenderer activeRenderer = renderer; + if (activeRenderer == null || !activeRenderer.isVisibleFrame() || service.isShutdown()) { + return; + } + draw(x, z, statusColor); + if (engine != null) { + service.execute(() -> renderChunkPreview(x, z)); + } + } + + private void renderChunkPreview(int x, int z) { + PregenRenderer activeRenderer = renderer; + if (activeRenderer == null || !activeRenderer.isVisibleFrame() || service.isShutdown() || engine.isClosing()) { + return; + } + try { + draw(x, z, engine.drawForPreview((x << 4) + 8, (z << 4) + 8)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + } catch (SavedBiomeUnavailableException unavailable) { + if (!unavailable.isLoading()) { + IrisLogging.reportError("Unable to draw saved biome information for chunk " + x + "," + z + ".", unavailable); + } + } catch (RuntimeException failure) { + IrisLogging.reportError("Unable to draw the pregeneration preview for chunk " + x + "," + z + ".", failure); + } + } + public record Configuration( PregenTask task, PregeneratorMethod method, diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index ae0e99fa4..e2e9a72d7 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -77,6 +77,7 @@ import lombok.Data; import lombok.Getter; import lombok.Setter; +import java.awt.Color; import java.io.IOException; import java.nio.file.Path; import java.util.Collections; @@ -547,6 +548,26 @@ public class IrisEngine implements Engine { } } + @Override + public Color drawForPreview(int x, int z) throws InterruptedException { + try (GenerationSessionLease lease = acquireGenerationLease("pregen_preview")) { + if (!usesSavedBiomeEnvironment()) { + return Engine.super.draw(x, z); + } + return generationHistoryRuntimeRouter.biomes().readSurfaceBiome(x, z, saved -> { + if (saved.isPresent()) { + return drawBiomeEnvironment(x, z, saved.get()); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "draw a pregeneration preview")) { + return Engine.super.draw(x, z); + } + }); + } catch (GenerationSessionException failure) { + throw new IllegalStateException("Pregeneration preview was rejected by the Iris lifecycle.", failure); + } + } + @Override public BiomeEnvironment getBiomeEnvironment(int x, int y, int z) { Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index ceb6d4ca4..801f1213f 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -262,11 +262,21 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default Color draw(double x, double z) { - IrisRegion region = getRegion((int) x, (int) z); - IrisBiome biome = getSurfaceBiome((int) x, (int) z); - int height = getHeight((int) x, (int) z); + return drawBiomeEnvironment((int) x, (int) z, getSurfaceBiomeEnvironment((int) x, (int) z)); + } + + @BlockCoordinates + default Color drawForPreview(int x, int z) throws InterruptedException { + return draw(x, z); + } + + @BlockCoordinates + default Color drawBiomeEnvironment(int x, int z, BiomeEnvironment environment) { + IrisRegion region = environment.region(); + IrisBiome biome = environment.biome(); + int height = getHeight(x, z); double heightFactor = M.lerpInverse(0, getTarget().getHeight(), height); - Color irc = region.getColor(this.getComplex(), RenderType.BIOME); + Color irc = region.getColor(environment::data, RenderType.BIOME); Color ibc = biome.getColor(this, RenderType.BIOME); Color rc = irc != null ? irc : Color.GREEN.darker(); Color bc = ibc != null ? ibc : biome.isAquatic() ? Color.BLUE : Color.YELLOW; diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java index 6331ad396..308e034f2 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java @@ -20,10 +20,14 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; public final class SavedBiomeRuntime implements AutoCloseable { private static final int MAXIMUM_QUERIES = 128; @@ -36,6 +40,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { private final Map definitions = new ConcurrentHashMap<>(); private final LinkedHashMap queries = new LinkedHashMap<>(32, 0.75F, true); private final Map pending = new HashMap<>(); + private final ReentrantReadWriteLock consumption = new ReentrantReadWriteLock(); private long queryBytes; private final ExecutorService reads = Executors.newFixedThreadPool(2, task -> { Thread thread = new Thread(task, "Iris Saved Biomes"); @@ -54,6 +59,31 @@ public final class SavedBiomeRuntime implements AutoCloseable { return resolve(blockX, worldY, blockZ, surface ? QueryKind.SURFACE : QueryKind.VOLUME); } + public T readSurfaceBiome(int blockX, int blockZ, Function, T> reader) + throws InterruptedException { + Objects.requireNonNull(reader, "reader"); + PreparedQuery prepared; + try { + prepared = queryAsync(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)).get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("Unable to prepare a saved biome preview.", cause); + } + consumption.readLock().lockInterruptibly(); + try { + requireOpen(); + return reader.apply(resolve(prepared, blockX, 0, blockZ, QueryKind.SURFACE)); + } finally { + consumption.readLock().unlock(); + } + } + public NativeBiomeSpawnSelection nativeSpawnSelection(int blockX, int worldY, int blockZ, String physicalBiomeKey) { PreparedQuery prepared = query(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)); if (prepared.failure() != null) { @@ -89,7 +119,10 @@ public final class SavedBiomeRuntime implements AutoCloseable { private Optional resolve(int blockX, int worldY, int blockZ, QueryKind kind) { int chunkX = Math.floorDiv(blockX, 16); int chunkZ = Math.floorDiv(blockZ, 16); - PreparedQuery query = query(chunkX, chunkZ); + return resolve(query(chunkX, chunkZ), blockX, worldY, blockZ, kind); + } + + private Optional resolve(PreparedQuery query, int blockX, int worldY, int blockZ, QueryKind kind) { if (query.failure() != null) { throw query.failure(); } @@ -184,22 +217,27 @@ public final class SavedBiomeRuntime implements AutoCloseable { pending.clear(); queryBytes = 0L; } - for (Definitions source : definitions.values()) { - try { + consumption.writeLock().lock(); + try { + for (Definitions source : definitions.values()) { try { - source.data().unregisterEngine(engine); - } finally { - source.data().close(); - } - } catch (Throwable closeFailure) { - if (failure == null) { - failure = closeFailure; - } else { - failure.addSuppressed(closeFailure); + try { + source.data().unregisterEngine(engine); + } finally { + source.data().close(); + } + } catch (Throwable closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } } } + definitions.clear(); + } finally { + consumption.writeLock().unlock(); } - definitions.clear(); if (failure != null) { throw new IllegalStateException("Unable to close saved biome definitions", failure); } @@ -229,20 +267,41 @@ public final class SavedBiomeRuntime implements AutoCloseable { } private PreparedQuery query(int chunkX, int chunkZ) { + synchronized (queries) { + requireOpen(); + PreparedQuery cached = queries.get(key(chunkX, chunkZ)); + if (cached != null) { + return cached; + } + queryAsync(chunkX, chunkZ); + } + throw loading(chunkX, chunkZ); + } + + private CompletableFuture queryAsync(int chunkX, int chunkZ) { long key = key(chunkX, chunkZ); synchronized (queries) { requireOpen(); PreparedQuery cached = queries.get(key); if (cached != null) { - return cached; + return CompletableFuture.completedFuture(cached); } - if (!pending.containsKey(key) && pending.size() < MAXIMUM_PENDING_QUERIES) { - PendingQuery loading = new PendingQuery(); - pending.put(key, loading); - reads.execute(() -> readQuery(chunkX, chunkZ, loading)); + PendingQuery loading = pending.get(key); + if (loading != null) { + return loading.result; } + if (pending.size() >= MAXIMUM_PENDING_QUERIES) { + return CompletableFuture.failedFuture(loading(chunkX, chunkZ)); + } + PendingQuery requested = new PendingQuery(); + pending.put(key, requested); + reads.execute(() -> readQuery(chunkX, chunkZ, requested)); + return requested.result; } - throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk " + } + + private static SavedBiomeUnavailableException loading(int chunkX, int chunkZ) { + return new SavedBiomeUnavailableException("Saved biome information is loading at chunk " + chunkX + "," + chunkZ + ". Try again shortly.", true); } @@ -269,11 +328,28 @@ public final class SavedBiomeRuntime implements AutoCloseable { } prepared = new PreparedQuery(Optional.empty(), Map.of(), Map.of(), new SavedBiomeUnavailableException( "Unable to read the saved biome at chunk " + chunkX + "," + chunkZ + ".", failure), 512L); + } catch (Error failure) { + loading.result.completeExceptionally(failure); + throw failure; } finally { + boolean refresh; synchronized (queries) { - pending.remove(key, loading); - if (!closed && !loading.invalidated && prepared != null) { - cacheQuery(key, prepared); + refresh = !closed && loading.invalidated; + if (refresh) { + loading.invalidated = false; + reads.execute(() -> readQuery(chunkX, chunkZ, loading)); + } else { + pending.remove(key, loading); + if (!closed && prepared != null) { + cacheQuery(key, prepared); + } + } + } + if (!refresh) { + if (closed || prepared == null) { + loading.result.completeExceptionally(new IllegalStateException("Saved biome runtime is closed.")); + } else { + loading.result.complete(prepared); } } } @@ -399,6 +475,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { } private static final class PendingQuery { + private final CompletableFuture result = new CompletableFuture<>(); private boolean invalidated; } diff --git a/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobSavedBiomeTest.java new file mode 100644 index 000000000..90d76f71c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobSavedBiomeTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.spi.IrisLogging; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.concurrent.ExecutorService; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PregeneratorJobSavedBiomeTest { + @Test + public void generatedChunkLoadingDoesNotEscapeRendererWork() throws Exception { + Fixture fixture = new Fixture(); + doThrow(new SavedBiomeUnavailableException("Saved biome information is loading.", true)) + .when(fixture.engine).draw(anyDouble(), anyDouble()); + + fixture.job.onChunkGenerated(2, -3, false); + assertEquals(1, fixture.tasks.size()); + fixture.tasks.removeFirst().run(); + verify(fixture.renderer).submit(anyInt(), anyInt(), any()); + } + + @Test + public void existingChunkPreviewRunsOnTheRendererWorker() throws Exception { + Fixture fixture = new Fixture(); + doThrow(new SavedBiomeUnavailableException("Saved biome information is loading.", true)) + .when(fixture.engine).draw(anyDouble(), anyDouble()); + + fixture.job.onChunkExistsInRegionGen(2, -3); + verify(fixture.engine, never()).draw(anyDouble(), anyDouble()); + assertEquals(1, fixture.tasks.size()); + fixture.tasks.removeFirst().run(); + } + + @Test + public void existingChunksDoNotResolveBiomesWhileThePreviewIsHidden() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.renderer.isVisibleFrame()).thenReturn(false); + + fixture.job.onChunkExistsInRegionGen(2, -3); + + assertEquals(0, fixture.tasks.size()); + verify(fixture.engine, never()).draw(anyDouble(), anyDouble()); + } + + @Test + public void queuedPreviewSkipsReadsWhenTheFrameBecomesHidden() throws Exception { + Fixture fixture = new Fixture(); + fixture.job.onChunkGenerated(2, -3, false); + when(fixture.renderer.isVisibleFrame()).thenReturn(false); + + fixture.tasks.removeFirst().run(); + + verify(fixture.engine, never()).draw(anyDouble(), anyDouble()); + } + + @Test + public void actualDrawFailureStillReportsWhenShutdownStartsDuringRendering() throws Exception { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("Palette failure"); + doAnswer(invocation -> { + when(fixture.engine.isClosing()).thenReturn(true); + throw failure; + }).when(fixture.engine).drawForPreview(anyInt(), anyInt()); + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + fixture.job.onChunkGenerated(2, -3, false); + fixture.tasks.removeFirst().run(); + + logging.verify(() -> IrisLogging.reportError( + "Unable to draw the pregeneration preview for chunk 2,-3.", failure)); + } + } + + private static final class Fixture { + private final PregeneratorJob job = mock(PregeneratorJob.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + private final PregenRenderer renderer = mock(PregenRenderer.class); + private final ArrayList tasks = new ArrayList<>(); + + private Fixture() throws Exception { + ExecutorService service = mock(ExecutorService.class); + doAnswer(invocation -> { + tasks.add(invocation.getArgument(0)); + return null; + }).when(service).execute(any(Runnable.class)); + when(renderer.isVisibleFrame()).thenReturn(true); + setField("engine", engine); + setField("renderer", renderer); + setField("service", service); + } + + private void setField(String name, Object value) throws Exception { + Field field = PregeneratorJob.class.getDeclaredField(name); + field.setAccessible(true); + field.set(job, value); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEnginePreviewTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEnginePreviewTest.java new file mode 100644 index 000000000..ba7eebb57 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEnginePreviewTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.SavedBiomeRuntime; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import org.junit.Test; + +import java.awt.Color; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.function.Function; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisEnginePreviewTest { + @Test + public void previewRendersThePreparedEnvironmentWhileItIsRetained() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime biomes = mock(SavedBiomeRuntime.class); + GenerationSessionLease lease = mock(GenerationSessionLease.class); + BiomeEnvironment environment = new BiomeEnvironment(1L, mock(IrisBiome.class), mock(IrisRegion.class), + mock(IrisDimension.class), mock(IrisData.class)); + Field field = IrisEngine.class.getDeclaredField("generationHistoryRuntimeRouter"); + field.setAccessible(true); + field.set(engine, router); + when(engine.acquireGenerationLease("pregen_preview")).thenReturn(lease); + when(router.biomes()).thenReturn(biomes); + when(biomes.readSurfaceBiome(eq(-24), eq(40), any())).thenAnswer(invocation -> { + Function, Color> reader = invocation.getArgument(2); + Color color = reader.apply(Optional.of(environment)); + verify(engine).drawBiomeEnvironment(-24, 40, environment); + return color; + }); + when(engine.drawBiomeEnvironment(-24, 40, environment)).thenReturn(Color.RED); + doCallRealMethod().when(engine).drawForPreview(-24, 40); + + assertSame(Color.RED, engine.drawForPreview(-24, 40)); + + verify(lease).close(); + verify(engine, never()).getSurfaceBiomeEnvironment(anyInt(), anyInt()); + verify(engine, never()).getRegion(anyInt(), anyInt()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EnginePreviewColorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EnginePreviewColorTest.java new file mode 100644 index 000000000..6f0ebeb66 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EnginePreviewColorTest.java @@ -0,0 +1,49 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.common.data.DataProvider; +import org.junit.Test; + +import java.awt.Color; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EnginePreviewColorTest { + @Test + public void previewUsesOneSurfaceEnvironmentAndItsHistoricalRegionDefinitions() { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + IrisData data = mock(IrisData.class); + IrisRegion region = mock(IrisRegion.class); + IrisBiome biome = mock(IrisBiome.class); + BiomeEnvironment environment = new BiomeEnvironment(1L, biome, region, mock(IrisDimension.class), data); + EngineTarget target = mock(EngineTarget.class); + doReturn(environment).when(engine).getSurfaceBiomeEnvironment(-24, 40); + doReturn(target).when(engine).getTarget(); + doReturn(100).when(engine).getHeight(-24, 40); + when(target.getHeight()).thenReturn(384); + when(region.getColor(any(), any())).thenAnswer(invocation -> { + DataProvider provider = invocation.getArgument(0); + assertSame(data, provider.getData()); + return Color.GREEN; + }); + when(biome.getColor(any(), any())).thenReturn(Color.YELLOW); + + assertNotNull(engine.draw(-24, 40)); + + verify(engine).getSurfaceBiomeEnvironment(-24, 40); + verify(engine, never()).getRegion(anyInt(), anyInt()); + verify(engine, never()).getSurfaceBiome(anyInt(), anyInt()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java index f8f9eac92..ebb093777 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java @@ -11,6 +11,8 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.IOException; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -22,6 +24,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantReadWriteLock; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; @@ -30,6 +35,8 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; public class SavedBiomeRuntimeTest { @Rule @@ -217,9 +224,233 @@ public class SavedBiomeRuntimeTest { Files.delete(pack.resolve("regions/main.json")); assertSame(environment, runtime.resolve(0, 0, 0, true).orElseThrow()); assertEquals("forest", environment.biome().getLoadKey()); + assertSame(environment, runtime.readSurfaceBiome(0, 0, Optional::orElseThrow)); } } + @Test + public void backgroundPreviewWaitsForTheReadWithoutRepeatingTheQuery() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.empty(); + }); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + ExecutorService caller = Executors.newSingleThreadExecutor()) { + Future rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> rendered.get(100L, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + assertTrue(rendered.get(5L, TimeUnit.SECONDS)); + verify(store).get(0, 0); + } + } + + @Test + public void backgroundPreviewRetainsItsResultAcrossQueryCacheEviction() throws Exception { + allowUnownedChunks(); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + ExecutorService caller = Executors.newSingleThreadExecutor()) { + Field field = SavedBiomeRuntime.class.getDeclaredField("consumption"); + field.setAccessible(true); + ReentrantReadWriteLock consumption = (ReentrantReadWriteLock) field.get(runtime); + consumption.writeLock().lock(); + Future rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); + while (runtime.cachedQueryCount() == 0 && System.nanoTime() < deadline) { + Thread.sleep(1L); + } + assertEquals(1, runtime.cachedQueryCount()); + for (int chunkX = 1; chunkX <= 130; chunkX++) { + int requestedX = chunkX; + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.prepareChunk(requestedX, 0)).isLoading()); + awaitIdle(runtime); + } + assertThrows(TimeoutException.class, () -> rendered.get(100L, TimeUnit.MILLISECONDS)); + } finally { + consumption.writeLock().unlock(); + } + assertTrue(rendered.get(5L, TimeUnit.SECONDS)); + verify(store).get(0, 0); + } + } + + @Test + public void captureRefreshesThePendingPreviewWithoutAddingAnotherSlot() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger readCount = new AtomicInteger(); + when(store.get(0, 0)).thenAnswer(invocation -> { + if (readCount.getAndIncrement() == 0) { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + } + return Optional.empty(); + }); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + ExecutorService caller = Executors.newSingleThreadExecutor()) { + Future rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + runtime.capture(unresolvedChunk(1)); + assertEquals(1, runtime.pendingQueryCount()); + } finally { + release.countDown(); + } + assertTrue(rendered.get(5L, TimeUnit.SECONDS)); + verify(store, times(2)).get(0, 0); + } + } + + @Test + public void closeWaitsForBackgroundConsumptionBeforeClosingDefinitions() throws Exception { + allowUnownedChunks(); + CountDownLatch consuming = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + try (ExecutorService callers = Executors.newFixedThreadPool(2)) { + Future rendered = callers.submit(() -> runtime.readSurfaceBiome(0, 0, environment -> { + consuming.countDown(); + try { + assertTrue(release.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + return environment.isEmpty(); + })); + assertTrue(consuming.await(5L, TimeUnit.SECONDS)); + Future closing = callers.submit(runtime::close); + try { + assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + assertTrue(rendered.get(5L, TimeUnit.SECONDS)); + closing.get(5L, TimeUnit.SECONDS); + assertThrows(IllegalStateException.class, () -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + } finally { + release.countDown(); + runtime.close(); + } + } + + @Test + public void backgroundPreviewPreservesActualReadFailure() throws Exception { + IOException failure = new IOException("Saved biome read failed"); + when(store.get(0, 0)).thenThrow(failure); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + assertEquals(false, unavailable.isLoading()); + assertSame(failure, unavailable.getCause()); + } + } + + @Test + public void closeCompletesWaitingPreviewWithoutConsumingClosedDefinitions() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.empty(); + }); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + AtomicInteger consumed = new AtomicInteger(); + try (ExecutorService callers = Executors.newFixedThreadPool(2)) { + Future rendered = callers.submit(() -> runtime.readSurfaceBiome(0, 0, + environment -> consumed.incrementAndGet())); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + Future closing = callers.submit(runtime::close); + try { + assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + ExecutionException unavailable = assertThrows(ExecutionException.class, + () -> rendered.get(5L, TimeUnit.SECONDS)); + assertTrue(unavailable.getCause() instanceof IllegalStateException); + assertEquals(0, consumed.get()); + closing.get(5L, TimeUnit.SECONDS); + } finally { + release.countDown(); + runtime.close(); + } + } + + @Test + public void fullReadQueueLeavesThePreviewPendingWithoutAddingWork() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + when(store.get(anyInt(), anyInt())).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.empty(); + }); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + try { + for (int chunkX = 0; chunkX < 256; chunkX++) { + int requestedX = chunkX; + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.prepareChunk(requestedX, 0)).isLoading()); + } + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertEquals(256, runtime.pendingQueryCount()); + assertTrue(assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.readSurfaceBiome(256 * 16, 0, Optional::isEmpty)).isLoading()); + assertEquals(256, runtime.pendingQueryCount()); + } finally { + release.countDown(); + runtime.close(); + } + } + + @Test + public void interruptingPreviewDoesNotCancelTheSharedRead() throws Exception { + allowUnownedChunks(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(store.get(0, 0)).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return Optional.empty(); + }); + SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history); + try (ExecutorService caller = Executors.newSingleThreadExecutor()) { + Future rendered = caller.submit(() -> runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertTrue(rendered.cancel(true)); + assertEquals(1, runtime.pendingQueryCount()); + } finally { + release.countDown(); + } + assertTrue(runtime.readSurfaceBiome(0, 0, Optional::isEmpty)); + verify(store).get(0, 0); + } finally { + release.countDown(); + runtime.close(); + } + } + + private void allowUnownedChunks() throws IOException { + when(store.get(anyInt(), anyInt())).thenReturn(Optional.empty()); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true); + when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty()); + } + private static void awaitIdle(SavedBiomeRuntime runtime) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); while (runtime.pendingQueryCount() != 0 && System.nanoTime() < deadline) {