From df717cf1518294c3de9f848449bf3591d016732f Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:05:19 -0400 Subject: [PATCH 1/5] 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) { From cb85a09c0aef1542478dbdbaef981e048d0f3d2a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:08:31 -0400 Subject: [PATCH 2/5] Cached native biome handles - Biome wrappers read canonical keys only on cache insertion - Native handle identity prevents stale biome reuse after registry replacement - The biome wrapper cache retains at most 4096 entries - Regression tests cover key reuse, namespaces, replacement handles, and eviction --- .../iris/platform/bukkit/BukkitBiome.java | 34 ++-- .../iris/platform/bukkit/BukkitBiomeTest.java | 154 ++++++++++++++++++ 2 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/BukkitBiomeTest.java diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java index 245ffe4a6..89800c39d 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBiome.java @@ -19,43 +19,33 @@ package art.arcane.iris.platform.bukkit; import art.arcane.iris.spi.PlatformBiome; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.bukkit.NamespacedKey; import org.bukkit.block.Biome; -import java.util.concurrent.ConcurrentHashMap; - /** * Interned Bukkit adapter for a neutral biome handle. */ public final class BukkitBiome implements PlatformBiome { - private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + private static final Cache CACHE = Caffeine.newBuilder() + .weakKeys() + .maximumSize(4_096) + .build(); private final Biome biome; private final String key; private final String namespace; - private BukkitBiome(Biome biome, String key) { + private BukkitBiome(Biome biome) { this.biome = biome; - this.key = key; - int colon = key.indexOf(':'); - this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; + NamespacedKey biomeKey = biome.getKey(); + this.key = biomeKey.toString(); + this.namespace = biomeKey.getNamespace(); } public static BukkitBiome of(Biome biome) { - String key = biomeKey(biome); - return CACHE.computeIfAbsent(key, (String k) -> new BukkitBiome(biome, k)); - } - - private static String biomeKey(Biome biome) { - for (String method : new String[]{"getKeyOrNull", "getKeyOrThrow", "getKey"}) { - try { - Object key = Biome.class.getMethod(method).invoke(biome); - if (key != null) { - return key.toString(); - } - } catch (Throwable ignored) { - } - } - return biome.toString(); + return CACHE.get(biome, BukkitBiome::new); } @Override diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitBiomeTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitBiomeTest.java new file mode 100644 index 000000000..3e637096e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitBiomeTest.java @@ -0,0 +1,154 @@ +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.spi.PlatformBiome; +import com.github.benmanes.caffeine.cache.Cache; +import org.bukkit.NamespacedKey; +import org.junit.Before; +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class BukkitBiomeTest { + private Class biomeType; + private Method wrap; + + @Before + public void loadAdapterWithoutServerRegistryInitialization() throws Exception { + ClassLoader loader = new BiomeClassLoader(); + biomeType = loader.loadClass("org.bukkit.block.Biome"); + wrap = loader.loadClass(BukkitBiome.class.getName()).getMethod("of", biomeType); + } + + @Test + public void cachedHandlesKeepCanonicalIdentityWithoutReadingTheKeyAgain() throws Exception { + AtomicInteger keyReads = new AtomicInteger(); + Object biome = biome("iris", "riverbank", keyReads); + + PlatformBiome wrapped = wrap(biome); + assertEquals("iris:riverbank", wrapped.key()); + assertEquals("iris", wrapped.namespace()); + assertSame(biome, wrapped.nativeHandle()); + assertSame(wrapped, wrap(biome)); + assertSame(wrapped, wrap(biome)); + assertEquals(1, keyReads.get()); + } + + @Test + public void replacementRegistryHandleCannotReturnThePreviousNativeBiome() throws Exception { + Object previous = biome("iris", "registry_replacement", new AtomicInteger()); + Object replacement = biome("iris", "registry_replacement", new AtomicInteger()); + + PlatformBiome oldHandle = wrap(previous); + PlatformBiome newHandle = wrap(replacement); + + assertNotSame(oldHandle, newHandle); + assertEquals(oldHandle.key(), newHandle.key()); + assertSame(previous, oldHandle.nativeHandle()); + assertSame(replacement, newHandle.nativeHandle()); + assertSame(newHandle, wrap(replacement)); + } + + @Test + public void customAndVanillaNamespacesRemainDistinct() throws Exception { + PlatformBiome vanilla = wrap(biome("minecraft", "plains", new AtomicInteger())); + PlatformBiome custom = wrap(biome("iris", "plains", new AtomicInteger())); + + assertEquals("minecraft:plains", vanilla.key()); + assertEquals("minecraft", vanilla.namespace()); + assertEquals("iris:plains", custom.key()); + assertNotSame(vanilla, custom); + } + + @Test + public void cacheEvictionPreservesBiomeKeysAndNativeHandles() throws Exception { + Object retained = biome("iris", "retained", new AtomicInteger()); + PlatformBiome original = wrap(retained); + for (int index = 0; index < 8_192; index++) { + wrap(biome("iris", "eviction_" + index, new AtomicInteger())); + } + Field field = wrap.getDeclaringClass().getDeclaredField("CACHE"); + field.setAccessible(true); + Cache cache = (Cache) field.get(null); + cache.cleanUp(); + + assertTrue(cache.estimatedSize() <= 4_096); + PlatformBiome restored = wrap(retained); + assertEquals(original.key(), restored.key()); + assertEquals(original.namespace(), restored.namespace()); + assertSame(retained, restored.nativeHandle()); + } + + private PlatformBiome wrap(Object biome) throws Exception { + return (PlatformBiome) wrap.invoke(null, biome); + } + + private Object biome(String namespace, String name, AtomicInteger keyReads) { + NamespacedKey key = new NamespacedKey(namespace, name); + return Proxy.newProxyInstance(biomeType.getClassLoader(), new Class[]{biomeType}, (proxy, method, arguments) -> switch (method.getName()) { + case "getKey" -> { + keyReads.incrementAndGet(); + yield key; + } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> key.toString(); + default -> throw new UnsupportedOperationException(method.getName()); + }); + } + + private static final class BiomeClassLoader extends ClassLoader { + private BiomeClassLoader() { + super(BukkitBiomeTest.class.getClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals("org.bukkit.block.Biome") && !name.equals(BukkitBiome.class.getName())) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + try (InputStream source = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (source == null) { + throw new ClassNotFoundException(name); + } + byte[] bytecode = source.readAllBytes(); + if (name.equals("org.bukkit.block.Biome")) { + ClassWriter writer = new ClassWriter(0); + new ClassReader(bytecode).accept(new ClassVisitor(Opcodes.ASM9, writer) { + @Override + public MethodVisitor visitMethod(int access, String method, String descriptor, String signature, String[] exceptions) { + return method.equals("") ? null : super.visitMethod(access, method, descriptor, signature, exceptions); + } + }, 0); + bytecode = writer.toByteArray(); + } + loaded = defineClass(name, bytecode, 0, bytecode.length); + } catch (IOException exception) { + throw new ClassNotFoundException(name, exception); + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + } +} From fca4a583885cf0dd74dfea9dcbeb5150db8deb45 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:09:33 -0400 Subject: [PATCH 3/5] Reduced generation routing contention - Ready generation bindings acquire their lease in one metadata lock pass - Shared routing metadata uses nonfair lock handoffs under worker contention - Single-runtime release avoids allocating an empty retirement list - Concurrent route tests verify shutdown drains and rejects active workers --- .../GenerationHistoryRuntimeRouter.java | 8 +++- .../GenerationHistoryRuntimeRouterTest.java | 45 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java index 497060470..dc399a1e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouter.java @@ -51,7 +51,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "activation runtime factory"); this.bindings = new LinkedHashMap<>(); this.retiringBindings = new LinkedHashMap<>(); - this.stateLock = new ReentrantLock(true); + this.stateLock = new ReentrantLock(); this.inactive = stateLock.newCondition(); this.operationDepth = ThreadLocal.withInitial(() -> 0); this.scopedRoute = new ThreadLocal<>(); @@ -449,6 +449,9 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { loader = true; } entry.leases++; + if (!entry.loading && entry.loadFailure == null && entry.binding != null) { + return new RuntimeLease(this, entry, entry.binding); + } } finally { stateLock.unlock(); } @@ -584,6 +587,9 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable { } private List collectEvictionsLocked() { + if (bindings.size() <= 1) { + return List.of(); + } ArrayList retired = new ArrayList<>(); while (bindings.size() > 1) { RuntimeCacheEntry candidate = null; diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java index 4d352a466..fe5db35cf 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -389,6 +389,33 @@ public final class GenerationHistoryRuntimeRouterTest { verify(engine, never()).closeDetachedGenerationRuntime(active); } + @Test + public void closeCompletesWhileWorkersContinuouslyAcquireReadyRoutes() throws Exception { + Path world = temporaryFolder.newFolder("router-contended-close-world").toPath(); + GenerationHistory history = createHistory(world, createPack("router-contended-close-pack", "alpha")); + IrisEngine engine = mock(IrisEngine.class); + FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); + IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation()); + when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active); + ExecutorService executor = Executors.newFixedThreadPool(9); + CountDownLatch routing = new CountDownLatch(8); + CompletableFuture[] workers = new CompletableFuture[8]; + try (GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes)) { + for (int i = 0; i < workers.length; i++) { + workers[i] = CompletableFuture.runAsync(() -> routeUntilClosed(router, routing), executor); + } + assertTrue(routing.await(5, TimeUnit.SECONDS)); + CompletableFuture.runAsync(router::close, executor).get(5, TimeUnit.SECONDS); + CompletableFuture.allOf(workers).get(5, TimeUnit.SECONDS); + verify(engine).detachGenerationHistoryRuntimeRouter(router); + verify(engine, never()).closeDetachedGenerationRuntime(active); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test public void closeLeavesTheSingleDefaultRuntimeOwnedByTheEngine() throws Exception { Path world = temporaryFolder.newFolder("router-close-current-world").toPath(); @@ -894,6 +921,24 @@ public final class GenerationHistoryRuntimeRouterTest { router.close(); } + private static void routeUntilClosed(GenerationHistoryRuntimeRouter router, CountDownLatch routing) { + boolean started = false; + while (!Thread.currentThread().isInterrupted()) { + try (GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(0, 0)) { + assertEquals(1L, route.activation().activationId()); + if (!started) { + started = true; + routing.countDown(); + } + } catch (IOException failure) { + throw new AssertionError(failure); + } catch (IllegalStateException closed) { + assertEquals("Generation-history runtime router is closed.", closed.getMessage()); + return; + } + } + } + private AtomicReference installScopeTracking(IrisEngine engine) { AtomicReference scoped = new AtomicReference<>(); when(engine.openGenerationRuntimeScope(any())).thenAnswer(invocation -> { From 3425c05e8b955c65d30e5b27ededcf396aa2071b Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:11:41 -0400 Subject: [PATCH 4/5] Fixed saved biome scoreboards - Keep Studio board refreshes alive while saved biome data loads - Sample mantle-aware biome environments and clear prior world values - Report permanent failures once per display context - Localize loading states and verify recovery and Jigsaw transitions --- .../core/localization/BukkitUiMessages.java | 10 + .../arcane/iris/core/service/BoardSVC.java | 43 ++- .../art/arcane/iris/engine/IrisEngine.java | 12 + .../arcane/iris/engine/framework/Engine.java | 4 + core/src/main/resources/languages/de_DE.json | 2 + core/src/main/resources/languages/es_ES.json | 2 + core/src/main/resources/languages/fi_FI.json | 2 + core/src/main/resources/languages/fr_FR.json | 2 + core/src/main/resources/languages/he_IL.json | 2 + core/src/main/resources/languages/it_IT.json | 2 + core/src/main/resources/languages/ja-JP.json | 2 + core/src/main/resources/languages/ko_KR.json | 2 + core/src/main/resources/languages/lt_LT.json | 2 + core/src/main/resources/languages/nl_NL.json | 2 + core/src/main/resources/languages/pl_PL.json | 2 + core/src/main/resources/languages/pt_PT.json | 2 + core/src/main/resources/languages/ru_RU.json | 2 + core/src/main/resources/languages/tr_TR.json | 2 + core/src/main/resources/languages/vi_VI.json | 2 + core/src/main/resources/languages/zh_CN.json | 2 + core/src/main/resources/languages/zh_TW.json | 2 + .../core/service/BoardSVCSavedBiomeTest.java | 282 ++++++++++++++++++ .../IrisEngineGenerationRuntimeScopeTest.java | 1 + 23 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitUiMessages.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitUiMessages.java index e9b070109..36df1b54a 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitUiMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitUiMessages.java @@ -35,6 +35,14 @@ public final class BukkitUiMessages { "iris.bukkit.scoreboard.biome", C.AQUA + "Biome" + C.GRAY + ": {biome}" ); + public static final TextKey SCOREBOARD_BIOME_LOADING = TextKey.of( + "iris.bukkit.scoreboard.biome_loading", + "Loading" + ); + public static final TextKey SCOREBOARD_BIOME_UNAVAILABLE = TextKey.of( + "iris.bukkit.scoreboard.biome_unavailable", + "Unavailable" + ); public static final TextKey SCOREBOARD_HEIGHT = TextKey.of( "iris.bukkit.scoreboard.height", C.AQUA + "Height" + C.GRAY + ": {height}" @@ -56,6 +64,8 @@ public final class BukkitUiMessages { SCOREBOARD_CARVING, SCOREBOARD_REGION, SCOREBOARD_BIOME, + SCOREBOARD_BIOME_LOADING, + SCOREBOARD_BIOME_UNAVAILABLE, SCOREBOARD_HEIGHT, SCOREBOARD_SLOPE, SCOREBOARD_BLOCK_UPDATES diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index af26dbc55..0872d7b7b 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -25,7 +25,9 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.board.Board; import art.arcane.volmlib.util.board.BoardProvider; @@ -358,6 +360,8 @@ public class BoardSVC implements IrisService, BoardProvider { private volatile BoardView view; private volatile boolean cancelled; private volatile boolean ordinaryTickScheduled; + private UUID ordinaryWorldId; + private BiomeFailureContext reportedBiomeFailure; public PlayerBoard(Player player) { this.player = player; @@ -377,10 +381,12 @@ public class BoardSVC implements IrisService, BoardProvider { yieldBoard(player, this); return; } - boolean switched = view != BoardView.ORDINARY; + UUID worldId = player.getWorld().getUID(); + boolean switched = view != BoardView.ORDINARY || !worldId.equals(ordinaryWorldId); view = BoardView.ORDINARY; jigsawContext = null; if (switched) { + ordinaryWorldId = worldId; updateOrdinary(); board.update(); } @@ -515,14 +521,42 @@ public class BoardSVC implements IrisService, BoardProvider { } lines.add("&7&m "); - lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_REGION, MessageArgument.untrusted("region", engine.getRegion(x, y, z).getName()))); - lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BIOME, MessageArgument.untrusted("biome", engine.getBiomeOrMantle(x, y, z).getName()))); + String regionName; + String biomeName; + try { + BiomeEnvironment environment = engine.getBiomeOrMantleEnvironment(x, y, z); + regionName = environment.region().getName(); + biomeName = environment.biome().getName(); + reportedBiomeFailure = null; + } catch (SavedBiomeUnavailableException failure) { + regionName = IrisLanguage.text(failure.isLoading() + ? BukkitUiMessages.SCOREBOARD_BIOME_LOADING + : BukkitUiMessages.SCOREBOARD_BIOME_UNAVAILABLE); + biomeName = regionName; + if (!failure.isLoading()) { + reportBiomeFailure(failure, engine, loc); + } + } + lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_REGION, MessageArgument.untrusted("region", regionName))); + lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BIOME, MessageArgument.untrusted("biome", biomeName))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_HEIGHT, MessageArgument.trusted("height", Math.round(engine.getHeight(x, z))))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_SLOPE, MessageArgument.trusted("slope", Form.f(engine.getSlope(x, z), 2)))); lines.add(IrisLanguage.text(BukkitUiMessages.SCOREBOARD_BLOCK_UPDATES, MessageArgument.trusted("updates", Form.f(engine.getBlockUpdatesPerSecond())))); lines.add("&7&m "); this.lines = lines; } + + private void reportBiomeFailure(SavedBiomeUnavailableException failure, Engine engine, Location location) { + World world = Objects.requireNonNull(location.getWorld()); + BiomeFailureContext context = new BiomeFailureContext(world.getUID(), engine, + location.getBlockX() >> 4, location.getBlockZ() >> 4, failure.getMessage()); + if (context.equals(reportedBiomeFailure)) { + return; + } + reportedBiomeFailure = context; + IrisLogging.reportError("Unable to display saved Iris biome information in " + world.getName() + + " at " + location.getBlockX() + ", " + location.getBlockY() + ", " + location.getBlockZ() + ".", failure); + } } private void yieldBoard(Player player, PlayerBoard playerBoard) { @@ -536,4 +570,7 @@ public class BoardSVC implements IrisService, BoardProvider { ORDINARY, JIGSAW } + + private record BiomeFailureContext(UUID worldId, Engine engine, int chunkX, int chunkZ, String reason) { + } } 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..e7f32ec86 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -559,6 +559,18 @@ public class IrisEngine implements Engine { } } + @Override + public BiomeEnvironment getBiomeOrMantleEnvironment(int x, int y, int z) { + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + if (saved.isPresent()) { + return saved.get(); + } + try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = + openGenerationHistoryCoordinateScopeUnchecked(x, z, "resolve a mantle biome environment")) { + return Engine.super.getBiomeOrMantleEnvironment(x, y, z); + } + } + @Override public BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) { Optional saved = resolveSavedBiomeEnvironment(x, 0, z, true); 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..ad81331a2 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 @@ -97,6 +97,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return new BiomeEnvironment(0L, getBiome(x, y, z), getRegion(x, y, z), getDimension(), getData()); } + default BiomeEnvironment getBiomeOrMantleEnvironment(int x, int y, int z) { + return new BiomeEnvironment(0L, getBiomeOrMantle(x, y, z), getRegion(x, y, z), getDimension(), getData()); + } + default BiomeEnvironment getSurfaceBiomeEnvironment(int x, int z) { return new BiomeEnvironment(0L, getSurfaceBiome(x, z), getRegion(x, z), getDimension(), getData()); } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 3ca1aacdd..9b4354493 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dCarving§7: {state}", "iris.bukkit.scoreboard.region": "§bRegion§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiom§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Wird geladen", + "iris.bukkit.scoreboard.biome_unavailable": "Nicht verfügbar", "iris.bukkit.scoreboard.height": "§bHöhe§7: {height}", "iris.bukkit.scoreboard.slope": "§bSteigung§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 3cb6ad977..e74a394c9 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dTallado§7: {state}", "iris.bukkit.scoreboard.region": "§bRegión§7: {region}", "iris.bukkit.scoreboard.biome": "§bBioma§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Cargando", + "iris.bukkit.scoreboard.biome_unavailable": "No disponible", "iris.bukkit.scoreboard.height": "§bAltura§7: {height}", "iris.bukkit.scoreboard.slope": "§bPendiente§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index e251146d8..abcc6400d 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dKaiverrus§7: {state}", "iris.bukkit.scoreboard.region": "§bAlue§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiometri§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Ladataan", + "iris.bukkit.scoreboard.biome_unavailable": "Ei saatavilla", "iris.bukkit.scoreboard.height": "§bKorkeus§7: {height}", "iris.bukkit.scoreboard.slope": "§bRivi§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUDName/s§7: {updates}", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 7351df21e..5054efcc7 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dSculpture§7: {state}", "iris.bukkit.scoreboard.region": "§bRégion§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiome§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Chargement", + "iris.bukkit.scoreboard.biome_unavailable": "Indisponible", "iris.bukkit.scoreboard.height": "§bHauteur§7: {height}", "iris.bukkit.scoreboard.slope": "§bPente§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 025a42d6e..e479a81cd 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dעבודה§7: {state}", "iris.bukkit.scoreboard.region": "§bאזור§7: {region}", "iris.bukkit.scoreboard.biome": "§bביומי§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "בטעינה", + "iris.bukkit.scoreboard.biome_unavailable": "לא זמין", "iris.bukkit.scoreboard.height": "§bגובה§7: {height}", "iris.bukkit.scoreboard.slope": "§bסלייפ§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index d89dd31ec..d086daed3 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dCarving§7: {state}", "iris.bukkit.scoreboard.region": "§bRegione§7: {region}", "iris.bukkit.scoreboard.biome": "§bBioma§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Caricamento", + "iris.bukkit.scoreboard.biome_unavailable": "Non disponibile", "iris.bukkit.scoreboard.height": "§bAltezza§7: {height}", "iris.bukkit.scoreboard.slope": "§bPendenza§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 7517c1e98..51a26bbe2 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dカービング§7: {state}", "iris.bukkit.scoreboard.region": "§bリージョン§7: {region}", "iris.bukkit.scoreboard.biome": "§bバイオーム§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "読み込み中", + "iris.bukkit.scoreboard.biome_unavailable": "利用不可", "iris.bukkit.scoreboard.height": "§b高さ§7: {height}", "iris.bukkit.scoreboard.slope": "§bスロープ§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 9cdf2c228..7e5c3c931 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§d조각 중§7: {state}", "iris.bukkit.scoreboard.region": "§b주요 특징§7: {region}", "iris.bukkit.scoreboard.biome": "§b바이오메트§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "불러오는 중", + "iris.bukkit.scoreboard.biome_unavailable": "사용 불가", "iris.bukkit.scoreboard.height": "§b고도:§7: {height}", "iris.bukkit.scoreboard.slope": "§b기울기§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD/s§7: {updates}", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index b220c88a2..cb05cdb51 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dRaižyti§7: {state}", "iris.bukkit.scoreboard.region": "§bRegionas§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiomas§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Įkeliama", + "iris.bukkit.scoreboard.biome_unavailable": "Nepasiekiama", "iris.bukkit.scoreboard.height": "§bAukštis§7: {height}", "iris.bukkit.scoreboard.slope": "§bSpop§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBID/s§7: {updates}", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 3d9a4b356..a439f1557 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dsnijden§7: {state}", "iris.bukkit.scoreboard.region": "§bGebieden§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiomeName§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Laden", + "iris.bukkit.scoreboard.biome_unavailable": "Niet beschikbaar", "iris.bukkit.scoreboard.height": "§bHoogte§7: {height}", "iris.bukkit.scoreboard.slope": "§bHelling§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD./s§7: {updates}", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index d9a905191..7873ce982 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dRzeźba§7: {state}", "iris.bukkit.scoreboard.region": "§bObszar§7: {region}", "iris.bukkit.scoreboard.biome": "§bBioma§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Ładowanie", + "iris.bukkit.scoreboard.biome_unavailable": "Niedostępne", "iris.bukkit.scoreboard.height": "§bWysokość§7: {height}", "iris.bukkit.scoreboard.slope": "§bNachylenie§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bPOD/s§7: {updates}", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index af64a5de8..c5928cd4d 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dEsculpir§7: {state}", "iris.bukkit.scoreboard.region": "§bRegião§7: {region}", "iris.bukkit.scoreboard.biome": "§bBioma§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "A carregar", + "iris.bukkit.scoreboard.biome_unavailable": "Indisponível", "iris.bukkit.scoreboard.height": "§bAltura§7: {height}", "iris.bukkit.scoreboard.slope": "§bDeslize§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUD./s§7: {updates}", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 958d8042f..5c59e40aa 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dрезьба§7: {state}", "iris.bukkit.scoreboard.region": "§bрегион§7: {region}", "iris.bukkit.scoreboard.biome": "§bбиом§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Загрузка", + "iris.bukkit.scoreboard.biome_unavailable": "Недоступно", "iris.bukkit.scoreboard.height": "§bвысота§7: {height}", "iris.bukkit.scoreboard.slope": "§bСклон§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bБУД/s§7: {updates}", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index e45953a8a..13489820d 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dAraba§7: {state}", "iris.bukkit.scoreboard.region": "§bBölge Bölgesi§7: {region}", "iris.bukkit.scoreboard.biome": "§bBiyome§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Yükleniyor", + "iris.bukkit.scoreboard.biome_unavailable": "Kullanılamıyor", "iris.bukkit.scoreboard.height": "§bYükseklik§7: {height}", "iris.bukkit.scoreboard.slope": "§bSlopee§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBUDÜ/s§7: {updates}", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index d2b79adf3..e2ce21d00 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§dQuan tâm§7: {state}", "iris.bukkit.scoreboard.region": "§bVùng§7: {region}", "iris.bukkit.scoreboard.biome": "§bĐôi§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "Đang tải", + "iris.bukkit.scoreboard.biome_unavailable": "Không khả dụng", "iris.bukkit.scoreboard.height": "§bChiều cao§7: {height}", "iris.bukkit.scoreboard.slope": "§bTrượt§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§bBAD/s§7: {updates}", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 42e6230b7..8dc0d74f3 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§d雕刻§7: {state}", "iris.bukkit.scoreboard.region": "§b地区§7: {region}", "iris.bukkit.scoreboard.biome": "§b生物团§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "加载中", + "iris.bukkit.scoreboard.biome_unavailable": "不可用", "iris.bukkit.scoreboard.height": "§b高度§7: {height}", "iris.bukkit.scoreboard.slope": "§b斜坡§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§b学生会/s§7: {updates}", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 9513a8cf3..e3999f1a9 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1358,6 +1358,8 @@ "iris.bukkit.scoreboard.carving": "§d雕刻§7: {state}", "iris.bukkit.scoreboard.region": "§b地區§7: {region}", "iris.bukkit.scoreboard.biome": "§b生物團§7: {biome}", + "iris.bukkit.scoreboard.biome_loading": "載入中", + "iris.bukkit.scoreboard.biome_unavailable": "無法使用", "iris.bukkit.scoreboard.height": "§b高度§7: {height}", "iris.bukkit.scoreboard.slope": "§b斜坡§7: {slope}", "iris.bukkit.scoreboard.block_updates": "§b學生會/s§7: {updates}", diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java new file mode 100644 index 000000000..b5e295861 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCSavedBiomeTest.java @@ -0,0 +1,282 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.BiomeEnvironment; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.history.SavedBiomeUnavailableException; +import art.arcane.iris.engine.mantle.EngineMantle; +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.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.board.Board; +import art.arcane.volmlib.util.board.BoardSettings; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.junit.Test; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BoardSVCSavedBiomeTest { + private static IrisPlatform previousPlatform; + + @BeforeClass + public static void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class)); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void worldEntryLoadingKeepsRefreshAliveAndRecoversWithoutMovement() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.biomeFailure = new SavedBiomeUnavailableException("Saved biome is loading", true); + + fixture.service.on(new PlayerChangedWorldEvent(fixture.player, mock(World.class))); + + assertTrue(fixture.lines().contains("Loading")); + assertEquals(1, fixture.ticks.size()); + fixture.logging.verifyNoInteractions(); + + fixture.biomeFailure = null; + fixture.environment = environment("Saved region", "Saved biome"); + fixture.tick(); + + assertTrue(fixture.lines().contains("Saved region")); + assertTrue(fixture.lines().contains("Saved biome")); + assertFalse(fixture.lines().contains("Loading")); + assertEquals(1, fixture.ticks.size()); + verify(fixture.engine, times(2)).getBiomeOrMantleEnvironment(8, 160, 8); + verify(fixture.engine, never()).getRegion(anyInt(), anyInt(), anyInt()); + verify(fixture.engine, never()).getBiomeOrMantle(anyInt(), anyInt(), anyInt()); + } + } + + @Test + public void unsavedBiomeUsesTheMantleOverride() throws Exception { + try (Fixture fixture = new Fixture()) { + IrisBiome natural = mock(IrisBiome.class); + IrisBiome flooded = mock(IrisBiome.class); + when(natural.getName()).thenReturn("Natural cave"); + when(flooded.getName()).thenReturn("Flooded cave"); + when(fixture.engine.getHeight(8, 8)).thenReturn(200); + when(fixture.engine.getCaveBiome(8, 160, 8)).thenReturn(natural); + when(fixture.engine.getCaveOrMantleBiome(8, 160, 8)).thenReturn(flooded); + when(fixture.engine.getDimension()).thenReturn(fixture.environment.dimension()); + when(fixture.engine.getData()).thenReturn(fixture.environment.data()); + doCallRealMethod().when(fixture.engine).getBiome(8, 160, 8); + doCallRealMethod().when(fixture.engine).getBiomeOrMantle(8, 160, 8); + doCallRealMethod().when(fixture.engine).getBiomeEnvironment(8, 160, 8); + doCallRealMethod().when(fixture.engine).getBiomeOrMantleEnvironment(8, 160, 8); + + fixture.service.updatePlayer(fixture.player); + + assertTrue(fixture.lines().contains("Flooded cave")); + assertFalse(fixture.lines().contains("Natural cave")); + verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + } + } + + @Test + public void unavailableBiomeReportsOncePerContextAndKeepsRefreshing() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.biomeFailure = new SavedBiomeUnavailableException("Saved pack is missing", false); + fixture.service.updatePlayer(fixture.player); + + assertTrue(fixture.lines().contains("Unavailable")); + assertFalse(fixture.lines().contains("Loading")); + fixture.biomeFailure = new SavedBiomeUnavailableException("Saved pack is missing", false); + fixture.tick(); + fixture.logging.verify(() -> IrisLogging.reportError(anyString(), + any(SavedBiomeUnavailableException.class)), times(1)); + + fixture.location = new Location(fixture.world, 24, 96, 8); + fixture.tick(); + + fixture.logging.verify(() -> IrisLogging.reportError(anyString(), + any(SavedBiomeUnavailableException.class)), times(2)); + assertEquals(1, fixture.ticks.size()); + } + } + + @Test + public void changingWorldClearsThePreviousBiomeWhileTheNewOneLoads() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.environment = environment("Previous region", "Previous biome"); + fixture.service.updatePlayer(fixture.player); + assertTrue(fixture.lines().contains("Previous biome")); + World previous = fixture.world; + fixture.world = world("second-studio"); + fixture.location = new Location(fixture.world, 8, 96, 8); + fixture.biomeFailure = new SavedBiomeUnavailableException("Saved biome is loading", true); + + fixture.service.on(new PlayerChangedWorldEvent(fixture.player, previous)); + + assertTrue(fixture.lines().contains("Loading")); + assertFalse(fixture.lines().contains("Previous region")); + assertFalse(fixture.lines().contains("Previous biome")); + assertEquals(1, fixture.ticks.size()); + } + } + + @Test + public void jigsawViewDoesNotQueryBiomesAndOrdinaryViewStillRetries() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.biomeFailure = new SavedBiomeUnavailableException("Saved biome is loading", true); + JigsawStudioBoardContext context = new JigsawStudioBoardContext( + fixture.world.getUID(), UUID.randomUUID(), "village", JigsawStudioMode.PLANAR_JIGSAW, + "Corner", "Corner", "mossy", JigsawStudioBoardState.SAVED, "Open the chest"); + + fixture.service.applyJigsawContext(fixture.player, context); + + assertEquals(BoardSVC.jigsawLines(context), fixture.service.getLines(fixture.player)); + verify(fixture.engine, never()).getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt()); + assertTrue(fixture.ticks.isEmpty()); + + fixture.service.clearJigsawContext(fixture.player); + + assertTrue(fixture.lines().contains("Loading")); + assertFalse(fixture.lines().contains("Jigsaw Studio")); + assertEquals(1, fixture.ticks.size()); + } + } + + private static BiomeEnvironment environment(String regionName, String biomeName) { + IrisRegion region = mock(IrisRegion.class); + IrisBiome biome = mock(IrisBiome.class); + when(region.getName()).thenReturn(regionName); + when(biome.getName()).thenReturn(biomeName); + return new BiomeEnvironment(1L, biome, region, mock(IrisDimension.class), mock(IrisData.class)); + } + + private static World world(String name) { + World world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getName()).thenReturn(name); + when(world.getMinHeight()).thenReturn(-64); + return world; + } + + private static void setField(BoardSVC service, String name, Object value) throws ReflectiveOperationException { + Field field = BoardSVC.class.getDeclaredField(name); + field.setAccessible(true); + field.set(service, value); + } + + private static final class Fixture implements AutoCloseable { + private final MockedStatic scheduling = mockStatic(J.class); + private final MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + private final MockedStatic settings = mockStatic(IrisSettings.class); + private final MockedStatic logging = mockStatic(IrisLogging.class); + private final MockedConstruction boards = mockConstruction(Board.class, + (board, context) -> when(board.ownsScoreboardAssignment()).thenReturn(true)); + private final BoardSVC service = new BoardSVC(); + private final Player player = mock(Player.class); + private final Engine engine = mock(Engine.class); + private final ArrayDeque ticks = new ArrayDeque<>(); + private World world = world("first-studio"); + private Location location = new Location(world, 8, 96, 8); + private BiomeEnvironment environment = environment("Ready region", "Ready biome"); + private SavedBiomeUnavailableException biomeFailure; + + private Fixture() throws ReflectiveOperationException { + setField(service, "boardEnabled", true); + setField(service, "settings", BoardSettings.builder().boardProvider(service).build()); + settings.when(IrisSettings::get).thenReturn(new IrisSettings()); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isOnline()).thenReturn(true); + when(player.getWorld()).thenAnswer(invocation -> world); + when(player.getLocation()).thenAnswer(invocation -> location); + scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true); + scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return true; + }); + scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(20), any(Runnable.class))) + .thenAnswer(invocation -> ticks.add(invocation.getArgument(1, Runnable.class))); + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + when(generator.isStudio()).thenReturn(true); + when(generator.getEngine()).thenReturn(engine); + toolbelt.when(() -> IrisToolbelt.access(any(World.class))).thenReturn(generator); + when(engine.getGeneratedPerSecond()).thenReturn(10D); + EngineMantle mantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(mantle); + doAnswer(invocation -> sampledEnvironment()).when(engine).getBiomeEnvironment(anyInt(), anyInt(), anyInt()); + doAnswer(invocation -> sampledEnvironment()).when(engine).getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt()); + doAnswer(invocation -> sampledEnvironment().region()).when(engine).getRegion(anyInt(), anyInt(), anyInt()); + doAnswer(invocation -> sampledEnvironment().biome()).when(engine).getBiomeOrMantle(anyInt(), anyInt(), anyInt()); + } + + private BiomeEnvironment sampledEnvironment() { + if (biomeFailure != null) { + throw biomeFailure; + } + return environment; + } + + private String lines() { + return String.join("\n", service.getLines(player)); + } + + private void tick() { + assertFalse("The ordinary scoreboard must schedule its next refresh", ticks.isEmpty()); + ticks.remove().run(); + } + + @Override + public void close() { + boards.close(); + logging.close(); + settings.close(); + toolbelt.close(); + scheduling.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java index c4f3c86da..8d26b2227 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -645,6 +645,7 @@ public class IrisEngineGenerationRuntimeScopeTest { when(saved.resolveCaveBase(19, -3)).thenReturn(Optional.of(environment)); assertSame(environment, engine.getBiomeEnvironment(19, 20, -3)); + assertSame(environment, engine.getBiomeOrMantleEnvironment(19, 20, -3)); assertSame(environment, engine.getSurfaceBiomeEnvironment(19, -3)); assertSame(environment.biome(), engine.getBiome(19, 20, -3)); assertSame(environment.biome(), engine.getBiomeOrMantle(19, 20, -3)); From 9fdb5615d6f6eb9b6059dff9fecb3be1bebdce6e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 12:11:56 -0400 Subject: [PATCH 5/5] Bounded routing test cleanup - Shutdown assertions interrupt and drain workers before fallback router close --- .../engine/history/GenerationHistoryRuntimeRouterTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java index fe5db35cf..06c930109 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterTest.java @@ -400,8 +400,9 @@ public final class GenerationHistoryRuntimeRouterTest { ExecutorService executor = Executors.newFixedThreadPool(9); CountDownLatch routing = new CountDownLatch(8); CompletableFuture[] workers = new CompletableFuture[8]; - try (GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( - engine, history, (ignored, x, z) -> signature(x, z), runtimes)) { + GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach( + engine, history, (ignored, x, z) -> signature(x, z), runtimes); + try { for (int i = 0; i < workers.length; i++) { workers[i] = CompletableFuture.runAsync(() -> routeUntilClosed(router, routing), executor); } @@ -413,6 +414,7 @@ public final class GenerationHistoryRuntimeRouterTest { } finally { executor.shutdownNow(); assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + router.close(); } }