From 3dc958edcfd79a583305bc7cc1dbbe89292ce3a9 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 26 Oct 2026 23:29:21 -0400 Subject: [PATCH] Stabilized shutdown and Studio - Shutdown protection initializes before enable and drains inline when retention fails - Cleanup preserves class loader retention and closes native providers in order - Error reports retain context and handle empty stack traces - Studio maps retain partial coverage and suspend hidden views - Hydrology previews refresh when plans and rasters complete - Terrain scratch hashing spreads lattice samples across cache slots - Plugin and probe artifacts include LWJGL libraries and native resources - Regression tests cover shutdown, error reporting, map progress, and terrain scratch --- .../src/main/java/art/arcane/iris/Iris.java | 78 +++-- .../art/arcane/iris/FailedEnableLockTest.java | 3 + .../arcane/iris/IrisErrorReportingTest.java | 80 +++++ .../iris/IrisShutdownMaintenanceTest.java | 232 ++++++++++++- build.gradle | 9 + core/build.gradle | 17 +- .../hydrology/engine/HydrologyRuntime.java | 10 +- .../hydrology/plan/HydrologyPlanCache.java | 9 +- .../generation/noise/TerrainBatchScratch.java | 6 +- .../iris/generation/runtime/IrisEngine.java | 1 - .../iris/platform/bukkit/BukkitPlatform.java | 6 + .../nms/BukkitWorldLifecyclePolicy.java | 20 +- .../iris/platform/bukkit/nms/INMSBinding.java | 8 +- .../art/arcane/iris/studio/map/MapCanvas.java | 15 + .../art/arcane/iris/studio/map/MapWindow.java | 48 ++- .../studio/map/VisionRenderController.java | 201 ++++++----- .../map/components/MapTileRenderer.java | 210 +++++++++--- .../iris/studio/render/IrisRenderer.java | 72 ++++ .../arcane/iris/studio/render/WaterMap.java | 102 ++++-- .../engine/HydrologyRuntimeTest.java | 92 +++++ .../plan/HydrologyPlanCacheTest.java | 67 ++++ .../noise/TerrainBatchScratchTest.java | 69 ++++ .../IrisEngineFailureReportingTest.java | 41 +++ .../BukkitPlatformErrorReportingTest.java | 54 +++ .../bukkit/nms/BukkitBindingTest.java | 19 +- .../nms/BukkitWorldLifecyclePolicyTest.java | 65 ++++ .../iris/studio/map/MapTileProgressTest.java | 224 ++++++++++++ .../map/VisionRenderControllerTest.java | 322 ++++++++++++++++-- .../studio/render/WaterMapProgressTest.java | 100 ++++++ gradle/generation-build-revision.gradle | 4 + probe/build.gradle | 6 + 31 files changed, 1973 insertions(+), 217 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisErrorReportingTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/TerrainBatchScratchTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineFailureReportingTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformErrorReportingTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicyTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/map/MapTileProgressTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/render/WaterMapProgressTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 35291cecc..ee0c35397 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -57,6 +57,7 @@ import art.arcane.iris.localization.IrisLanguage; import art.arcane.volmlib.util.director.help.DirectorMiniMenu; import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher; import art.arcane.iris.integration.MultiverseCoreLink; +import art.arcane.iris.platform.agent.Agent; import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; import art.arcane.iris.studio.view.BukkitGuiHost; @@ -201,10 +202,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false); private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false); private final AtomicBoolean servicesDisabled = new AtomicBoolean(false); + private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false); private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false); private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false); private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); - private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false); private volatile boolean generatorDrainCompleted; private volatile boolean enableFailed; private volatile PlaceholderRegistration papiRegistration; @@ -225,6 +226,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private BukkitDebugDump debugDump; private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile ServerShutdownBoundary serverShutdownBoundary; + private boolean shutdownProtectionInitialized; public static VolmitSender getSender() { VolmitSender current = sender; @@ -474,10 +476,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } if (debug) { - String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber(); + String n = e.getClass().getName(); + StackTraceElement[] trace = e.getStackTrace(); + if (trace.length > 0) { + n += "-" + trace[0].getClassName() + "-" + trace[0].getLineNumber(); + } if (e.getCause() != null) { - n += "-" + e.getCause().getStackTrace()[0].getClassName() + "-" + e.getCause().getStackTrace()[0].getLineNumber(); + StackTraceElement[] causeTrace = e.getCause().getStackTrace(); + n += "-" + (causeTrace.length == 0 ? e.getCause().getClass().getName() + : causeTrace[0].getClassName() + "-" + causeTrace[0].getLineNumber()); } File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt"); @@ -627,10 +635,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { postStopFinisherStarted.set(false); serverStopTeardownDeferred.set(false); servicesDisabled.set(false); + runtimeTeardownFailed.set(false); sharedRuntimeClosed.set(false); startupBoundaryRestart.set(false); terminalCleanupCompleted.set(false); - runtimeTeardownFailed.set(false); generatorDrainCompleted = false; deferredShutdownGenerators.clear(); MultiBurst.burst.reopen(); @@ -911,6 +919,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void onEnable() { enableFailed = false; + shutdownProtectionInitialized = false; IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); try { @@ -922,13 +931,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { refuseUnsupportedServer(); return; } + initializeShutdownProtection(); enable(); } catch (Throwable failure) { + enableFailed = true; if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), generatorResolver.refusals().startupWorldsCreated())) { throw failure; } - enableFailed = true; return; } BukkitGuiHost.install(); @@ -946,6 +956,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { reportLockedRuntime(); } + private void initializeShutdownProtection() { + if (!INMS.get().supportsIrisWorldGeneration()) { + return; + } + if (!Agent.install()) { + throw new IllegalStateException("Iris Java agent is unavailable; shutdown protection cannot be installed."); + } + INMS.get().initializeLifecycle(getClass().getClassLoader()); + shutdownProtectionInitialized = true; + } + private void refuseUnsupportedServer() { Throwable bindFailure = INMS.bindFailure(); IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null @@ -1008,7 +1029,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisLanguage.shutdown(); teardownPapi(); boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping()) - && INMS.get().deferPluginClassLoaderClose(); + && tryDeferPluginClassLoaderClose(); boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); if (restartingAtStartupBoundary) { teardownRuntime("startup-boundary-restart", 30L); @@ -1040,7 +1061,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { preservePendingBlockDrops(); papiLifecycleActive = false; teardownPapi(); - if (IrisToolbelt.isServerStopping() && INMS.get().deferPluginClassLoaderClose()) { + if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) { quiesceRuntimeForServerShutdown("pre-unload:" + reason); startPostStopFinisher(); Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); @@ -1055,6 +1076,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return CompletableFuture.completedFuture(null); } + private boolean tryDeferPluginClassLoaderClose() { + if (!shutdownProtectionInitialized) { + return false; + } + try { + INMS.get().deferPluginClassLoaderClose(); + return true; + } catch (RuntimeException | LinkageError failure) { + Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure); + return false; + } + } + /** * Drains the world generators exactly once. Serialized against the JVM shutdown hook so a * second caller cannot rip the pools or services out from under an in-flight drain. @@ -1101,19 +1135,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { try { MultiBurst.burst.close(); } catch (Throwable failure) { - runtimeTeardownFailed.set(true); Iris.reportError("Failed to close Iris generation workers.", failure); } try { MultiBurst.hydrology.close(); } catch (Throwable failure) { - runtimeTeardownFailed.set(true); Iris.reportError("Failed to close Iris hydrology workers.", failure); } try { MultiBurst.ioBurst.close(); } catch (Throwable failure) { - runtimeTeardownFailed.set(true); Iris.reportError("Failed to close Iris I/O workers.", failure); } clearQueues(); @@ -1229,30 +1260,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { if (!terminalCleanupCompleted.compareAndSet(false, true)) { return; } - J.attempt(() -> INMS.get().uninjectBukkit()); + try { + if (INMS.isBound()) { + INMS.get().uninjectBukkit(); + } + } catch (Throwable failure) { + Iris.reportError("Failed to remove Iris world-generation hooks.", failure); + } try { runPostShutdown(); } catch (Throwable e) { runtimeTeardownFailed.set(true); Iris.reportError("Failed to run Iris post-shutdown cleanup.", e); } finally { - if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) { - runtimeTeardownFailed.set(true); - SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit."); - } + boolean activeResources = serverStopTeardownDeferred.get() + && (runtimeTeardownFailed.get() || hasActiveShutdownResources()); IrisPlatforms.unbind(); - if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) { - SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit."); + BukkitPlatform.releaseHost(); + if (activeResources) { + SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit."); } else { try { - INMS.get().releasePluginClassLoaderClose(); - SlimJar.closeNativeProviders(); + if (shutdownProtectionInitialized) { + INMS.get().releasePluginClassLoaderClose(); + } else { + SlimJar.closeNativeProviders(); + } } catch (Throwable failure) { SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup."); failure.printStackTrace(SHUTDOWN_ERRORS); } } - BukkitPlatform.releaseHost(); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java index 25eaf2d03..f86195317 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -148,6 +148,9 @@ public class FailedEnableLockTest { try (EnableFixture fixture = new EnableFixture(false)) { IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable); assertTrue(failure.getMessage(), failure.getMessage().contains("already bound")); + Field failed = Iris.class.getDeclaredField("enableFailed"); + failed.setAccessible(true); + assertTrue(failed.getBoolean(plugin)); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisErrorReportingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisErrorReportingTest.java new file mode 100644 index 000000000..c3dd7e0e5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisErrorReportingTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris; + +import art.arcane.iris.configuration.IrisSettings; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisErrorReportingTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void debugReportingPreservesStacklessFailures() throws Exception { + ArrayIndexOutOfBoundsException failure = new ArrayIndexOutOfBoundsException("raster index"); + failure.setStackTrace(new StackTraceElement[0]); + + String output = reportWithDebug(failure); + + assertEquals(1L, output.lines().filter(line -> line.contains("raster index")).count()); + } + + @Test + public void debugReportingPreservesFailureWithStacklessCause() throws Exception { + ArrayIndexOutOfBoundsException cause = new ArrayIndexOutOfBoundsException("raster index"); + cause.setStackTrace(new StackTraceElement[0]); + IllegalStateException failure = new IllegalStateException("Hydrology failed", cause); + + String output = reportWithDebug(failure); + + assertEquals(1L, output.lines().filter(line -> line.contains("Hydrology failed")).count()); + assertTrue(output.contains("Caused by: java.lang.ArrayIndexOutOfBoundsException: raster index")); + assertTrue(output.contains("IrisErrorReportingTest.debugReportingPreservesFailureWithStacklessCause")); + } + + private String reportWithDebug(Throwable failure) throws Exception { + Iris previousPlugin = Iris.instance; + IrisSettings previousSettings = IrisSettings.settings; + PrintStream previousError = System.err; + Iris plugin = mock(Iris.class); + Field deferred = Iris.class.getDeclaredField("serverStopTeardownDeferred"); + deferred.setAccessible(true); + deferred.set(plugin, new AtomicBoolean()); + File report = new File(temporaryFolder.getRoot(), "failure.txt"); + when(plugin.getDataFile(any(String[].class))).thenReturn(report); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8); + MockedStatic iris = mockStatic(Iris.class)) { + Iris.instance = plugin; + IrisSettings.settings = new IrisSettings(); + IrisSettings.settings.getGeneral().setDebug(true); + System.setErr(errors); + iris.when(() -> Iris.reportError(failure)).thenCallRealMethod(); + + Iris.reportError(failure); + + assertTrue(Files.readString(report.toPath()).contains(failure.toString())); + return output.toString(StandardCharsets.UTF_8); + } finally { + System.setErr(previousError); + IrisSettings.settings = previousSettings; + Iris.instance = previousPlugin; + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java index d8062379b..67e9ffd28 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java @@ -1,7 +1,17 @@ package art.arcane.iris; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.platform.agent.Agent; +import art.arcane.iris.platform.bootstrap.SlimJar; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.plugin.IrisService; +import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.world.lifecycle.IrisRuntimeStatics; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionManager; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; @@ -16,8 +26,8 @@ import art.arcane.iris.world.IrisWorld; import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.world.task.J; -import art.arcane.volmlib.integration.ReloadAware; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; +import art.arcane.volmlib.integration.ReloadAware; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; @@ -27,10 +37,13 @@ import org.mockito.MockedStatic; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -38,12 +51,16 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; -import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -152,13 +169,181 @@ public class IrisShutdownMaintenanceTest { } } + @Test + public void startupEnsuresAgentReadinessBeforeLifecycleProtectionWithoutWorldInjection() throws Exception { + try (Fixture fixture = new Fixture(); MockedStatic agent = mockStatic(Agent.class)) { + when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true); + agent.when(Agent::install).thenReturn(true); + doAnswer(invocation -> { + agent.verify(Agent::install); + assertSame(fixture.plugin.getClass().getClassLoader(), invocation.getArgument(0)); + return null; + }).when(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader()); + invoke(fixture.plugin, "initializeShutdownProtection"); + verify(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader()); + verify(fixture.binding, never()).injectBukkit(); + } + } + + @Test + public void unavailableAgentStopsLifecycleInitialization() throws Exception { + try (Fixture fixture = new Fixture(); MockedStatic agent = mockStatic(Agent.class)) { + when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true); + InvocationTargetException failure = assertThrows(InvocationTargetException.class, + () -> invoke(fixture.plugin, "initializeShutdownProtection")); + assertTrue(failure.getCause() instanceof IllegalStateException); + verify(fixture.binding, never()).initializeLifecycle(fixture.plugin.getClass().getClassLoader()); + } + } + + @Test + public void worldHookTeardownFailureReleasesProtectionAfterServicesAndWorkersStop() throws Exception { + try (Fixture fixture = new Fixture(); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic statics = mockStatic(IrisRuntimeStatics.class); + MockedStatic platforms = mockStatic(IrisPlatforms.class); + MockedStatic bukkitPlatform = mockStatic(BukkitPlatform.class); + MockedStatic providers = mockStatic(SlimJar.class)) { + IrisService service = mock(IrisService.class); + IllegalStateException failure = new IllegalStateException("world hook teardown failed"); + fixture.nms.when(INMS::isBound).thenReturn(true); + doThrow(failure).when(fixture.binding).uninjectBukkit(); + setField(fixture.plugin, "services", new KMap, IrisService>()); + setField(fixture.plugin, "enabledServices", List.of(service)); + doAnswer(invocation -> { + platforms.verify(IrisPlatforms::unbind); + bukkitPlatform.verify(BukkitPlatform::releaseHost); + assertTrue(MultiBurst.burst.isTerminated()); + assertTrue(MultiBurst.hydrology.isTerminated()); + assertTrue(MultiBurst.ioBurst.isTerminated()); + return null; + }).when(fixture.binding).releasePluginClassLoaderClose(); + fixture.quiesce(); + try { + fixture.finish(5L); + fixture.finish(5L); + verify(service).onDisable(); + iris.verify(() -> Iris.reportError("Failed to remove Iris world-generation hooks.", failure)); + assertTrue(MultiBurst.burst.isTerminated()); + assertTrue(MultiBurst.hydrology.isTerminated()); + assertTrue(MultiBurst.ioBurst.isTerminated()); + verify(fixture.binding).releasePluginClassLoaderClose(); + platforms.verify(IrisPlatforms::unbind); + bukkitPlatform.verify(BukkitPlatform::releaseHost); + } finally { + MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); + MultiBurst.ioBurst.reopen(); + } + } + } + + @Test + public void failedServiceWithAnOwnedExecutorRetainsTheClassLoader() throws Exception { + ExecutorService ownedExecutor = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(1); + ownedExecutor.submit(() -> { + started.countDown(); + finish.await(); + return null; + }); + try (Fixture fixture = new Fixture(); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic statics = mockStatic(IrisRuntimeStatics.class); + MockedStatic platforms = mockStatic(IrisPlatforms.class); + MockedStatic bukkitPlatform = mockStatic(BukkitPlatform.class)) { + assertTrue(started.await(5, TimeUnit.SECONDS)); + IrisService service = mock(IrisService.class); + IllegalStateException failure = new IllegalStateException("service teardown failed"); + doThrow(failure).when(service).onDisable(); + setField(fixture.plugin, "services", new KMap, IrisService>()); + setField(fixture.plugin, "enabledServices", List.of(service)); + fixture.quiesce(); + fixture.finish(5L); + verify(service).onDisable(); + iris.verify(() -> Iris.reportError("Failed to disable " + service.getClass().getSimpleName() + ".", failure)); + assertFalse(ownedExecutor.isTerminated()); + verify(fixture.binding, never()).releasePluginClassLoaderClose(); + } finally { + finish.countDown(); + ownedExecutor.shutdownNow(); + assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS)); + MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); + MultiBurst.ioBurst.reopen(); + } + } + + @Test + public void failedPostShutdownCallbackRetainsTheClassLoaderForSkippedCleanup() throws Exception { + ExecutorService ownedExecutor = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(1); + ownedExecutor.submit(() -> { + started.countDown(); + finish.await(); + return null; + }); + try (Fixture fixture = new Fixture(); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic statics = mockStatic(IrisRuntimeStatics.class); + MockedStatic platforms = mockStatic(IrisPlatforms.class); + MockedStatic bukkitPlatform = mockStatic(BukkitPlatform.class)) { + assertTrue(started.await(5, TimeUnit.SECONDS)); + IllegalStateException failure = new IllegalStateException("post-shutdown callback failed"); + AtomicBoolean cleanupAttempted = new AtomicBoolean(); + KList callbacks = new KList<>(); + callbacks.add(() -> { throw failure; }); + callbacks.add(() -> { + cleanupAttempted.set(true); + ownedExecutor.shutdown(); + }); + Field callbacksField = VolmitPlugin.class.getDeclaredField("postShutdown"); + callbacksField.setAccessible(true); + callbacksField.set(fixture.plugin, callbacks); + Method runCallbacks = VolmitPlugin.class.getDeclaredMethod("runPostShutdown"); + runCallbacks.setAccessible(true); + runCallbacks.invoke(doCallRealMethod().when(fixture.plugin)); + fixture.quiesce(); + fixture.finish(5L); + iris.verify(() -> Iris.reportError("Failed to run Iris post-shutdown cleanup.", failure)); + assertFalse(cleanupAttempted.get()); + assertFalse(ownedExecutor.isShutdown()); + assertFalse(ownedExecutor.isTerminated()); + verify(fixture.binding, never()).releasePluginClassLoaderClose(); + } finally { + finish.countDown(); + ownedExecutor.shutdownNow(); + assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS)); + MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); + MultiBurst.ioBurst.reopen(); + } + } + + @Test + public void incompleteDrainRetainsServicesWorkersAndClassLoader() throws Exception { + try (Fixture fixture = new Fixture(); MockedStatic iris = mockStatic(Iris.class)) { + IrisService service = mock(IrisService.class); + setField(fixture.plugin, "services", new KMap, IrisService>()); + setField(fixture.plugin, "enabledServices", List.of(service)); + when(fixture.generator.closeAsync()).thenReturn(new CompletableFuture<>()); + fixture.quiesce(); + fixture.finish(0L); + verify(service, never()).onDisable(); + verify(fixture.binding, never()).releasePluginClassLoaderClose(); + verify(fixture.binding, never()).uninjectBukkit(); + } + } + @Test public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception { try (Fixture fixture = new Fixture()) { - when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(true); setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true)); try { fixture.commitReloadDuringServerStop(); + verify(fixture.binding).deferPluginClassLoaderClose(); verify(fixture.generator).quiesceForServerShutdown(); verify(fixture.generator, never()).closeAsync(); assertTrue(fixture.serverStopTeardownDeferred()); @@ -170,19 +355,43 @@ public class IrisShutdownMaintenanceTest { @Test public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception { - try (Fixture fixture = new Fixture()) { - when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(false); + try (Fixture fixture = new Fixture(); MockedStatic iris = mockStatic(Iris.class)) { + IllegalStateException failure = new IllegalStateException("class loader hold failed"); + doThrow(failure).when(fixture.binding).deferPluginClassLoaderClose(); try { fixture.commitReloadDuringServerStop(); verify(fixture.generator).closeAsync(); verify(fixture.generator, never()).quiesceForServerShutdown(); assertFalse(fixture.serverStopTeardownDeferred()); + iris.verify(() -> Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure)); + } finally { + WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true); + } + } + } + + @Test + public void serverStopPreUnloadDrainsInlineWithoutInitializedProtection() throws Exception { + try (Fixture fixture = new Fixture()) { + setField(fixture.plugin, "shutdownProtectionInitialized", false); + try { + fixture.commitReloadDuringServerStop(); + verify(fixture.binding, never()).deferPluginClassLoaderClose(); + verify(fixture.generator).closeAsync(); + verify(fixture.generator, never()).quiesceForServerShutdown(); + assertFalse(fixture.serverStopTeardownDeferred()); } finally { WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true); } } } + private static void invoke(Iris plugin, String name) throws ReflectiveOperationException { + Method method = Iris.class.getDeclaredMethod(name); + method.setAccessible(true); + method.invoke(plugin); + } + private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException { Field field = Iris.class.getDeclaredField(name); field.setAccessible(true); @@ -210,6 +419,12 @@ public class IrisShutdownMaintenanceTest { private Fixture() throws ReflectiveOperationException { IrisSettings.settings = new IrisSettings(); + setField(plugin, "shutdownProtectionInitialized", true); + setField(plugin, "alreadyDrained", new AtomicBoolean()); + setField(plugin, "servicesDisabled", new AtomicBoolean()); + setField(plugin, "runtimeTeardownFailed", new AtomicBoolean()); + setField(plugin, "sharedRuntimeClosed", new AtomicBoolean()); + setField(plugin, "terminalCleanupCompleted", new AtomicBoolean()); setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean()); setField(plugin, "postStopFinisherStarted", new AtomicBoolean()); setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList()); @@ -251,6 +466,13 @@ public class IrisShutdownMaintenanceTest { return ((AtomicBoolean) field.get(plugin)).get(); } + + private void finish(long timeoutSeconds) throws ReflectiveOperationException { + Method method = Iris.class.getDeclaredMethod("finishDeferredRuntimeTeardown", String.class, long.class); + method.setAccessible(true); + method.invoke(plugin, "test_shutdown", timeoutSeconds); + } + private void startFinisher() throws ReflectiveOperationException { Method method = Iris.class.getDeclaredMethod("startPostStopFinisher"); method.setAccessible(true); diff --git a/build.gradle b/build.gradle index fca8d7fc2..092c631ea 100644 --- a/build.gradle +++ b/build.gradle @@ -180,6 +180,13 @@ def jarJar = configurations.create('jarJar') dependencies { add('included', project(path: ':core', configuration: 'shadow')) add('included', project(path: ':adapters:bukkit:plugin', configuration: 'runtimeElements')) + // Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar. + add('included', libs.lwjgl) + add('included', libs.lwjgl.opengl) + add('included', libs.lwjgl.egl) + add('included', libs.lwjgl.jawt) + add('included', variantOf(libs.lwjgl) { classifier('natives-linux') }) + add('included', variantOf(libs.lwjgl.opengl) { classifier('natives-linux') }) add('jarJar', project(':core:agent')) } @@ -194,6 +201,7 @@ tasks.named('jar', Jar).configure { includeEmptyDirs = false from(jarJar) from(provider { included.resolve().collect { zipTree(it) } }) { + exclude('META-INF/INDEX.LIST') exclude('languages/**') exclude('com/fren_gor/**') @@ -734,6 +742,7 @@ pluginPackaging { policyName = 'Iris Bukkit' packed = true packedJarAccessors = ['art/arcane/iris/platform/bukkit/plugin/VolmitPlugin#getJarFile'] + packedPayloadOnlyPrefixes = ['linux/x64/org/lwjgl/', 'iris-language-guides/'] stripDirectories = true stripLocalVariables = true releaseCompression = providers.gradleProperty('compactRelease') diff --git a/core/build.gradle b/core/build.gradle index ecc27cff9..6475a019c 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -108,6 +108,10 @@ dependencies { compileOnly(libs.log4j.api) compileOnly(libs.log4j.core) compileOnly(libs.guava) + compileOnly(libs.lwjgl) + compileOnly(libs.lwjgl.opengl) + compileOnly(libs.lwjgl.egl) + compileOnly(libs.lwjgl.jawt) // Third Party Integrations compileOnly(libs.nexo) @@ -136,13 +140,6 @@ dependencies { implementation('com.moandjiezana.toml:toml4j:0.7.2') { transitive = false } - // Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar. - implementation(libs.lwjgl) - implementation(libs.lwjgl.opengl) - implementation(libs.lwjgl.egl) - implementation(libs.lwjgl.jawt) - runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') }) - runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') }) // Dynamically Loaded slim(libs.gson) slim(libs.lru) @@ -183,6 +180,12 @@ dependencies { testImplementation(libs.multiverseCore) testImplementation(libs.annotations) testImplementation(libs.mythic) + testRuntimeOnly(libs.lwjgl) + testRuntimeOnly(libs.lwjgl.opengl) + testRuntimeOnly(libs.lwjgl.egl) + testRuntimeOnly(libs.lwjgl.jawt) + testRuntimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') }) + testRuntimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') }) } tasks.named('test').configure { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java index 3eb059957..2f525c3d9 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntime.java @@ -47,6 +47,7 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import java.util.function.Function; import java.util.function.IntBinaryOperator; @@ -79,6 +80,7 @@ public final class HydrologyRuntime { private final HydrologyBinding binding; private final Function stores; private final boolean caveFluidPolicies; + private final AtomicLong rasterRevision = new AtomicLong(); private volatile PreparedHydrologyCacheIdentity storeIdentity; private boolean storeOpen; private volatile boolean closed; @@ -210,6 +212,10 @@ public final class HydrologyRuntime { return settings; } + public long previewRevision() { + return cache == null ? 0L : cache.readyRevision() + rasterRevision.get(); + } + public PreviewScope preview() { return new PreviewScope(cache == null ? null : last.get()); } @@ -696,7 +702,9 @@ public final class HydrologyRuntime { private ChunkWaterRaster remember(long key, ChunkWaterRaster raster) { synchronized (rasters) { - rasters.put(key, raster); + if (rasters.put(key, raster) == null) { + rasterRevision.incrementAndGet(); + } } last.get().remember(key, raster); return raster; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java index f2d324cc5..ec2365dc8 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCache.java @@ -57,6 +57,7 @@ public final class HydrologyPlanCache { private final AtomicLong demands = new AtomicLong(); private final AtomicLong speculations = new AtomicLong(); private final AtomicLong previewStamps = new AtomicLong(Long.MIN_VALUE); + private final AtomicLong readyRevision = new AtomicLong(); private final Shelf regions = new Shelf<>(REGION_CAPACITY, this::startRegion); private final Shelf windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow); private final Map>> epochSlots = new HashMap<>(); @@ -112,6 +113,10 @@ public final class HydrologyPlanCache { return windows.evictions.get(); } + public long readyRevision() { + return readyRevision.get(); + } + public RegionPlan region(RegionKey key) { Objects.requireNonNull(key, "key"); requireOffMainThread("region " + key.rx() + "," + key.rz()); @@ -759,7 +764,9 @@ public final class HydrologyPlanCache { private void succeed(V value) { release(); - future.complete(value); + if (future.complete(value)) { + readyRevision.incrementAndGet(); + } shelf.evict(); } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/TerrainBatchScratch.java b/core/src/main/java/art/arcane/iris/generation/noise/TerrainBatchScratch.java index 9cdaaa6eb..7223181d3 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/TerrainBatchScratch.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/TerrainBatchScratch.java @@ -78,8 +78,10 @@ public final class TerrainBatchScratch { int lattice(double x, double z) { long xBits = Double.doubleToRawLongBits(x); long zBits = Double.doubleToRawLongBits(z); - long mixed = xBits * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(zBits * 0xC2B2AE3D27D4EB4FL, 31); - mixed ^= mixed >>> 29; + long mixed = xBits ^ Long.rotateLeft(zBits, 32); + mixed = (mixed ^ (mixed >>> 33)) * 0xFF51AFD7ED558CCDL; + mixed = (mixed ^ (mixed >>> 33)) * 0xC4CEB9FE1A85EC53L; + mixed ^= mixed >>> 33; int mask = latticeCapacity - 1; int slot = (int) mixed & mask; while (true) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index f324ab713..c512b9a6d 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -1668,7 +1668,6 @@ public class IrisEngine implements Engine { } catch (GenerationSessionException e) { throw e; } catch (Throwable e) { - IrisLogging.reportError(e); fail("Failed to generate " + x + ", " + z, e); if (e instanceof WrongEngineBroException wrongEngine) { throw wrongEngine; diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index d6fb5f1bc..c6aad0faf 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -365,4 +365,10 @@ public final class BukkitPlatform implements IrisPlatform { public void reportError(Throwable error) { bridge().errorSink().accept(error); } + + @Override + public void reportError(String context, Throwable error) { + log(LogLevel.ERROR, context == null || context.isBlank() ? "Unhandled Iris failure." : context); + reportError(error); + } } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicy.java index eb68e28f5..1dcab93da 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicy.java @@ -1,6 +1,7 @@ package art.arcane.iris.platform.bukkit.nms; import art.arcane.iris.platform.agent.Agent; +import art.arcane.iris.platform.bootstrap.SlimJar; import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.spi.IrisLogging; @@ -8,6 +9,7 @@ import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; import art.arcane.volmlib.nativelib.terrain.NativeWorldLifecyclePolicy; import org.bukkit.generator.ChunkGenerator; +import java.io.IOException; import java.lang.instrument.Instrumentation; import java.util.Objects; @@ -41,7 +43,23 @@ public enum BukkitWorldLifecyclePolicy implements NativeWorldLifecyclePolicy { @Override public void releaseClassLoader(ClassLoader loader) { - Agent.releaseClassLoader(loader); + Throwable providerFailure = null; + try { + SlimJar.closeNativeProviders(); + } catch (IOException | RuntimeException | Error failure) { + providerFailure = failure; + } + try { + Agent.releaseClassLoader(loader); + } catch (RuntimeException | Error failure) { + if (providerFailure != null) { + failure.addSuppressed(providerFailure); + } + throw failure; + } + if (providerFailure != null) { + throw new IllegalStateException("Failed to close Iris native providers", providerFailure); + } } @Override diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/INMSBinding.java index 3ee60431b..9704a918c 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/INMSBinding.java @@ -290,6 +290,10 @@ public interface INMSBinding extends NativeTerrainAccess { boolean missingDimensionTypes(String... keys); + default void initializeLifecycle(ClassLoader loader) { + throw new IllegalStateException("Plugin class loader shutdown protection is unavailable"); + } + default boolean injectBukkit() { return true; } @@ -301,8 +305,8 @@ public interface INMSBinding extends NativeTerrainAccess { default void uninjectBukkit() { } - default boolean deferPluginClassLoaderClose() { - return false; + default void deferPluginClassLoaderClose() { + throw new IllegalStateException("Plugin class loader shutdown protection is unavailable"); } default void releasePluginClassLoaderClose() { diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java index 3142238e4..d3b61c9cd 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java +++ b/core/src/main/java/art/arcane/iris/studio/map/MapCanvas.java @@ -276,9 +276,21 @@ final class MapCanvas extends JPanel { // Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones. void setActive(boolean nextActive) { + if (closed || active == nextActive) { + return; + } active = nextActive; if (active) { + markerTimer.start(); + provisionalTimer.start(); requestRender(); + } else { + controller.suspend(); + hoverTimer.stop(); + legendTimer.stop(); + markerTimer.stop(); + provisionalTimer.stop(); + clearHover(); } } @@ -1021,6 +1033,9 @@ final class MapCanvas extends JPanel { } private void onTilesChanged() { + if (closed || !active) { + return; + } tileLayerStale = true; repaint(); refreshStatus(); diff --git a/core/src/main/java/art/arcane/iris/studio/map/MapWindow.java b/core/src/main/java/art/arcane/iris/studio/map/MapWindow.java index 8ba95ebbf..9f04edd1e 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/MapWindow.java +++ b/core/src/main/java/art/arcane/iris/studio/map/MapWindow.java @@ -112,6 +112,7 @@ public final class MapWindow implements MapCanvas.Listener { private double worldCenterX; private double worldCenterZ; private MapView currentView; + private boolean windowVisible = true; private double paletteCenterX; private double paletteCenterZ; @@ -190,11 +191,55 @@ public final class MapWindow implements MapCanvas.Listener { frame.setMinimumSize(StudioTheme.scaledSize(820, 520)); frame.setLocationRelativeTo(null); frame.addWindowListener(new WindowAdapter() { + @Override + public void windowIconified(WindowEvent event) { + updateWindowVisibility(); + } + + @Override + public void windowDeiconified(WindowEvent event) { + updateWindowVisibility(); + } + @Override public void windowClosed(WindowEvent event) { close(); } }); + frame.addComponentListener(new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent event) { + updateWindowVisibility(); + } + + @Override + public void componentHidden(ComponentEvent event) { + updateWindowVisibility(); + } + }); + } + + private void updateWindowVisibility() { + boolean visible = isWindowVisible(); + boolean wasVisible = windowVisible; + windowVisible = visible; + canvas.setActive(visible && currentView != null && currentView.isTileMap()); + if (currentView == MapView.WORLD_3D && worldExplorer != null) { + if (visible && !wasVisible) { + worldExplorer.show(worldCenterX, worldCenterZ, false); + } else if (!visible) { + worldExplorer.hide(); + } + } + if (visible) { + resourceUsage.start(); + } else { + resourceUsage.stop(); + } + } + + private boolean isWindowVisible() { + return frame.isShowing() && (frame.getExtendedState() & JFrame.ICONIFIED) == 0; } private JToolBar buildToolbar() { @@ -265,6 +310,7 @@ public final class MapWindow implements MapCanvas.Listener { } frame.getRootPane().revalidate(); frame.getRootPane().repaint(); + updateWindowVisibility(); } private void showMap(MapView previousView, MapView view) { @@ -272,7 +318,7 @@ public final class MapWindow implements MapCanvas.Listener { canvas.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ()); } canvas.showView(view); - canvas.setActive(true); + canvas.setActive(isWindowVisible()); showControls(mapControls); waterToggle.setVisible(view == MapView.BIOMES); if (view == MapView.REGIONS) { diff --git a/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java b/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java index 12795d6fd..2f330d98b 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java +++ b/core/src/main/java/art/arcane/iris/studio/map/VisionRenderController.java @@ -19,7 +19,6 @@ package art.arcane.iris.studio.map; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.studio.map.components.MapTileRenderer; import art.arcane.iris.studio.map.components.MapLabels; import art.arcane.iris.studio.map.components.MapLayer; @@ -39,6 +38,7 @@ import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; +import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; @@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; +import java.util.function.LongSupplier; /** * Renders the map as tiles on fixed zoom levels, so zooming within a level and panning reuse tiles that are already drawn. @@ -67,6 +68,7 @@ final class VisionRenderController implements AutoCloseable { private static final long CACHE_BYTES = 256L * 1024L * 1024L; private final Runnable listener; + private final LongSupplier nanoTime; private final int workerCount; private final ThreadPoolExecutor renderExecutor; private final ThreadPoolExecutor probeExecutor; @@ -79,9 +81,15 @@ final class VisionRenderController implements AutoCloseable { private final AtomicBoolean publicationQueued = new AtomicBoolean(); private final AtomicBoolean publicationDirty = new AtomicBoolean(); private volatile Frame currentFrame; + private Future activeProbe; VisionRenderController(Runnable listener) { + this(listener, System::nanoTime); + } + + VisionRenderController(Runnable listener, LongSupplier nanoTime) { this.listener = Objects.requireNonNull(listener, "listener"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); this.workerCount = Math.max(1, Math.min(6, processors / 2)); AtomicInteger threadSequence = new AtomicInteger(); @@ -123,11 +131,10 @@ final class VisionRenderController implements AutoCloseable { List tiles = visibleTiles(spec, renderScale); Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles); for (VisibleTile tile : tiles) { - tile.setTile(cachedTileServing(tile.key())); + tile.state = cachedTileServing(tile.key()); } currentFrame = frame; - probeSequence.incrementAndGet(); - probeExecutor.getQueue().clear(); + cancelProbe(); startWorkers(); publish(); return frame; @@ -141,25 +148,41 @@ final class VisionRenderController implements AutoCloseable { startWorkers(); } + synchronized void suspend() { + currentFrame = null; + cancelProbe(); + } + /** * A tile from another zoom level covering the given one, to draw while the real tile renders. */ Fallback fallback(Frame frame, VisibleTile tile) { - MapTile withoutHeights = cache.get(tile.key().withHeights(false)); + Fallback complete = fallback(tile.key(), false); + return complete != null || tile.tile() != null ? complete : fallback(tile.key(), true); + } + + private Fallback fallback(TileKey key, boolean provisional) { + MapTile withoutHeights = fallbackTile(key.withHeights(false), provisional); if (withoutHeights != null) { return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D))); } - Fallback coarser = coarserFallback(frame, tile); - return coarser != null ? coarser : finerFallback(frame, tile); + Fallback coarser = coarserFallback(key, provisional); + return coarser != null ? coarser : finerFallback(key, provisional); } // A tile drawn with heights serves just as well where no heights are needed, so turning shading off redraws nothing. - private MapTile cachedTileServing(TileKey key) { - MapTile exact = cache.get(key); + private TileState cachedTileServing(TileKey key) { + CacheEntry exact = cache.get(key); if (exact != null || key.heights()) { - return exact; + return exact == null ? null : exact.state(); } - return cache.get(key.withHeights(true)); + CacheEntry shaded = cache.get(key.withHeights(true)); + return shaded != null && !shaded.state().provisional() ? shaded.state() : null; + } + + private MapTile fallbackTile(TileKey key, boolean provisional) { + CacheEntry entry = cache.get(key); + return entry != null && entry.state().provisional() == provisional ? entry.state().tile() : null; } Progress progress(Frame frame) { @@ -168,34 +191,44 @@ final class VisionRenderController implements AutoCloseable { } int ready = 0; for (VisibleTile tile : frame.tiles()) { - if (tile.tile() != null && !tile.provisional) { + if (tile.tile() != null && !tile.provisional()) { ready++; } } return new Progress(frame.tiles().size(), ready, activeWorkers.get()); } - void submitProbe(Frame frame, Callable probe, Consumer consumer) { + synchronized void submitProbe(Frame frame, Callable probe, Consumer consumer) { Objects.requireNonNull(probe, "probe"); Objects.requireNonNull(consumer, "consumer"); if (frame == null || closed.get() || currentFrame != frame) { return; } - long probeRevision = probeSequence.incrementAndGet(); - probeExecutor.getQueue().clear(); + cancelProbe(); + long probeRevision = probeSequence.get(); try { - probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer)); + activeProbe = probeExecutor.submit(() -> runProbe(probeRevision, probe, consumer)); } catch (RejectedExecutionException ignored) { probeExecutor.getQueue().clear(); } } + private void cancelProbe() { + probeSequence.incrementAndGet(); + if (activeProbe != null) { + activeProbe.cancel(true); + activeProbe = null; + } + probeExecutor.getQueue().clear(); + } + @Override public synchronized void close() { if (!closed.compareAndSet(false, true)) { return; } currentFrame = null; + cancelProbe(); renderExecutor.shutdownNow(); probeExecutor.shutdownNow(); cache.clear(); @@ -285,7 +318,7 @@ final class VisionRenderController implements AutoCloseable { private synchronized boolean hasWork() { Frame frame = currentFrame; - return frame != null && frame.nextMissing(tilesInFlight, false) != null; + return frame != null && frame.nextMissing(tilesInFlight, false, nanoTime.getAsLong()) != null; } private synchronized TileJob claimNextTile() { @@ -293,51 +326,63 @@ final class VisionRenderController implements AutoCloseable { if (frame == null || closed.get()) { return null; } - VisibleTile tile = frame.nextMissing(tilesInFlight, true); + VisibleTile tile = frame.nextMissing(tilesInFlight, true, nanoTime.getAsLong()); if (tile == null) { return null; } tilesInFlight.add(tile.key()); - return new TileJob(frame.spec().renderer(), tile.key()); + CacheEntry cached = cache.get(tile.key()); + return new TileJob(frame.spec().renderer(), tile.key(), cached == null ? null : cached.work(), tile.state); } - private synchronized void release(TileKey key) { + private synchronized void release(TileKey key, boolean cancelled) { tilesInFlight.remove(key); + Frame frame = currentFrame; + VisibleTile visible = frame == null ? null : frame.tile(key); + if (cancelled && visible != null && visible.needsRender(nanoTime.getAsLong(), frame.spec().renderer().previewRevision())) { + frame.nextCandidate = 0; + } } private void render(TileJob job) { TileKey key = job.key(); + boolean cancelled = false; try { + long previewRevision = job.renderer().previewRevision(); double tileSpan = TILE_PIXELS * key.blocksPerPixel(); - long revision = ProvisionalSampling.revision(); - MapTile tile = job.renderer().renderTile( - key.tileX() * tileSpan, - key.tileZ() * tileSpan, - key.blocksPerPixel(), - TILE_PIXELS, - key.layer(), - key.heights(), - () -> !isWanted(key) - ); - boolean provisional = revision != ProvisionalSampling.revision(); + MapTileRenderer.TileRender work = job.work(); + if (work == null) { + work = job.renderer().prepareTile(key.tileX() * tileSpan, key.tileZ() * tileSpan, + key.blocksPerPixel(), TILE_PIXELS, key.layer(), key.heights()); + } + MapTile tile = work.render(() -> !isWanted(key)); + boolean provisional = !work.complete(); + TileState previous = job.previous(); + long delay = 0L; + if (provisional) { + delay = previous == null || previous.retryDelay() == 0L + ? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, previous.retryDelay() * 2L); + } + long completedAt = nanoTime.getAsLong(); + TileState state = new TileState(tile, provisional, delay, completedAt + delay, + completedAt + FIRST_RETRY_NANOS, previewRevision); synchronized (this) { - if (!isWanted(key)) { + if (closed.get()) { return; } - VisibleTile visible = currentFrame.tile(key); - if (provisional) { - visible.markProvisional(tile, System.nanoTime()); - } else { - cache.put(key, tile); - visible.setTile(tile); + cache.put(key, state, provisional ? work : null); + if (isWanted(key)) { + currentFrame.tile(key).state = state; } } publish(); } catch (CancellationException ignored) { + cancelled = true; + cache.discardWork(key); } catch (Throwable error) { IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); } finally { - release(key); + release(key, cancelled); } } @@ -346,8 +391,7 @@ final class VisionRenderController implements AutoCloseable { return !closed.get() && frame != null && frame.tile(key) != null; } - private Fallback coarserFallback(Frame frame, VisibleTile tile) { - TileKey key = tile.key(); + private Fallback coarserFallback(TileKey key, boolean provisional) { for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) { long scale = 1L << level; int sourceSize = TILE_PIXELS >> level; @@ -357,8 +401,8 @@ final class VisionRenderController implements AutoCloseable { } long parentX = Math.floorDiv(key.tileX(), scale); long parentZ = Math.floorDiv(key.tileZ(), scale); - MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX, - parentZ)); + MapTile parent = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, + parentX, parentZ), provisional); if (parent != null) { int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize; int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize; @@ -368,8 +412,7 @@ final class VisionRenderController implements AutoCloseable { return null; } - private Fallback finerFallback(Frame frame, VisibleTile tile) { - TileKey key = tile.key(); + private Fallback finerFallback(TileKey key, boolean provisional) { double childScale = key.blocksPerPixel() / 2D; if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) { return null; @@ -377,8 +420,8 @@ final class VisionRenderController implements AutoCloseable { List pieces = new ArrayList<>(4); for (int childZ = 0; childZ < 2; childZ++) { for (int childX = 0; childX < 2; childX++) { - MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale, - key.tileX() * 2L + childX, key.tileZ() * 2L + childZ)); + MapTile child = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale, + key.tileX() * 2L + childX, key.tileZ() * 2L + childZ), provisional); if (child != null) { pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D)); } @@ -528,10 +571,11 @@ final class VisionRenderController implements AutoCloseable { } // Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there. - private VisibleTile nextMissing(Set inFlight, boolean advance) { + private VisibleTile nextMissing(Set inFlight, boolean advance, long now) { + long previewRevision = spec.renderer().previewRevision(); for (int index = nextCandidate; index < tiles.size(); index++) { VisibleTile tile = tiles.get(index); - if (tile.needsRender(System.nanoTime()) && !inFlight.contains(tile.key())) { + if (tile.needsRender(now, previewRevision) && !inFlight.contains(tile.key())) { if (advance) { nextCandidate = index + 1; } @@ -548,10 +592,7 @@ final class VisionRenderController implements AutoCloseable { static final class VisibleTile { private final TileKey key; private final double distanceSquared; - private volatile MapTile tile; - private volatile boolean provisional; - private long retryDelay; - private long retryAt; + private volatile TileState state; private VisibleTile(TileKey key, double distanceSquared) { this.key = key; @@ -575,28 +616,19 @@ final class VisionRenderController implements AutoCloseable { } MapTile tile() { - return tile; + TileState current = state; + return current == null ? null : current.tile(); } boolean provisional() { - return provisional; + TileState current = state; + return current != null && current.provisional(); } - private void setTile(MapTile tile) { - this.tile = tile; - provisional = false; - retryDelay = 0L; - } - - private void markProvisional(MapTile tile, long now) { - this.tile = tile; - retryDelay = retryDelay == 0L ? FIRST_RETRY_NANOS : Math.min(LAST_RETRY_NANOS, retryDelay * 2L); - retryAt = now + retryDelay; - provisional = true; - } - - private boolean needsRender(long now) { - return tile == null || provisional && now - retryAt >= 0L; + private boolean needsRender(long now, long previewRevision) { + TileState current = state; + return current == null || current.provisional() && (now - current.retryAt() >= 0L + || previewRevision != current.previewRevision() && now - current.earliestRetryAt() >= 0L); } } @@ -645,7 +677,11 @@ final class VisionRenderController implements AutoCloseable { } } - private record TileJob(MapTileRenderer renderer, TileKey key) { + private record TileJob(MapTileRenderer renderer, TileKey key, MapTileRenderer.TileRender work, TileState previous) { + } + + private record TileState(MapTile tile, boolean provisional, long retryDelay, long retryAt, long earliestRetryAt, + long previewRevision) { } private static final class WeightedTileCache { @@ -658,14 +694,13 @@ final class VisionRenderController implements AutoCloseable { this.entries = new LinkedHashMap<>(128, 0.75F, true); } - synchronized MapTile get(TileKey key) { - CacheEntry entry = entries.get(key); - return entry == null ? null : entry.tile(); + synchronized CacheEntry get(TileKey key) { + return entries.get(key); } - synchronized void put(TileKey key, MapTile tile) { - long tileBytes = tile.bytes(); - CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes)); + synchronized void put(TileKey key, TileState state, MapTileRenderer.TileRender work) { + long tileBytes = state.tile().bytes() + (work == null ? 0L : work.bytes()); + CacheEntry previous = entries.put(key, new CacheEntry(state, work, tileBytes)); if (previous != null) { bytes -= previous.bytes(); } @@ -682,8 +717,18 @@ final class VisionRenderController implements AutoCloseable { entries.clear(); bytes = 0L; } + + synchronized void discardWork(TileKey key) { + CacheEntry entry = entries.get(key); + if (entry == null || entry.work() == null) { + return; + } + long retainedBytes = entry.state().tile().bytes(); + entries.put(key, new CacheEntry(entry.state(), null, retainedBytes)); + bytes -= entry.bytes() - retainedBytes; + } } - private record CacheEntry(MapTile tile, long bytes) { + private record CacheEntry(TileState state, MapTileRenderer.TileRender work, long bytes) { } } diff --git a/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java b/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java index 419c4d628..5ff3aca9a 100644 --- a/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/map/components/MapTileRenderer.java @@ -21,6 +21,7 @@ package art.arcane.iris.studio.map.components; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; +import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; @@ -30,7 +31,6 @@ import art.arcane.iris.studio.render.RenderType; import art.arcane.volmlib.util.stream.ProceduralStream; import java.awt.Color; -import java.awt.image.BufferedImage; import java.util.Arrays; import java.util.EnumMap; import java.util.IdentityHashMap; @@ -73,6 +73,11 @@ public final class MapTileRenderer { return labels; } + public long previewRevision() { + HydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); + return runtime == null ? 0L : runtime.previewRevision(); + } + /** * Renders the square of {@code size} pixels whose top left pixel samples the given world position. * @@ -87,6 +92,13 @@ public final class MapTileRenderer { boolean withHeights, BooleanSupplier cancelled ) { + Objects.requireNonNull(cancelled, "cancelled"); + checkCancelled(cancelled); + return prepareTile(startX, startZ, blocksPerPixel, size, layer, withHeights).render(cancelled); + } + + public TileRender prepareTile(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer, + boolean withHeights) { if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { throw new IllegalArgumentException("Map tile coordinates and scale must be finite"); } @@ -94,35 +106,7 @@ public final class MapTileRenderer { throw new IllegalArgumentException("Map tile size must be positive"); } Objects.requireNonNull(layer, "layer"); - Objects.requireNonNull(cancelled, "cancelled"); - TileType type = layer.type(); - checkCancelled(cancelled); - - TileGrid grid = new TileGrid(size + 2, startX - blocksPerPixel, startZ - blocksPerPixel, blocksPerPixel, cancelled); - int[] identities = sampleIdentities(grid, layer); - double[] heights = withHeights || type == TileType.HEIGHT ? sampleHeights(grid) : null; - short[] subjects = new short[identities.length]; - short[] regions = new short[identities.length]; - for (int index = 0; index < identities.length; index++) { - subjects[index] = subjectOf(identities[index]); - regions[index] = regionOf(identities[index]); - } - int[] colors = switch (type) { - case HEIGHT -> null; - case RIVER -> riverColors(startX, startZ, blocksPerPixel, size, cancelled); - default -> tileColors(size, type, subjects); - }; - if (layer.display() != PickDisplay.PICKS) { - drawPickNoise(colors, grid, size, layer); - } - if (layer.waterOverlay()) { - BufferedImage base = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); - base.setRGB(0, 0, size, size, colors, 0, size); - BufferedImage water = hydrologyRenderer.refineStudioBiome(startX, startZ, blocksPerPixel * size, base, cancelled); - colors = water.getRGB(0, 0, size, size, null, 0, size); - } - MapLabels.Kind subjectKind = type == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME; - return new MapTile(size, blocksPerPixel, labels, subjectKind, colors, defaultHeightRange(), subjects, regions, heights); + return new TileRender(new TileRequest(startX, startZ, blocksPerPixel, size, layer, withHeights)); } /** @@ -166,15 +150,15 @@ public final class MapTileRenderer { return sampler.identities; } - private double[] sampleHeights(TileGrid grid) { + private double[] sampleHeights(HeightLattice lattice, BooleanSupplier cancelled) { IrisComplex complex = engine.getComplex(); IrisDimension dimension = engine.getDimension(); HydrologyRuntime water = complex.getHydrologyRuntime(); if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) { - return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights(); + return lattice.interpolatedHeights(cancelled); } try (HydrologyRuntime.PreviewScope ignored = water.preview()) { - return new HeightLattice(grid, complex.getHeightStream()).interpolatedHeights(); + return lattice.interpolatedHeights(cancelled); } } @@ -277,17 +261,6 @@ public final class MapTileRenderer { }; } - // Rivers are drawn by the hydrology renderer the engine ships; the biomes under them stay the tile's subjects. - private int[] riverColors(double startX, double startZ, double blocksPerPixel, int size, BooleanSupplier cancelled) { - BufferedImage rivers = hydrologyRenderer.renderStudio(startX, startZ, blocksPerPixel * size, size, RenderType.RIVER, - cancelled); - int[] colors = rivers.getRGB(0, 0, size, size, null, 0, size); - for (int index = 0; index < colors.length; index++) { - colors[index] &= 0xFFFFFF; - } - return colors; - } - private int[] tileColors(int size, TileType type, short[] subjects) { int[] colors = new int[size * size]; short previousSubject = MapLabels.NONE; @@ -376,6 +349,124 @@ public final class MapTileRenderer { } } + public final class TileRender { + private final TileRequest request; + private short[] subjects; + private short[] regions; + private int[] baseColors; + private int[] finalColors; + private double[] heights; + private HeightLattice heightLattice; + private IrisRenderer.WaterTile water; + private boolean identitiesComplete; + private boolean baseComplete; + private boolean heightsComplete; + private boolean waterComplete; + private volatile boolean complete; + private volatile long bytes; + + private TileRender(TileRequest request) { + this.request = request; + heightsComplete = !request.withHeights() && request.layer().type() != TileType.HEIGHT; + waterComplete = request.layer().type() != TileType.RIVER && !request.layer().waterOverlay(); + } + + public MapTile render(BooleanSupplier cancelled) { + Objects.requireNonNull(cancelled, "cancelled"); + checkCancelled(cancelled); + TileGrid grid = new TileGrid(request.size() + 2, request.startX() - request.blocksPerPixel(), + request.startZ() - request.blocksPerPixel(), request.blocksPerPixel(), cancelled); + sampleSubjects(grid); + sampleBaseColors(grid); + sampleTerrain(grid, cancelled); + int[] colors = sampleWater(cancelled); + checkCancelled(cancelled); + complete = baseComplete && heightsComplete && waterComplete; + bytes = (heightLattice == null ? 0L : heightLattice.bytes()) + (water == null ? 0L : water.bytes()) + + (baseColors == null || colors == baseColors ? 0L : (long) baseColors.length * Integer.BYTES); + if (!complete) { + ProvisionalSampling.mark(); + } + MapLabels.Kind kind = request.layer().type() == TileType.REGION ? MapLabels.Kind.REGION : MapLabels.Kind.BIOME; + return new MapTile(request.size(), request.blocksPerPixel(), labels, kind, colors, defaultHeightRange(), subjects, + regions, heights); + } + + public boolean complete() { + return complete; + } + + public long bytes() { + return bytes; + } + + private void sampleSubjects(TileGrid grid) { + if (identitiesComplete) { + return; + } + long revision = ProvisionalSampling.revision(); + int[] identities = sampleIdentities(grid, request.layer()); + subjects = new short[identities.length]; + regions = new short[identities.length]; + for (int index = 0; index < identities.length; index++) { + subjects[index] = subjectOf(identities[index]); + regions[index] = regionOf(identities[index]); + } + identitiesComplete = revision == ProvisionalSampling.revision(); + } + + private void sampleBaseColors(TileGrid grid) { + if (baseComplete) { + return; + } + long revision = ProvisionalSampling.revision(); + baseColors = switch (request.layer().type()) { + case HEIGHT, RIVER -> null; + default -> tileColors(request.size(), request.layer().type(), subjects); + }; + if (request.layer().display() != PickDisplay.PICKS) { + drawPickNoise(baseColors, grid, request.size(), request.layer()); + } + baseComplete = identitiesComplete && revision == ProvisionalSampling.revision(); + } + + private void sampleTerrain(TileGrid grid, BooleanSupplier cancelled) { + if (heightsComplete) { + return; + } + if (heightLattice == null) { + heightLattice = new HeightLattice(grid, engine.getComplex().getHeightStream()); + } + heights = sampleHeights(heightLattice, cancelled); + heightsComplete = heightLattice.complete(); + if (heightsComplete) { + heightLattice = null; + } + } + + private int[] sampleWater(BooleanSupplier cancelled) { + if (waterComplete) { + return finalColors == null ? baseColors : finalColors; + } + if (water == null) { + water = hydrologyRenderer.prepareStudioWater(request.startX(), request.startZ(), request.blocksPerPixel(), + request.size()); + } + int[] colors = water.render(baseColors, request.layer().waterOverlay(), cancelled); + if (baseComplete && water.complete()) { + finalColors = colors; + baseColors = null; + waterComplete = true; + water = null; + } + return colors; + } + } + + private record TileRequest(double startX, double startZ, double blocksPerPixel, int size, MapLayer layer, + boolean withHeights) { + } + @FunctionalInterface private interface IdentityShader { int identity(double x, double z); @@ -554,16 +645,20 @@ public final class MapTileRenderer { private final ProceduralStream stream; private final int pointsPerSide; private final double[] points; + private final boolean[] resolved; + private int pending; private HeightLattice(TileGrid grid, ProceduralStream stream) { this.grid = grid; this.stream = stream; this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1; this.points = new double[pointsPerSide * pointsPerSide]; + this.resolved = new boolean[points.length]; + this.pending = points.length; } - double[] interpolatedHeights() { - samplePoints(); + double[] interpolatedHeights(BooleanSupplier cancelled) { + samplePoints(cancelled); int size = grid.size(); double[] heights = new double[size * size]; for (int gridZ = 0; gridZ < size; gridZ++) { @@ -574,16 +669,33 @@ public final class MapTileRenderer { return heights; } + boolean complete() { + return pending == 0; + } + + long bytes() { + return (long) points.length * (Double.BYTES + 1L); + } + // Lattice point 0 sits on the tile's first pixel; the margin covers the border ring and the curve's reach. - private void samplePoints() { + private void samplePoints(BooleanSupplier cancelled) { double latticeStep = grid.step() * LATTICE_PIXELS; double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep; double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep; for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) { - checkCancelled(grid.cancelled()); + checkCancelled(cancelled); for (int pointX = 0; pointX < pointsPerSide; pointX++) { - points[pointZ * pointsPerSide + pointX] = stream.getDouble(firstX + pointX * latticeStep, + int index = pointZ * pointsPerSide + pointX; + if (resolved[index]) { + continue; + } + long revision = ProvisionalSampling.revision(); + points[index] = stream.getDouble(firstX + pointX * latticeStep, firstZ + pointZ * latticeStep); + if (revision == ProvisionalSampling.revision()) { + resolved[index] = true; + pending--; + } } } } diff --git a/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java index 4b05fa047..24eb3bdaf 100644 --- a/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/studio/render/IrisRenderer.java @@ -70,6 +70,13 @@ public final class IrisRenderer { this.renderer = Objects.requireNonNull(renderer, "renderer"); } + public WaterTile prepareStudioWater(double startX, double startZ, double step, int resolution) { + if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(step) || step <= 0D || resolution < 1) { + throw new IllegalArgumentException("Water tile coordinates, scale and resolution must be valid"); + } + return new WaterTile(startX, startZ, step, resolution); + } + public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) { return render(sx, sz, size, resolution, currentType, () -> false); } @@ -445,6 +452,71 @@ public final class IrisRenderer { } } + public final class WaterTile { + private final double startX; + private final double startZ; + private final double step; + private final int resolution; + private final WaterMap map; + private boolean complete; + + private WaterTile(double startX, double startZ, double step, int resolution) { + this.startX = startX; + this.startZ = startZ; + this.step = step; + this.resolution = resolution; + HydrologyRuntime runtime = renderer.getComplex().getHydrologyRuntime(); + this.map = runtime == null || !runtime.enabled() || step > WaterMap.MAX_STEP + ? null : new WaterMap(runtime, resolution, startX, startZ, step); + complete = map == null; + } + + public int[] render(int[] base, boolean composite, BooleanSupplier cancelled) { + Objects.requireNonNull(cancelled, "cancelled"); + checkCancelled(cancelled); + if (composite && (base == null || base.length != resolution * resolution)) { + throw new IllegalArgumentException("Water overlay requires matching base pixels"); + } + if (!complete) { + map.advance(cancelled); + map.drawCavePools(GenerationSemanticQueries.recordedCavePools(renderer, + Math.floorDiv((int) Math.floor(startX), 16), Math.floorDiv((int) Math.floor(startZ), 16), + Math.floorDiv((int) Math.ceil(startX + resolution * step) - 1, 16), + Math.floorDiv((int) Math.ceil(startZ + resolution * step) - 1, 16))); + complete = !map.provisional(); + } + int[] colors = composite ? base.clone() : new int[resolution * resolution]; + if (!composite) { + Arrays.fill(colors, NO_RIVER & 0xFFFFFF); + } + if (map != null) { + for (int z = 0; z < resolution; z++) { + checkCancelled(cancelled); + for (int x = 0; x < resolution; x++) { + HydrologyFeatureType type = map.at(x, z); + if (type != null) { + int index = z * resolution + x; + int color = hydrologyFeatureColor(type); + colors[index] = (composite ? blend(colors[index], color, RIVER_OVERLAY) : color) & 0xFFFFFF; + } + } + } + } + if (!complete) { + ProvisionalSampling.mark(); + } + return colors; + } + + public boolean complete() { + return complete; + } + + public long bytes() { + return map == null ? 0L : map.bytes(); + } + } + @FunctionalInterface private interface PixelShader { int color(double x, double z); diff --git a/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java b/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java index 85c83919a..33c33db23 100644 --- a/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java +++ b/core/src/main/java/art/arcane/iris/studio/render/WaterMap.java @@ -36,35 +36,85 @@ final class WaterMap { private final double startX; private final double startZ; private final double step; - private final BooleanSupplier cancelled; private final int[] ranks; + private final boolean[] resolved; private final IdentityHashMap channels = new IdentityHashMap<>(); + private final Set lakes = new HashSet<>(); + private BooleanSupplier cancelled; + private int[] plannedRanks; + private boolean plansComplete; private boolean provisional; - private WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) { + WaterMap(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step) { this.runtime = runtime; this.resolution = resolution; this.startX = startX; this.startZ = startZ; this.step = step; - this.cancelled = cancelled; this.ranks = new int[resolution * resolution]; + this.resolved = step <= RASTER_STEP ? new boolean[ranks.length] : null; Arrays.fill(ranks, DRY); + provisional = true; } static WaterMap draw(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) { - WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step, cancelled); - HydrologyPlanPreview plans = runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ), - (int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1); - map.provisional = !plans.complete(); - if (step <= RASTER_STEP) { - map.drawRasters(plans); - } else { - map.drawPlans(plans); - } + WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step); + map.advance(cancelled); return map; } + void advance(BooleanSupplier cancelled) { + if (!provisional) { + return; + } + this.cancelled = cancelled; + HydrologyPlanPreview plans = null; + if (!plansComplete) { + plans = preview(); + plannedRanks = null; + lakes.clear(); + for (Pond lake : plans.lakes()) { + lakes.add(lake.id()); + } + if (step > RASTER_STEP) { + prepareFallback(plans); + } + plansComplete = plans.complete(); + } + if (step <= RASTER_STEP) { + provisional = drawRasters(plans) || !plansComplete; + } else { + prepareFallback(plans); + System.arraycopy(plannedRanks, 0, ranks, 0, ranks.length); + provisional = !plansComplete; + } + if (!provisional) { + plannedRanks = null; + lakes.clear(); + } + } + + private HydrologyPlanPreview preview() { + return runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ), + (int) Math.ceil(startX + resolution * step) - 1, (int) Math.ceil(startZ + resolution * step) - 1); + } + + private void prepareFallback(HydrologyPlanPreview plans) { + if (plannedRanks != null) { + return; + } + WaterMap planned = new WaterMap(runtime, resolution, startX, startZ, step); + planned.cancelled = cancelled; + planned.drawPlans(plans == null ? preview() : plans); + plannedRanks = planned.ranks; + } + + long bytes() { + return (long) ranks.length * Integer.BYTES + (resolved == null ? 0L : resolved.length) + + (plannedRanks == null ? 0L : (long) plannedRanks.length * Integer.BYTES) + + (long) lakes.size() * 64L; + } + HydrologyFeatureType at(int pixelX, int pixelZ) { int rank = ranks[pixelZ * resolution + pixelX]; return rank == DRY ? null : DRAWN.get(rank); @@ -84,37 +134,39 @@ final class WaterMap { } } - private void drawRasters(HydrologyPlanPreview plans) { - Set lakes = new HashSet<>(); - for (Pond lake : plans.lakes()) { - lakes.add(lake.id()); - } + private boolean drawRasters(HydrologyPlanPreview plans) { Map rasters = new HashMap<>(); - WaterMap planned = null; + boolean pending = false; for (int pixelZ = 0; pixelZ < resolution; pixelZ++) { checkCancelled(); int minimumZ = (int) Math.floor(startZ + pixelZ * step); int maximumZ = Math.max(minimumZ, (int) Math.ceil(startZ + (pixelZ + 1) * step) - 1); for (int pixelX = 0; pixelX < resolution; pixelX++) { + int index = pixelZ * resolution + pixelX; + if (resolved[index]) { + continue; + } + ranks[index] = DRY; + boolean ready = plansComplete; int minimumX = (int) Math.floor(startX + pixelX * step); int maximumX = Math.max(minimumX, (int) Math.ceil(startX + (pixelX + 1) * step) - 1); for (int z = minimumZ; z <= maximumZ; z++) { for (int x = minimumX; x <= maximumX; x++) { ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4); if (raster.provisional()) { - provisional = true; - if (planned == null) { - planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled); - planned.drawPlans(plans); - } - stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ)); + pending = true; + ready = false; + prepareFallback(plans); + ranks[index] = Math.min(ranks[index], plannedRanks[index]); } else if (!raster.empty()) { stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes)); } } } + resolved[index] = ready; } } + return pending; } private ChunkWaterRaster raster(Map rasters, int chunkX, int chunkZ) { @@ -251,7 +303,7 @@ final class WaterMap { } private void checkCancelled() { - if (cancelled.getAsBoolean()) { + if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) { throw new CancellationException("Vision render was cancelled"); } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java index 83df8ce68..a97fa21dc 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/engine/HydrologyRuntimeTest.java @@ -70,6 +70,7 @@ import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -143,6 +144,95 @@ public class HydrologyRuntimeTest { assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName()))); } + @Test(timeout = 180_000L) + public void previewRevisionAdvancesOnlyWhenPlansOrRastersBecomeAvailable() throws Exception { + FakePlanners planners = new FakePlanners(settings(0, false)); + ExecutorService pool = pool(planners, 1); + HydrologyRuntime runtime = runtime(planners, pool, () -> false); + CountDownLatch blocker = block(pool); + CompletableFuture plans = runtime.prepareWindowsAround(0, 0, 0, 0, 1); + + assertEquals(0L, runtime.previewRevision()); + assertFalse(plans.isDone()); + blocker.countDown(); + plans.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + long planned = runtime.previewRevision(); + assertTrue(planned > 0L); + assertTrue(runtime.planPreview(0, 0, 15, 15).complete()); + assertEquals(planned, runtime.previewRevision()); + + CountDownLatch rasterBlocker = block(pool); + for (int repeat = 0; repeat < 100; repeat++) { + assertTrue(runtime.rasterPreview(0, 0).provisional()); + assertEquals(planned, runtime.previewRevision()); + } + rasterBlocker.countDown(); + await(() -> runtime.previewRevision() == planned + 1L, "raster publication advances preview readiness"); + ChunkWaterRaster ready = runtime.rasterPreview(0, 0); + assertFalse(ready.provisional()); + for (int repeat = 0; repeat < 100; repeat++) { + assertSame(ready, runtime.rasterPreview(0, 0)); + assertSame(ready, runtime.raster(0, 0)); + assertEquals(planned + 1L, runtime.previewRevision()); + } + assertFalse(runtime.raster(1, 0).provisional()); + assertEquals(planned + 2L, runtime.previewRevision()); + } + + @Test(timeout = 180_000L) + public void concurrentRasterPublicationAdvancesPreviewRevisionOnce() throws Exception { + FakePlanners planners = new FakePlanners(settings(0, false)); + ExecutorService pool = pool(planners, 1); + HydrologyRuntime runtime = runtime(planners, pool, () -> false); + runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + long planned = runtime.previewRevision(); + CountDownLatch gate = new CountDownLatch(1); + planners.naturalGate = gate; + AtomicReference failure = new AtomicReference<>(); + Thread demand = new Thread(() -> { + try { + runtime.raster(0, 0); + } catch (Throwable error) { + failure.set(error); + } + }); + demand.start(); + try { + await(() -> planners.naturalSamples.get() > 0, "demanded raster starts"); + assertTrue(runtime.rasterPreview(0, 0).provisional()); + await(() -> planners.naturalSamples.get() >= 2, "preview raster starts beside demand"); + assertEquals(planned, runtime.previewRevision()); + } finally { + gate.countDown(); + demand.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)); + } + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertFalse(demand.isAlive()); + assertNull(failure.get()); + assertFalse(runtime.rasterPreview(0, 0).provisional()); + assertEquals(planned + 1L, runtime.previewRevision()); + } + + @Test(timeout = 180_000L) + public void failedRasterDoesNotAdvancePreviewRevision() throws Exception { + FakePlanners planners = new FakePlanners(settings(0, false)); + ExecutorService pool = pool(planners, 1); + HydrologyPlanCache cache = new HydrologyPlanCache(planners.settings, planners::region, planners::window, pool, () -> false); + HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, (x, z) -> { + throw new IllegalStateException("terrain sampling failed"); + }, () -> false, planners.ticker::get); + runtimes.add(runtime); + runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + long planned = runtime.previewRevision(); + + assertThrows(IllegalStateException.class, () -> runtime.raster(0, 0)); + + assertEquals(planned, runtime.previewRevision()); + } + @Test(timeout = 180_000L) public void rasterCachedPerChunk() throws Exception { FakePlanners planners = new FakePlanners(settings()); @@ -868,6 +958,7 @@ public class HydrologyRuntimeTest { private static void assertDisabled(HydrologyRuntime runtime) { assertFalse(runtime.enabled()); + assertEquals(0L, runtime.previewRevision()); assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0)); @@ -879,6 +970,7 @@ public class HydrologyRuntimeTest { assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0)); assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511)); runtime.close(); + assertEquals(0L, runtime.previewRevision()); } private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java index 26976399d..040a6bfa4 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/plan/HydrologyPlanCacheTest.java @@ -139,6 +139,73 @@ public class HydrologyPlanCacheTest { assertEquals(KEY, results[0].plan().key()); } + @Test + public void readyRevisionCountsSuccessfulPlanPublicationsWithoutCountingReads() throws Exception { + FakePlanners planners = new FakePlanners(unpersisted()); + ExecutorService pool = pool(1); + HydrologyPlanCache cache = cache(planners, pool, () -> false); + RegionKey region = WindowPlanner.lakeRegions(KEY).getFirst(); + Gate gate = new Gate(); + planners.hook = key -> gate.pass(); + CompletableFuture pending = cache.previewRegion(region); + await(() -> gate.arrivals() == 1, "region planning starts before publication"); + assertEquals(0L, cache.readyRevision()); + assertFalse(pending.isDone()); + gate.release(); + RegionPlan ready = pending.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(1L, cache.readyRevision()); + assertSame(ready, cache.regionIfReady(region).orElseThrow()); + assertSame(ready, cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertSame(ready, cache.region(region)); + assertEquals(1L, cache.readyRevision()); + + cache.previewWindow(KEY).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + long published = WindowPlanner.lakeRegions(KEY).size() + 1L; + assertEquals(published, cache.readyRevision()); + assertTrue(cache.windowIfReady(KEY).isPresent()); + cache.window(KEY); + assertEquals(published, cache.readyRevision()); + } + + @Test + public void failedPlansDoNotAdvanceReadyRevision() throws Exception { + FakePlanners planners = new FakePlanners(unpersisted()); + ExecutorService pool = pool(1); + HydrologyPlanCache cache = cache(planners, pool, () -> false); + RegionKey region = new RegionKey(0, 0); + planners.hook = key -> { + throw new IllegalStateException("region planning failed"); + }; + + CompletableFuture failed = cache.previewRegion(region); + assertThrows(ExecutionException.class, () -> failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(0L, cache.readyRevision()); + planners.hook = key -> { }; + cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(1L, cache.readyRevision()); + } + + @Test + public void cancelledPlanCompletionDoesNotAdvanceReadyRevision() throws Exception { + FakePlanners planners = new FakePlanners(unpersisted()); + ExecutorService pool = pool(1); + HydrologyPlanCache cache = cache(planners, pool, () -> false); + Gate gate = new Gate(); + planners.hook = key -> gate.pass(); + CompletableFuture pending = cache.previewRegion(new RegionKey(0, 0)); + await(() -> gate.arrivals() == 1, "region planning starts before cancellation"); + + cache.close(); + gate.release(); + pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(pending.isCompletedExceptionally()); + assertEquals(0L, cache.readyRevision()); + } + @Test public void interruptedPlanIsNotCached() throws Exception { FakePlanners planners = new FakePlanners(unpersisted()); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/TerrainBatchScratchTest.java b/core/src/test/java/art/arcane/iris/generation/noise/TerrainBatchScratchTest.java new file mode 100644 index 000000000..c9f4e12f6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/TerrainBatchScratchTest.java @@ -0,0 +1,69 @@ +package art.arcane.iris.generation.noise; + +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class TerrainBatchScratchTest { + @Test + public void integerLatticesDistributeWithoutLongLinearProbeClusters() throws Exception { + TerrainBatchScratch scratch = new TerrainBatchScratch(); + for (int origin : new int[]{0, -1536, 1536, 16_777_212}) { + scratch.clearLattice(4096); + for (int index = 0; index < 1024; index++) { + double x = origin + (index & 31) * 6D; + double z = origin + (index >> 5) * 6D; + assertEquals(index, scratch.lattice(x, z)); + } + assertEquals(1024, scratch.latticeCount()); + for (int index = 1023; index >= 0; index--) { + double x = origin + (index & 31) * 6D; + double z = origin + (index >> 5) * 6D; + assertEquals(index, scratch.lattice(x, z)); + assertEquals(x, scratch.latticeX(index), 0D); + assertEquals(z, scratch.latticeZ(index), 0D); + } + assertTrue("clustered lattice at " + origin, longestCluster(scratch) < 32); + } + } + + @Test + public void rawCoordinateIdentityAndInsertionOrderSurviveRehashingAndReset() { + TerrainBatchScratch scratch = new TerrainBatchScratch(); + long[] bits = {0L, Long.MIN_VALUE, Double.doubleToRawLongBits(1D), Double.doubleToRawLongBits(-1D), + Double.doubleToRawLongBits(0.125D), Double.doubleToRawLongBits(-0.125D), + Double.doubleToRawLongBits(Double.POSITIVE_INFINITY), Double.doubleToRawLongBits(Double.NEGATIVE_INFINITY), + 0x7FF8000000000001L, 0x7FF8000000000002L}; + for (int pass = 0; pass < 2; pass++) { + scratch.clearLattice(bits.length * bits.length); + for (int x = 0; x < bits.length; x++) { + for (int z = 0; z < bits.length; z++) { + int expected = x * bits.length + z; + double worldX = Double.longBitsToDouble(bits[x]); + double worldZ = Double.longBitsToDouble(bits[z]); + assertEquals(expected, scratch.lattice(worldX, worldZ)); + assertEquals(expected, scratch.lattice(worldX, worldZ)); + assertEquals(bits[x], Double.doubleToRawLongBits(scratch.latticeX(expected))); + assertEquals(bits[z], Double.doubleToRawLongBits(scratch.latticeZ(expected))); + } + } + assertEquals(bits.length * bits.length, scratch.latticeCount()); + } + } + + private static int longestCluster(TerrainBatchScratch scratch) throws Exception { + Field field = TerrainBatchScratch.class.getDeclaredField("latticeSlots"); + field.setAccessible(true); + int[] slots = (int[]) field.get(scratch); + int longest = 0; + int length = 0; + for (int index = 0; index < slots.length * 2; index++) { + length = slots[index % slots.length] == 0 ? 0 : length + 1; + longest = Math.max(longest, length); + } + return longest; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineFailureReportingTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineFailureReportingTest.java new file mode 100644 index 000000000..ef1471dc2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineFailureReportingTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.spi.IrisLogging; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class IrisEngineFailureReportingTest { + @Test + public void failedGenerationReportsOnceAndRetainsItsCause() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + IllegalStateException failure = new IllegalStateException("Hydrology raster failed"); + Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks"); + platformHooks.setAccessible(true); + platformHooks.set(engine, mock(EnginePlatformHooks.class)); + doThrow(failure).when(engine).openGenerationHistoryCoordinateScope(-352, -144); + doCallRealMethod().when(engine).generate(-352, -144, null, null, false); + doCallRealMethod().when(engine).fail("Failed to generate -352, -144", failure); + doCallRealMethod().when(engine).hasFailed(); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + WrongEngineBroException reported = assertThrows(WrongEngineBroException.class, + () -> engine.generate(-352, -144, null, null, false)); + + assertSame(failure, reported.getCause()); + assertTrue(engine.hasFailed()); + logging.verify(() -> IrisLogging.error("Failed to generate -352, -144")); + logging.verify(() -> IrisLogging.reportError(failure)); + logging.verifyNoMoreInteractions(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformErrorReportingTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformErrorReportingTest.java new file mode 100644 index 000000000..1cc091976 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformErrorReportingTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.spi.LogLevel; +import org.junit.After; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class BukkitPlatformErrorReportingTest { + @After + public void releaseHost() { + BukkitPlatform.releaseHost(); + } + + @Test + public void contextualReportEmitsOneCompleteTraceThroughTheHost() { + ArrayList messages = new ArrayList<>(); + ArrayList reported = new ArrayList<>(); + BukkitPlatform.hostBridge(new BukkitPlatform.HostBridge( + (level, message) -> messages.add(new LogEntry(level, message)), null, + failure -> { + reported.add(failure); + failure.printStackTrace(System.err); + }, null, null, null, null, null, null)); + IllegalStateException failure = new IllegalStateException("raster failure", + new ArrayIndexOutOfBoundsException("invalid sample")); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream previousError = System.err; + try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8)) { + System.setErr(errors); + + new BukkitPlatform().reportError("Generation failed", failure); + } finally { + System.setErr(previousError); + } + + assertEquals(List.of(new LogEntry(LogLevel.ERROR, "Generation failed")), messages); + assertEquals(List.of(failure), reported); + String trace = output.toString(StandardCharsets.UTF_8); + assertEquals(1L, trace.lines().filter(line -> line.contains("raster failure")).count()); + assertEquals(1L, trace.lines().filter(line -> line.contains("Caused by:")).count()); + assertTrue(trace.contains("BukkitPlatformErrorReportingTest.contextualReportEmitsOneCompleteTraceThroughTheHost")); + } + + private record LogEntry(LogLevel level, String message) { + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitBindingTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitBindingTest.java index 8abb5e561..806fa9d2e 100644 --- a/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitBindingTest.java +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitBindingTest.java @@ -14,7 +14,10 @@ import org.junit.Test; import java.util.Locale; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -38,13 +41,23 @@ public class BukkitBindingTest { Fixture fixture = new Fixture(); when(fixture.lifecycle.isServerStopping()).thenReturn(true); assertTrue(fixture.binding.isServerStopping()); - when(fixture.lifecycle.deferPluginClassLoaderClose()).thenReturn(true); - assertTrue(fixture.binding.deferPluginClassLoaderClose()); + fixture.binding.initializeLifecycle(getClass().getClassLoader()); + verify(fixture.lifecycle).initializeLifecycle(getClass().getClassLoader()); + fixture.binding.deferPluginClassLoaderClose(); fixture.binding.releasePluginClassLoaderClose(); verify(fixture.lifecycle).deferPluginClassLoaderClose(); verify(fixture.lifecycle).releasePluginClassLoaderClose(); } + @Test + public void limitedBindingRejectsClassLoaderProtectionAndUsesSynchronousShutdown() { + INMSBinding binding = mock(INMSBinding.class, CALLS_REAL_METHODS); + assertFalse(binding.isServerStopping()); + assertThrows(IllegalStateException.class, + () -> binding.initializeLifecycle(getClass().getClassLoader())); + assertThrows(IllegalStateException.class, binding::deferPluginClassLoaderClose); + } + @Test public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException { Fixture fixture = new Fixture(); @@ -71,8 +84,8 @@ public class BukkitBindingTest { } private static final class Fixture { - private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class); private final NativeWorldGeneration generation = mock(NativeWorldGeneration.class); + private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class); private final NativeWorldLifecycleFactory.Controller lifecycle = mock(NativeWorldLifecycleFactory.Controller.class); private final BukkitBinding binding; diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicyTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicyTest.java new file mode 100644 index 000000000..8a9c9e3f9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitWorldLifecyclePolicyTest.java @@ -0,0 +1,65 @@ +package art.arcane.iris.platform.bukkit.nms; + +import art.arcane.iris.platform.agent.Agent; +import art.arcane.iris.platform.bootstrap.SlimJar; +import art.arcane.iris.testsupport.PlatformBinding; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class BukkitWorldLifecyclePolicyTest { + @ClassRule + public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform(); + + @Test + public void nativeProvidersCloseBeforeThePluginClassLoaderIsReleased() throws Exception { + ClassLoader loader = mock(ClassLoader.class); + try (MockedStatic providers = mockStatic(SlimJar.class); + MockedStatic agent = mockStatic(Agent.class)) { + agent.when(() -> Agent.releaseClassLoader(loader)).thenAnswer(invocation -> { + providers.verify(SlimJar::closeNativeProviders); + return null; + }); + BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader); + agent.verify(() -> Agent.releaseClassLoader(loader)); + } + } + + @Test + public void nativeProviderCloseFailureStillReleasesThePluginClassLoader() throws Exception { + ClassLoader loader = mock(ClassLoader.class); + IOException failure = new IOException("provider close failed"); + try (MockedStatic providers = mockStatic(SlimJar.class); + MockedStatic agent = mockStatic(Agent.class)) { + providers.when(SlimJar::closeNativeProviders).thenThrow(failure); + IllegalStateException reported = assertThrows(IllegalStateException.class, + () -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader)); + assertSame(failure, reported.getCause()); + agent.verify(() -> Agent.releaseClassLoader(loader)); + } + } + @Test + public void providerAndLoaderCloseFailuresAreBothPreserved() throws Exception { + ClassLoader loader = mock(ClassLoader.class); + IOException providerFailure = new IOException("provider close failed"); + IllegalStateException loaderFailure = new IllegalStateException("loader close failed"); + try (MockedStatic providers = mockStatic(SlimJar.class); + MockedStatic agent = mockStatic(Agent.class)) { + providers.when(SlimJar::closeNativeProviders).thenThrow(providerFailure); + agent.when(() -> Agent.releaseClassLoader(loader)).thenThrow(loaderFailure); + IllegalStateException reported = assertThrows(IllegalStateException.class, + () -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader)); + assertSame(loaderFailure, reported); + assertEquals(1, reported.getSuppressed().length); + assertSame(providerFailure, reported.getSuppressed()[0]); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/map/MapTileProgressTest.java b/core/src/test/java/art/arcane/iris/studio/map/MapTileProgressTest.java new file mode 100644 index 000000000..7ee12f31a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/map/MapTileProgressTest.java @@ -0,0 +1,224 @@ +package art.arcane.iris.studio.map; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview; +import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; +import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster; +import art.arcane.iris.generation.hydrology.raster.WaterRole; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.stream.ProvisionalSampling; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.studio.map.components.MapLayer; +import art.arcane.iris.studio.map.components.MapStyle; +import art.arcane.iris.studio.map.components.MapTile; +import art.arcane.iris.studio.map.components.MapTileRenderer; +import art.arcane.iris.studio.map.components.PickDisplay; +import art.arcane.iris.studio.map.components.TileType; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; +import org.junit.Test; + +import java.awt.image.BufferedImage; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MapTileProgressTest { + @Test + public void retriesOnlySampleUnresolvedHeightPointsAndReuseBiomeData() { + AtomicBoolean pending = new AtomicBoolean(true); + AtomicInteger heightSamples = new AtomicInteger(); + AtomicInteger biomeSamples = new AtomicInteger(); + Engine engine = engine(biomeSamples); + when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> { + heightSamples.incrementAndGet(); + if (x == 0D && z == 0D && pending.get()) { + ProvisionalSampling.mark(); + return 60D; + } + return 80D + x * 0.02D + z * 0.01D; + })); + MapTileRenderer renderer = new MapTileRenderer(engine); + MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false); + MapTileRenderer.TileRender progress = renderer.prepareTile(-8D, -8D, 0.5D, 16, layer, true); + + progress.render(() -> false); + int initialHeights = heightSamples.get(); + int initialBiomes = biomeSamples.get(); + assertFalse(progress.complete()); + assertTrue(progress.bytes() > 0L); + + progress.render(() -> false); + assertEquals(initialHeights + 1, heightSamples.get()); + assertEquals(initialBiomes, biomeSamples.get()); + pending.set(false); + MapTile settled = progress.render(() -> false); + assertTrue(progress.complete()); + assertEquals(initialHeights + 2, heightSamples.get()); + assertEquals(initialBiomes, biomeSamples.get()); + assertEquals(0L, progress.bytes()); + progress.render(() -> false); + assertEquals(initialHeights + 2, heightSamples.get()); + assertEquals(initialBiomes, biomeSamples.get()); + + MapTile expected = renderer.renderTile(-8D, -8D, 0.5D, 16, layer, true, () -> false); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(expected.heightAt(x, z), settled.heightAt(x, z), 0D); + } + } + assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))), + pixels(settled.image(MapStyle.PLAIN.withShading(true)))); + } + + @Test + public void completedWaterIsNotBlendedAgainWhileHeightsWait() { + AtomicBoolean pending = new AtomicBoolean(true); + Engine engine = engine(new AtomicInteger()); + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + ChunkWaterRaster raster = mock(ChunkWaterRaster.class); + when(runtime.enabled()).thenReturn(true); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster); + when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND); + when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime); + when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> { + if (pending.get()) { + ProvisionalSampling.mark(); + } + return 60D; + })); + MapTileRenderer renderer = new MapTileRenderer(engine); + MapTileRenderer.TileRender progress = renderer.prepareTile(0D, 0D, 1D, 16, MapLayer.of(TileType.BIOME), true); + + MapTile first = progress.render(() -> false); + assertFalse(progress.complete()); + assertNotEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(0, 0)); + MapTile second = progress.render(() -> false); + assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(second.image(MapStyle.PLAIN))); + + pending.set(false); + MapTile complete = progress.render(() -> false); + assertTrue(progress.complete()); + assertEquals(0L, progress.bytes()); + assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(complete.image(MapStyle.PLAIN))); + } + + @Test + public void twoBlockPixelsRefineMixedWaterAndHeightsToTheSameFinalTile() { + AtomicBoolean pending = new AtomicBoolean(true); + Engine engine = engine(new AtomicInteger()); + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + ChunkWaterRaster raster = mock(ChunkWaterRaster.class); + when(runtime.enabled()).thenReturn(true); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster); + when(runtime.rasterPreview(0, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : raster); + when(raster.role(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int z = invocation.getArgument(1); + if (x == 1 && z < 4) { + return WaterRole.POND; + } + if (x == 7 && z < 8) { + return WaterRole.WATERFALL; + } + return x < 4 && z == 9 ? WaterRole.INLET : WaterRole.NONE; + }); + when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime); + when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> { + if (x == 0D && z == 0D && pending.get()) { + ProvisionalSampling.mark(); + return 60D; + } + return 80D + x * 0.02D + z * 0.01D; + })); + MapTileRenderer renderer = new MapTileRenderer(engine); + MapLayer layer = MapLayer.of(TileType.BIOME); + MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 2D, 16, layer, true); + + MapTile first = progress.render(() -> false); + int[] firstPixels = pixels(first.image(MapStyle.PLAIN.withShading(true))); + assertFalse(progress.complete()); + assertEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(8, 8)); + MapTile waiting = progress.render(() -> false); + assertFalse(progress.complete()); + assertArrayEquals(firstPixels, pixels(waiting.image(MapStyle.PLAIN.withShading(true)))); + + pending.set(false); + MapTile actual = progress.render(() -> false); + assertTrue(progress.complete()); + assertEquals(0L, progress.bytes()); + BufferedImage colors = actual.image(MapStyle.PLAIN); + assertEquals(0xFF738249, colors.getRGB(14, 14)); + assertNotEquals(0xFF738249, colors.getRGB(8, 8)); + assertNotEquals(colors.getRGB(8, 8), colors.getRGB(11, 8)); + assertNotEquals(colors.getRGB(8, 8), colors.getRGB(8, 12)); + assertNotEquals(colors.getRGB(11, 8), colors.getRGB(8, 12)); + assertArrayEquals(firstPixels, pixels(first.image(MapStyle.PLAIN.withShading(true)))); + + MapTile expected = renderer.renderTile(-16D, -16D, 2D, 16, layer, true, () -> false); + assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))), + pixels(actual.image(MapStyle.PLAIN.withShading(true)))); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(expected.heightAt(x, z), actual.heightAt(x, z), 0D); + assertEquals(expected.subjectAt(x, z), actual.subjectAt(x, z)); + assertEquals(expected.regionAt(x, z), actual.regionAt(x, z)); + } + } + } + + @Test + public void cancelledPreparationCanResumeWithoutReusingIncompleteHeightSamples() { + Engine engine = engine(new AtomicInteger()); + AtomicInteger samples = new AtomicInteger(); + when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> { + samples.incrementAndGet(); + return x + z + 80D; + })); + MapTileRenderer renderer = new MapTileRenderer(engine); + MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false); + MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 1D, 16, layer, true); + assertThrows(CancellationException.class, () -> progress.render(() -> samples.get() >= 9)); + assertFalse(progress.complete()); + MapTile actual = progress.render(() -> false); + assertTrue(progress.complete()); + assertEquals(81, samples.get()); + MapTile expected = renderer.renderTile(-16D, -16D, 1D, 16, layer, true, () -> false); + assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))), + pixels(actual.image(MapStyle.PLAIN.withShading(true)))); + } + + private static Engine engine(AtomicInteger biomeSamples) { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisBiome biome = new IrisBiome(); + biome.setColor("#738249"); + IrisRegion region = new IrisRegion(); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(256); + when(complex.getBaseBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> { + biomeSamples.incrementAndGet(); + return biome; + }, Interpolated.of(value -> 0D, value -> biome))); + when(complex.getRegionStream()).thenReturn(ProceduralStream.of((x, z) -> region, + Interpolated.of(value -> 0D, value -> region))); + return engine; + } + + private static int[] pixels(BufferedImage image) { + return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java b/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java index 8d38a26b1..94db0984c 100644 --- a/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java +++ b/core/src/test/java/art/arcane/iris/studio/map/VisionRenderControllerTest.java @@ -1,20 +1,23 @@ package art.arcane.iris.studio.map; -import art.arcane.iris.generation.stream.ProvisionalSampling; import art.arcane.iris.studio.map.components.MapLayer; import art.arcane.iris.studio.map.components.MapTile; import art.arcane.iris.studio.map.components.MapTileRenderer; import art.arcane.iris.studio.map.components.TileType; import org.junit.Test; +import java.awt.EventQueue; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -22,21 +25,23 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class VisionRenderControllerTest { @Test public void provisionalTilesRetryWithBackoffAndConvergeWithoutMoving() throws Exception { - MapTileRenderer renderer = mock(MapTileRenderer.class); + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); MapTile tile = mock(MapTile.class); AtomicInteger attempts = new AtomicInteger(); - when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + when(work.render(any())) .thenAnswer(invocation -> { - if (attempts.incrementAndGet() < 3) { - ProvisionalSampling.mark(); - } + attempts.incrementAndGet(); return tile; }); + when(work.complete()).thenAnswer(invocation -> attempts.get() >= 3); try (VisionRenderController controller = new VisionRenderController(() -> { })) { VisionRenderController.Frame frame = controller.request(spec(renderer, 1L)); await(() -> frame.tiles().getFirst().tile() != null); @@ -55,30 +60,47 @@ public class VisionRenderControllerTest { }); assertEquals(3, attempts.get()); assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile()); + verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); } } @Test - public void provisionalTilesAreShownButNeverReusedAsFinal() throws Exception { - MapTileRenderer renderer = mock(MapTileRenderer.class); + public void resizeZoomAndSuspendRetainProvisionalImageWorkAndBackoff() throws Exception { + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); MapTile incomplete = mock(MapTile.class); MapTile complete = mock(MapTile.class); AtomicInteger attempts = new AtomicInteger(); - when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + when(work.render(any())) .thenAnswer(invocation -> { if (attempts.incrementAndGet() == 1) { - ProvisionalSampling.mark(); return incomplete; } return complete; }); + when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2); try (VisionRenderController controller = new VisionRenderController(() -> { })) { VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); - await(() -> first.tiles().getFirst().tile() != null); + await(() -> first.tiles().getFirst().tile() != null && controller.progress(first).activeWorkers() == 0); assertSame(incomplete, first.tiles().getFirst().tile()); + VisionRenderController.Frame resized = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false)); + assertSame(incomplete, resized.tiles().getFirst().tile()); + assertTrue(resized.tiles().getFirst().provisional()); + VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1.15D, 48, 48, false)); + assertSame(incomplete, zoomed.tiles().getFirst().tile()); + controller.suspend(); VisionRenderController.Frame second = controller.request(spec(renderer, 1L)); - await(() -> second.tiles().getFirst().tile() == complete); + assertSame(incomplete, second.tiles().getFirst().tile()); + assertEquals(0, controller.progress(second).ready()); + assertEquals(1, attempts.get()); + await(() -> { + controller.retryProvisional(second); + return controller.progress(second).ready() == 1; + }); assertEquals(2, attempts.get()); + verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); VisionRenderController.Frame third = controller.request(spec(renderer, 1L)); assertSame(complete, third.tiles().getFirst().tile()); assertEquals(2, attempts.get()); @@ -87,10 +109,11 @@ public class VisionRenderControllerTest { @Test public void contentRevisionInvalidatesFinalTiles() throws Exception { - MapTileRenderer renderer = mock(MapTileRenderer.class); MapTile tile = mock(MapTile.class); + MapTileRenderer.TileRender work = finished(tile); + MapTileRenderer renderer = renderer(work); AtomicInteger attempts = new AtomicInteger(); - when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + when(work.render(any())) .thenAnswer(invocation -> { attempts.incrementAndGet(); return tile; @@ -109,14 +132,16 @@ public class VisionRenderControllerTest { MapTileRenderer renderer = mock(MapTileRenderer.class); MapTile overview = mock(MapTile.class); MapTile incomplete = mock(MapTile.class); - when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + MapTileRenderer.TileRender overviewWork = finished(overview); + MapTileRenderer.TileRender detailWork = mock(MapTileRenderer.TileRender.class); + when(detailWork.render(any())).thenReturn(incomplete); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())) .thenAnswer(invocation -> { double scale = invocation.getArgument(2); if (scale < 4D) { - ProvisionalSampling.mark(); - return incomplete; + return detailWork; } - return overview; + return overviewWork; }); try (VisionRenderController controller = new VisionRenderController(() -> { })) { VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer, @@ -142,10 +167,11 @@ public class VisionRenderControllerTest { @Test public void closeRejectsLatePublication() throws Exception { - MapTileRenderer renderer = mock(MapTileRenderer.class); + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); - when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) + when(work.render(any())) .thenAnswer(invocation -> { entered.countDown(); while (release.getCount() != 0L) { @@ -167,7 +193,261 @@ public class VisionRenderControllerTest { controller.close(); } await(() -> controller.progress(frame).activeWorkers() == 0); - assertEquals(null, frame.tiles().getFirst().tile()); + assertNull(frame.tiles().getFirst().tile()); + } + + @Test + public void resizeAndSameLevelZoomReuseFinalTiles() throws Exception { + MapTile tile = mock(MapTile.class); + MapTileRenderer.TileRender work = finished(tile); + MapTileRenderer renderer = renderer(work); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame first = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 1D, 48, 48, false)); + await(() -> controller.progress(first).ready() == 1); + VisionRenderController.Frame resized = controller.request(spec(renderer, 1L)); + assertSame(tile, resized.tiles().getFirst().tile()); + VisionRenderController.Frame zoomed = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 32D, 32D, 0.9D, 64, 64, false)); + assertSame(tile, zoomed.tiles().getFirst().tile()); + assertEquals(1, controller.progress(zoomed).ready()); + verify(work).render(any()); + verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + } + } + + @Test + public void contentRevisionInvalidatesProvisionalWork() throws Exception { + MapTile tile = mock(MapTile.class); + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); + when(work.render(any())).thenReturn(tile); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); + await(() -> first.tiles().getFirst().tile() != null); + VisionRenderController.Frame second = controller.request(spec(renderer, 2L)); + await(() -> second.tiles().getFirst().tile() != null); + verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + } + } + + @Test + public void missingDetailUsesProvisionalOverviewWithoutMarkingItFinal() throws Exception { + MapTileRenderer renderer = mock(MapTileRenderer.class); + MapTile overview = mock(MapTile.class); + MapTileRenderer.TileRender overviewWork = mock(MapTileRenderer.TileRender.class); + when(overviewWork.render(any())).thenReturn(overview); + CountDownLatch entered = new CountDownLatch(1); + MapTileRenderer.TileRender detailWork = cancellable(entered); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())) + .thenAnswer(invocation -> (double) invocation.getArgument(2) < 4D ? detailWork : overviewWork); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false)); + await(() -> coarse.tiles().getFirst().tile() != null); + VisionRenderController.Frame fine = controller.request(spec(renderer, 1L)); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertNull(fine.tiles().getFirst().tile()); + VisionRenderController.Fallback fallback = controller.fallback(fine, fine.tiles().getFirst()); + assertNotNull(fallback); + assertSame(overview, fallback.pieces().getFirst().tile()); + assertEquals(0, controller.progress(fine).ready()); + } + } + + @Test + public void zoomingOutKeepsCompleteFinerTilesOverProvisionalExactTile() throws Exception { + MapTileRenderer renderer = mock(MapTileRenderer.class); + MapTile fineTile = mock(MapTile.class); + MapTile coarseTile = mock(MapTile.class); + MapTileRenderer.TileRender fineWork = finished(fineTile); + MapTileRenderer.TileRender coarseWork = mock(MapTileRenderer.TileRender.class); + when(coarseWork.render(any())).thenReturn(coarseTile); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())) + .thenAnswer(invocation -> (double) invocation.getArgument(2) < 2D ? fineWork : coarseWork); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame fine = controller.request(spec(renderer, 1L)); + await(() -> controller.progress(fine).ready() == 1); + VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 64D, 64D, 2D, 64, 64, false)); + await(() -> coarse.tiles().getFirst().tile() != null); + VisionRenderController.Fallback fallback = controller.fallback(coarse, coarse.tiles().getFirst()); + assertNotNull(fallback); + assertSame(fineTile, fallback.pieces().getFirst().tile()); + assertEquals(0.5D, fallback.pieces().getFirst().size(), 0D); + } + } + + @Test + public void suspendedViewCancelsSamplingAndProbeButKeepsCompletedTiles() throws Exception { + MapTile tile = mock(MapTile.class); + MapTileRenderer renderer = mock(MapTileRenderer.class); + CountDownLatch sampling = new CountDownLatch(1); + MapTileRenderer.TileRender pending = cancellable(sampling); + MapTileRenderer.TileRender complete = finished(tile); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())) + .thenReturn(complete, pending); + CountDownLatch probing = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + AtomicInteger published = new AtomicInteger(); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); + await(() -> controller.progress(first).ready() == 1); + VisionRenderController.Frame next = controller.request(new VisionRenderController.RenderSpec(renderer, + MapLayer.of(TileType.RIVER), 1L, 96D, 32D, 1D, 64, 64, false)); + assertTrue(sampling.await(5L, TimeUnit.SECONDS)); + controller.submitProbe(next, () -> { + probing.countDown(); + try { + new CountDownLatch(1).await(); + return "late"; + } catch (InterruptedException error) { + interrupted.countDown(); + throw error; + } + }, value -> published.incrementAndGet()); + assertTrue(probing.await(5L, TimeUnit.SECONDS)); + controller.suspend(); + assertTrue(interrupted.await(5L, TimeUnit.SECONDS)); + await(() -> controller.progress(next).activeWorkers() == 0); + EventQueue.invokeAndWait(() -> { }); + assertEquals(0, published.get()); + assertNull(next.tiles().getFirst().tile()); + VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L)); + assertSame(tile, resumed.tiles().getFirst().tile()); + verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + } + } + + @Test + public void retainedWorkCountsAgainstCacheBudgetAndIsReleasedWhenComplete() throws Exception { + MapTile tile = mock(MapTile.class); + when(tile.bytes()).thenReturn(1L); + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); + AtomicInteger attempts = new AtomicInteger(); + when(work.render(any())).thenAnswer(invocation -> { + attempts.incrementAndGet(); + return tile; + }); + when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2); + when(work.bytes()).thenReturn(256L * 1024L * 1024L); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); + await(() -> first.tiles().getFirst().tile() != null); + VisionRenderController.Frame second = controller.request(spec(renderer, 1L)); + await(() -> controller.progress(second).ready() == 1); + VisionRenderController.Frame third = controller.request(spec(renderer, 1L)); + assertSame(tile, third.tiles().getFirst().tile()); + verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + assertEquals(2, attempts.get()); + } + } + + @Test + public void cancelledRetryReleasesWorkAndKeepsPublishedImage() throws Exception { + MapTile incomplete = mock(MapTile.class); + MapTile complete = mock(MapTile.class); + CountDownLatch entered = new CountDownLatch(1); + MapTileRenderer.TileRender partial = mock(MapTileRenderer.TileRender.class); + MapTileRenderer.TileRender replacement = finished(complete); + AtomicInteger attempts = new AtomicInteger(); + when(partial.render(any())).thenAnswer(invocation -> { + if (attempts.incrementAndGet() == 1) { + return incomplete; + } + BooleanSupplier cancelled = invocation.getArgument(0); + entered.countDown(); + await(cancelled); + throw new CancellationException(); + }); + MapTileRenderer renderer = mock(MapTileRenderer.class); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())) + .thenReturn(partial, replacement); + try (VisionRenderController controller = new VisionRenderController(() -> { })) { + VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); + await(() -> first.tiles().getFirst().tile() != null); + await(() -> { + controller.retryProvisional(first); + return entered.getCount() == 0L; + }); + controller.suspend(); + await(() -> controller.progress(first).activeWorkers() == 0); + assertSame(incomplete, first.tiles().getFirst().tile()); + VisionRenderController.Frame resumed = controller.request(spec(renderer, 1L)); + await(() -> controller.progress(resumed).ready() == 1); + assertSame(complete, resumed.tiles().getFirst().tile()); + verify(renderer, times(2)).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + } + } + + @Test + public void previewReadinessWakesBackoffAtBoundedCadenceIncludingCompletionDuringRender() throws Exception { + MapTile tile = mock(MapTile.class); + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + MapTileRenderer renderer = renderer(work); + AtomicLong time = new AtomicLong(); + AtomicLong readiness = new AtomicLong(); + AtomicInteger attempts = new AtomicInteger(); + when(renderer.previewRevision()).thenAnswer(invocation -> readiness.get()); + when(work.render(any())).thenAnswer(invocation -> { + if (attempts.incrementAndGet() == 3) { + readiness.incrementAndGet(); + } + return tile; + }); + when(work.complete()).thenAnswer(invocation -> attempts.get() >= 4); + try (VisionRenderController controller = new VisionRenderController(() -> { }, time::get)) { + VisionRenderController.Frame frame = controller.request(spec(renderer, 1L)); + await(() -> attempts.get() == 1 && controller.progress(frame).activeWorkers() == 0); + time.set(VisionRenderController.FIRST_RETRY_NANOS); + controller.retryProvisional(frame); + await(() -> attempts.get() == 2 && controller.progress(frame).activeWorkers() == 0); + time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS); + controller.retryProvisional(frame); + assertEquals(2, attempts.get()); + readiness.incrementAndGet(); + controller.retryProvisional(frame); + await(() -> attempts.get() == 3 && controller.progress(frame).activeWorkers() == 0); + time.addAndGet(VisionRenderController.FIRST_RETRY_NANOS - 1L); + controller.retryProvisional(frame); + assertEquals(3, attempts.get()); + time.incrementAndGet(); + controller.retryProvisional(frame); + await(() -> controller.progress(frame).ready() == 1); + assertEquals(4, attempts.get()); + verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean()); + } + } + + private static MapTileRenderer.TileRender cancellable(CountDownLatch entered) { + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + when(work.render(any())).thenAnswer(invocation -> { + BooleanSupplier cancelled = invocation.getArgument(0); + entered.countDown(); + while (!cancelled.getAsBoolean() && !Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(1L); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + throw new CancellationException(); + }); + return work; + } + + private static MapTileRenderer renderer(MapTileRenderer.TileRender work) { + MapTileRenderer renderer = mock(MapTileRenderer.class); + when(renderer.prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean())).thenReturn(work); + return renderer; + } + + private static MapTileRenderer.TileRender finished(MapTile tile) { + MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class); + when(work.render(any())).thenReturn(tile); + when(work.complete()).thenReturn(true); + return work; } private static VisionRenderController.RenderSpec spec(MapTileRenderer renderer, long revision) { diff --git a/core/src/test/java/art/arcane/iris/studio/render/WaterMapProgressTest.java b/core/src/test/java/art/arcane/iris/studio/render/WaterMapProgressTest.java new file mode 100644 index 000000000..dd09b60bd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/render/WaterMapProgressTest.java @@ -0,0 +1,100 @@ +package art.arcane.iris.studio.render; + +import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview; +import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; +import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType; +import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster; +import art.arcane.iris.generation.hydrology.raster.WaterRole; +import art.arcane.iris.generation.hydrology.water.Reach; +import org.junit.Test; + +import java.util.concurrent.CancellationException; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WaterMapProgressTest { + @Test + public void completeDetailedRastersDoNotDrawPlanFallbacks() { + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + Reach reach = mock(Reach.class); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(new HydrologyPlanPreview(List.of(reach), List.of(), List.of(), List.of(), true)); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY); + + WaterMap map = WaterMap.draw(runtime, 32, 0D, 0D, 1D, () -> false); + + assertFalse(map.provisional()); + verifyNoInteractions(reach); + } + + @Test + public void completedPixelsSurviveRuntimeRasterEviction() { + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + ChunkWaterRaster pond = pond(); + AtomicBoolean pending = new AtomicBoolean(true); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY); + when(runtime.rasterPreview(0, 0)).thenReturn(pond, ChunkWaterRaster.PROVISIONAL); + when(runtime.rasterPreview(1, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : pond); + WaterMap map = new WaterMap(runtime, 32, 0D, 0D, 1D); + + map.advance(() -> false); + assertTrue(map.provisional()); + assertEquals(HydrologyFeatureType.POND, map.at(1, 1)); + assertNull(map.at(17, 1)); + pending.set(false); + map.advance(() -> false); + + assertFalse(map.provisional()); + assertEquals(HydrologyFeatureType.POND, map.at(1, 1)); + assertEquals(HydrologyFeatureType.POND, map.at(17, 1)); + assertNull(map.at(1, 17)); + assertTrue(map.bytes() < 16_384L); + verify(runtime, times(1)).rasterPreview(0, 0); + verify(runtime, times(2)).rasterPreview(1, 0); + verify(runtime, times(1)).planPreview(anyInt(), anyInt(), anyInt(), anyInt()); + + map.advance(() -> false); + verify(runtime, times(2)).rasterPreview(1, 0); + } + + @Test + public void cancelledWaterSamplingResumesToTheSameFinalPixels() { + HydrologyRuntime runtime = mock(HydrologyRuntime.class); + ChunkWaterRaster raster = pond(); + when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY); + when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster); + AtomicInteger rows = new AtomicInteger(); + WaterMap map = new WaterMap(runtime, 32, -16.5D, -16.5D, 0.5D); + + assertThrows(CancellationException.class, () -> map.advance(() -> rows.incrementAndGet() > 2)); + assertTrue(map.provisional()); + map.advance(() -> false); + assertFalse(map.provisional()); + WaterMap expected = WaterMap.draw(runtime, 32, -16.5D, -16.5D, 0.5D, () -> false); + for (int z = 0; z < 32; z++) { + for (int x = 0; x < 32; x++) { + assertEquals(expected.at(x, z), map.at(x, z)); + } + } + } + + private static ChunkWaterRaster pond() { + ChunkWaterRaster raster = mock(ChunkWaterRaster.class); + when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND); + return raster; + } +} diff --git a/gradle/generation-build-revision.gradle b/gradle/generation-build-revision.gradle index eee1cb288..f59c1e03f 100644 --- a/gradle/generation-build-revision.gradle +++ b/gradle/generation-build-revision.gradle @@ -13,6 +13,10 @@ Configuration kernelDependencies = configurations.create('generationKernelDepend extendsFrom(configurations.implementation, configurations.slim, configurations.annotationProcessor) } dependencies.add(kernelDependencies.name, libs.guava) +dependencies.add(kernelDependencies.name, libs.lwjgl) +dependencies.add(kernelDependencies.name, libs.lwjgl.opengl) +dependencies.add(kernelDependencies.name, libs.lwjgl.egl) +dependencies.add(kernelDependencies.name, libs.lwjgl.jawt) List nativeRevisionModules = ['native-common', 'native-minecraft26_2-sources'] for (String adapter : rootProject.extensions.extraProperties.get('nativeDevelopmentAdapters')) { nativeRevisionModules.add('native-' + adapter) diff --git a/probe/build.gradle b/probe/build.gradle index b005aa0b8..d476509f3 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -40,6 +40,12 @@ dependencies { runtimeOnly(libs.oshi) runtimeOnly(libs.byteBuddy.core) runtimeOnly(libs.byteBuddy.agent) + runtimeOnly(libs.lwjgl) + runtimeOnly(libs.lwjgl.opengl) + runtimeOnly(libs.lwjgl.egl) + runtimeOnly(libs.lwjgl.jawt) + runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') }) + runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') }) studioProbeApi(libs.spigot) { transitive = false }