Stabilized shutdown and Studio

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

No files matched your search

@@ -57,6 +57,7 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.util.director.help.DirectorMiniMenu; import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher; import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
import art.arcane.iris.integration.MultiverseCoreLink; import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost; 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 postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false); private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = 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 sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false); private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted; private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed; private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration; private volatile PlaceholderRegistration papiRegistration;
@@ -225,6 +226,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private BukkitDebugDump debugDump; private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile ServerShutdownBoundary serverShutdownBoundary; private volatile ServerShutdownBoundary serverShutdownBoundary;
private boolean shutdownProtectionInitialized;
public static VolmitSender getSender() { public static VolmitSender getSender() {
VolmitSender current = sender; VolmitSender current = sender;
@@ -474,10 +476,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
if (debug) { 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) { 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"); File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt");
@@ -627,10 +635,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
postStopFinisherStarted.set(false); postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false); serverStopTeardownDeferred.set(false);
servicesDisabled.set(false); servicesDisabled.set(false);
runtimeTeardownFailed.set(false);
sharedRuntimeClosed.set(false); sharedRuntimeClosed.set(false);
startupBoundaryRestart.set(false); startupBoundaryRestart.set(false);
terminalCleanupCompleted.set(false); terminalCleanupCompleted.set(false);
runtimeTeardownFailed.set(false);
generatorDrainCompleted = false; generatorDrainCompleted = false;
deferredShutdownGenerators.clear(); deferredShutdownGenerators.clear();
MultiBurst.burst.reopen(); MultiBurst.burst.reopen();
@@ -911,6 +919,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onEnable() { public void onEnable() {
enableFailed = false; enableFailed = false;
shutdownProtectionInitialized = false;
IrisStartupValidation.begin(); IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try { try {
@@ -922,13 +931,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
refuseUnsupportedServer(); refuseUnsupportedServer();
return; return;
} }
initializeShutdownProtection();
enable(); enable();
} catch (Throwable failure) { } catch (Throwable failure) {
enableFailed = true;
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) { generatorResolver.refusals().startupWorldsCreated())) {
throw failure; throw failure;
} }
enableFailed = true;
return; return;
} }
BukkitGuiHost.install(); BukkitGuiHost.install();
@@ -946,6 +956,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime(); 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() { private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure(); Throwable bindFailure = INMS.bindFailure();
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
@@ -1008,7 +1029,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisLanguage.shutdown(); IrisLanguage.shutdown();
teardownPapi(); teardownPapi();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping()) boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
&& INMS.get().deferPluginClassLoaderClose(); && tryDeferPluginClassLoaderClose();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) { if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L); teardownRuntime("startup-boundary-restart", 30L);
@@ -1040,7 +1061,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
preservePendingBlockDrops(); preservePendingBlockDrops();
papiLifecycleActive = false; papiLifecycleActive = false;
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping() && INMS.get().deferPluginClassLoaderClose()) { if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher(); startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); 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); 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 * 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. * 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 { try {
MultiBurst.burst.close(); MultiBurst.burst.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris generation workers.", failure); Iris.reportError("Failed to close Iris generation workers.", failure);
} }
try { try {
MultiBurst.hydrology.close(); MultiBurst.hydrology.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris hydrology workers.", failure); Iris.reportError("Failed to close Iris hydrology workers.", failure);
} }
try { try {
MultiBurst.ioBurst.close(); MultiBurst.ioBurst.close();
} catch (Throwable failure) { } catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris I/O workers.", failure); Iris.reportError("Failed to close Iris I/O workers.", failure);
} }
clearQueues(); clearQueues();
@@ -1229,30 +1260,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!terminalCleanupCompleted.compareAndSet(false, true)) { if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return; 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 { try {
runPostShutdown(); runPostShutdown();
} catch (Throwable e) { } catch (Throwable e) {
runtimeTeardownFailed.set(true); runtimeTeardownFailed.set(true);
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e); Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally { } finally {
if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) { boolean activeResources = serverStopTeardownDeferred.get()
runtimeTeardownFailed.set(true); && (runtimeTeardownFailed.get() || hasActiveShutdownResources());
SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit.");
}
IrisPlatforms.unbind(); IrisPlatforms.unbind();
if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) { BukkitPlatform.releaseHost();
SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit."); if (activeResources) {
SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit.");
} else { } else {
try { try {
if (shutdownProtectionInitialized) {
INMS.get().releasePluginClassLoaderClose(); INMS.get().releasePluginClassLoaderClose();
} else {
SlimJar.closeNativeProviders(); SlimJar.closeNativeProviders();
}
} catch (Throwable failure) { } catch (Throwable failure) {
SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup."); SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup.");
failure.printStackTrace(SHUTDOWN_ERRORS); failure.printStackTrace(SHUTDOWN_ERRORS);
} }
} }
BukkitPlatform.releaseHost();
} }
} }
@@ -148,6 +148,9 @@ public class FailedEnableLockTest {
try (EnableFixture fixture = new EnableFixture(false)) { try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable); IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound")); assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
Field failed = Iris.class.getDeclaredField("enableFailed");
failed.setAccessible(true);
assertTrue(failed.getBoolean(plugin));
} }
} }
@@ -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> 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;
}
}
}
@@ -1,7 +1,17 @@
package art.arcane.iris; package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine; 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.GenerationSessionLease;
import art.arcane.iris.generation.runtime.GenerationSessionManager; import art.arcane.iris.generation.runtime.GenerationSessionManager;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; 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.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import art.arcane.volmlib.integration.ReloadAware;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.volmlib.integration.ReloadAware;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
@@ -27,10 +37,13 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; 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.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; 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.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS; 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.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.mockStatic;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; 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> 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> 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> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class);
MockedStatic<SlimJar> 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<Class<? extends IrisService>, 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> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> 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<Class<? extends IrisService>, 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> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IllegalStateException failure = new IllegalStateException("post-shutdown callback failed");
AtomicBoolean cleanupAttempted = new AtomicBoolean();
KList<Runnable> 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> iris = mockStatic(Iris.class)) {
IrisService service = mock(IrisService.class);
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, 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 @Test
public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception { public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(true);
setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true)); setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true));
try { try {
fixture.commitReloadDuringServerStop(); fixture.commitReloadDuringServerStop();
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.generator).quiesceForServerShutdown(); verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync(); verify(fixture.generator, never()).closeAsync();
assertTrue(fixture.serverStopTeardownDeferred()); assertTrue(fixture.serverStopTeardownDeferred());
@@ -170,19 +355,43 @@ public class IrisShutdownMaintenanceTest {
@Test @Test
public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception { public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(false); IllegalStateException failure = new IllegalStateException("class loader hold failed");
doThrow(failure).when(fixture.binding).deferPluginClassLoaderClose();
try { try {
fixture.commitReloadDuringServerStop(); fixture.commitReloadDuringServerStop();
verify(fixture.generator).closeAsync(); verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown(); verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred()); 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 { } finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true); 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 { private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name); Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
@@ -210,6 +419,12 @@ public class IrisShutdownMaintenanceTest {
private Fixture() throws ReflectiveOperationException { private Fixture() throws ReflectiveOperationException {
IrisSettings.settings = new IrisSettings(); 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, "serverStopTeardownDeferred", new AtomicBoolean());
setField(plugin, "postStopFinisherStarted", new AtomicBoolean()); setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>()); setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
@@ -251,6 +466,13 @@ public class IrisShutdownMaintenanceTest {
return ((AtomicBoolean) field.get(plugin)).get(); 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 { private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher"); Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true); method.setAccessible(true);
+9
View File
@@ -180,6 +180,13 @@ def jarJar = configurations.create('jarJar')
dependencies { dependencies {
add('included', project(path: ':core', configuration: 'shadow')) add('included', project(path: ':core', configuration: 'shadow'))
add('included', project(path: ':adapters:bukkit:plugin', configuration: 'runtimeElements')) 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')) add('jarJar', project(':core:agent'))
} }
@@ -194,6 +201,7 @@ tasks.named('jar', Jar).configure {
includeEmptyDirs = false includeEmptyDirs = false
from(jarJar) from(jarJar)
from(provider { included.resolve().collect { zipTree(it) } }) { from(provider { included.resolve().collect { zipTree(it) } }) {
exclude('META-INF/INDEX.LIST')
exclude('languages/**') exclude('languages/**')
exclude('com/fren_gor/**') exclude('com/fren_gor/**')
@@ -734,6 +742,7 @@ pluginPackaging {
policyName = 'Iris Bukkit' policyName = 'Iris Bukkit'
packed = true packed = true
packedJarAccessors = ['art/arcane/iris/platform/bukkit/plugin/VolmitPlugin#getJarFile'] packedJarAccessors = ['art/arcane/iris/platform/bukkit/plugin/VolmitPlugin#getJarFile']
packedPayloadOnlyPrefixes = ['linux/x64/org/lwjgl/', 'iris-language-guides/']
stripDirectories = true stripDirectories = true
stripLocalVariables = true stripLocalVariables = true
releaseCompression = providers.gradleProperty('compactRelease') releaseCompression = providers.gradleProperty('compactRelease')
+10 -7
View File
@@ -108,6 +108,10 @@ dependencies {
compileOnly(libs.log4j.api) compileOnly(libs.log4j.api)
compileOnly(libs.log4j.core) compileOnly(libs.log4j.core)
compileOnly(libs.guava) compileOnly(libs.guava)
compileOnly(libs.lwjgl)
compileOnly(libs.lwjgl.opengl)
compileOnly(libs.lwjgl.egl)
compileOnly(libs.lwjgl.jawt)
// Third Party Integrations // Third Party Integrations
compileOnly(libs.nexo) compileOnly(libs.nexo)
@@ -136,13 +140,6 @@ dependencies {
implementation('com.moandjiezana.toml:toml4j:0.7.2') { implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false 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 // Dynamically Loaded
slim(libs.gson) slim(libs.gson)
slim(libs.lru) slim(libs.lru)
@@ -183,6 +180,12 @@ dependencies {
testImplementation(libs.multiverseCore) testImplementation(libs.multiverseCore)
testImplementation(libs.annotations) testImplementation(libs.annotations)
testImplementation(libs.mythic) 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 { tasks.named('test').configure {
@@ -47,6 +47,7 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.IntBinaryOperator; import java.util.function.IntBinaryOperator;
@@ -79,6 +80,7 @@ public final class HydrologyRuntime {
private final HydrologyBinding binding; private final HydrologyBinding binding;
private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores; private final Function<PreparedHydrologyCacheIdentity, HydrologyPlanStore> stores;
private final boolean caveFluidPolicies; private final boolean caveFluidPolicies;
private final AtomicLong rasterRevision = new AtomicLong();
private volatile PreparedHydrologyCacheIdentity storeIdentity; private volatile PreparedHydrologyCacheIdentity storeIdentity;
private boolean storeOpen; private boolean storeOpen;
private volatile boolean closed; private volatile boolean closed;
@@ -210,6 +212,10 @@ public final class HydrologyRuntime {
return settings; return settings;
} }
public long previewRevision() {
return cache == null ? 0L : cache.readyRevision() + rasterRevision.get();
}
public PreviewScope preview() { public PreviewScope preview() {
return new PreviewScope(cache == null ? null : last.get()); return new PreviewScope(cache == null ? null : last.get());
} }
@@ -696,7 +702,9 @@ public final class HydrologyRuntime {
private ChunkWaterRaster remember(long key, ChunkWaterRaster raster) { private ChunkWaterRaster remember(long key, ChunkWaterRaster raster) {
synchronized (rasters) { synchronized (rasters) {
rasters.put(key, raster); if (rasters.put(key, raster) == null) {
rasterRevision.incrementAndGet();
}
} }
last.get().remember(key, raster); last.get().remember(key, raster);
return raster; return raster;
@@ -57,6 +57,7 @@ public final class HydrologyPlanCache {
private final AtomicLong demands = new AtomicLong(); private final AtomicLong demands = new AtomicLong();
private final AtomicLong speculations = new AtomicLong(); private final AtomicLong speculations = new AtomicLong();
private final AtomicLong previewStamps = new AtomicLong(Long.MIN_VALUE); private final AtomicLong previewStamps = new AtomicLong(Long.MIN_VALUE);
private final AtomicLong readyRevision = new AtomicLong();
private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion); private final Shelf<RegionKey, RegionPlan> regions = new Shelf<>(REGION_CAPACITY, this::startRegion);
private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow); private final Shelf<WindowKey, IndexedWindow> windows = new Shelf<>(WINDOW_CAPACITY, this::startWindow);
private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>(); private final Map<Object, Set<Slot<?, ?>>> epochSlots = new HashMap<>();
@@ -112,6 +113,10 @@ public final class HydrologyPlanCache {
return windows.evictions.get(); return windows.evictions.get();
} }
public long readyRevision() {
return readyRevision.get();
}
public RegionPlan region(RegionKey key) { public RegionPlan region(RegionKey key) {
Objects.requireNonNull(key, "key"); Objects.requireNonNull(key, "key");
requireOffMainThread("region " + key.rx() + "," + key.rz()); requireOffMainThread("region " + key.rx() + "," + key.rz());
@@ -759,7 +764,9 @@ public final class HydrologyPlanCache {
private void succeed(V value) { private void succeed(V value) {
release(); release();
future.complete(value); if (future.complete(value)) {
readyRevision.incrementAndGet();
}
shelf.evict(); shelf.evict();
} }
@@ -78,8 +78,10 @@ public final class TerrainBatchScratch {
int lattice(double x, double z) { int lattice(double x, double z) {
long xBits = Double.doubleToRawLongBits(x); long xBits = Double.doubleToRawLongBits(x);
long zBits = Double.doubleToRawLongBits(z); long zBits = Double.doubleToRawLongBits(z);
long mixed = xBits * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(zBits * 0xC2B2AE3D27D4EB4FL, 31); long mixed = xBits ^ Long.rotateLeft(zBits, 32);
mixed ^= mixed >>> 29; mixed = (mixed ^ (mixed >>> 33)) * 0xFF51AFD7ED558CCDL;
mixed = (mixed ^ (mixed >>> 33)) * 0xC4CEB9FE1A85EC53L;
mixed ^= mixed >>> 33;
int mask = latticeCapacity - 1; int mask = latticeCapacity - 1;
int slot = (int) mixed & mask; int slot = (int) mixed & mask;
while (true) { while (true) {
@@ -1668,7 +1668,6 @@ public class IrisEngine implements Engine {
} catch (GenerationSessionException e) { } catch (GenerationSessionException e) {
throw e; throw e;
} catch (Throwable e) { } catch (Throwable e) {
IrisLogging.reportError(e);
fail("Failed to generate " + x + ", " + z, e); fail("Failed to generate " + x + ", " + z, e);
if (e instanceof WrongEngineBroException wrongEngine) { if (e instanceof WrongEngineBroException wrongEngine) {
throw wrongEngine; throw wrongEngine;
@@ -365,4 +365,10 @@ public final class BukkitPlatform implements IrisPlatform {
public void reportError(Throwable error) { public void reportError(Throwable error) {
bridge().errorSink().accept(error); bridge().errorSink().accept(error);
} }
@Override
public void reportError(String context, Throwable error) {
log(LogLevel.ERROR, context == null || context.isBlank() ? "Unhandled Iris failure." : context);
reportError(error);
}
} }
@@ -1,6 +1,7 @@
package art.arcane.iris.platform.bukkit.nms; package art.arcane.iris.platform.bukkit.nms;
import art.arcane.iris.platform.agent.Agent; 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.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.generation.runtime.EngineTarget;
import art.arcane.iris.spi.IrisLogging; 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 art.arcane.volmlib.nativelib.terrain.NativeWorldLifecyclePolicy;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import java.io.IOException;
import java.lang.instrument.Instrumentation; import java.lang.instrument.Instrumentation;
import java.util.Objects; import java.util.Objects;
@@ -41,7 +43,23 @@ public enum BukkitWorldLifecyclePolicy implements NativeWorldLifecyclePolicy {
@Override @Override
public void releaseClassLoader(ClassLoader loader) { public void releaseClassLoader(ClassLoader loader) {
Throwable providerFailure = null;
try {
SlimJar.closeNativeProviders();
} catch (IOException | RuntimeException | Error failure) {
providerFailure = failure;
}
try {
Agent.releaseClassLoader(loader); 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 @Override
@@ -290,6 +290,10 @@ public interface INMSBinding extends NativeTerrainAccess {
boolean missingDimensionTypes(String... keys); boolean missingDimensionTypes(String... keys);
default void initializeLifecycle(ClassLoader loader) {
throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
}
default boolean injectBukkit() { default boolean injectBukkit() {
return true; return true;
} }
@@ -301,8 +305,8 @@ public interface INMSBinding extends NativeTerrainAccess {
default void uninjectBukkit() { default void uninjectBukkit() {
} }
default boolean deferPluginClassLoaderClose() { default void deferPluginClassLoaderClose() {
return false; throw new IllegalStateException("Plugin class loader shutdown protection is unavailable");
} }
default void releasePluginClassLoaderClose() { default void releasePluginClassLoaderClose() {
@@ -276,9 +276,21 @@ final class MapCanvas extends JPanel {
// Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones. // Tiles keep loading in the background only for the view on screen; a hidden canvas stops asking for new ones.
void setActive(boolean nextActive) { void setActive(boolean nextActive) {
if (closed || active == nextActive) {
return;
}
active = nextActive; active = nextActive;
if (active) { if (active) {
markerTimer.start();
provisionalTimer.start();
requestRender(); 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() { private void onTilesChanged() {
if (closed || !active) {
return;
}
tileLayerStale = true; tileLayerStale = true;
repaint(); repaint();
refreshStatus(); refreshStatus();
@@ -112,6 +112,7 @@ public final class MapWindow implements MapCanvas.Listener {
private double worldCenterX; private double worldCenterX;
private double worldCenterZ; private double worldCenterZ;
private MapView currentView; private MapView currentView;
private boolean windowVisible = true;
private double paletteCenterX; private double paletteCenterX;
private double paletteCenterZ; private double paletteCenterZ;
@@ -190,11 +191,55 @@ public final class MapWindow implements MapCanvas.Listener {
frame.setMinimumSize(StudioTheme.scaledSize(820, 520)); frame.setMinimumSize(StudioTheme.scaledSize(820, 520));
frame.setLocationRelativeTo(null); frame.setLocationRelativeTo(null);
frame.addWindowListener(new WindowAdapter() { frame.addWindowListener(new WindowAdapter() {
@Override
public void windowIconified(WindowEvent event) {
updateWindowVisibility();
}
@Override
public void windowDeiconified(WindowEvent event) {
updateWindowVisibility();
}
@Override @Override
public void windowClosed(WindowEvent event) { public void windowClosed(WindowEvent event) {
close(); 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() { private JToolBar buildToolbar() {
@@ -265,6 +310,7 @@ public final class MapWindow implements MapCanvas.Listener {
} }
frame.getRootPane().revalidate(); frame.getRootPane().revalidate();
frame.getRootPane().repaint(); frame.getRootPane().repaint();
updateWindowVisibility();
} }
private void showMap(MapView previousView, MapView view) { 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.centerOn(paletteExplorer.centerX(), paletteExplorer.centerZ());
} }
canvas.showView(view); canvas.showView(view);
canvas.setActive(true); canvas.setActive(isWindowVisible());
showControls(mapControls); showControls(mapControls);
waterToggle.setVisible(view == MapView.BIOMES); waterToggle.setVisible(view == MapView.BIOMES);
if (view == MapView.REGIONS) { if (view == MapView.REGIONS) {
@@ -19,7 +19,6 @@
package art.arcane.iris.studio.map; package art.arcane.iris.studio.map;
import art.arcane.iris.spi.IrisLogging; 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.MapTileRenderer;
import art.arcane.iris.studio.map.components.MapLabels; import art.arcane.iris.studio.map.components.MapLabels;
import art.arcane.iris.studio.map.components.MapLayer; 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.ArrayBlockingQueue;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory; 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.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; 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. * 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 static final long CACHE_BYTES = 256L * 1024L * 1024L;
private final Runnable listener; private final Runnable listener;
private final LongSupplier nanoTime;
private final int workerCount; private final int workerCount;
private final ThreadPoolExecutor renderExecutor; private final ThreadPoolExecutor renderExecutor;
private final ThreadPoolExecutor probeExecutor; private final ThreadPoolExecutor probeExecutor;
@@ -79,9 +81,15 @@ final class VisionRenderController implements AutoCloseable {
private final AtomicBoolean publicationQueued = new AtomicBoolean(); private final AtomicBoolean publicationQueued = new AtomicBoolean();
private final AtomicBoolean publicationDirty = new AtomicBoolean(); private final AtomicBoolean publicationDirty = new AtomicBoolean();
private volatile Frame currentFrame; private volatile Frame currentFrame;
private Future<?> activeProbe;
VisionRenderController(Runnable listener) { VisionRenderController(Runnable listener) {
this(listener, System::nanoTime);
}
VisionRenderController(Runnable listener, LongSupplier nanoTime) {
this.listener = Objects.requireNonNull(listener, "listener"); this.listener = Objects.requireNonNull(listener, "listener");
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); int processors = Math.max(1, Runtime.getRuntime().availableProcessors());
this.workerCount = Math.max(1, Math.min(6, processors / 2)); this.workerCount = Math.max(1, Math.min(6, processors / 2));
AtomicInteger threadSequence = new AtomicInteger(); AtomicInteger threadSequence = new AtomicInteger();
@@ -123,11 +131,10 @@ final class VisionRenderController implements AutoCloseable {
List<VisibleTile> tiles = visibleTiles(spec, renderScale); List<VisibleTile> tiles = visibleTiles(spec, renderScale);
Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles); Frame frame = new Frame(viewSequence.incrementAndGet(), spec, renderScale, tiles);
for (VisibleTile tile : tiles) { for (VisibleTile tile : tiles) {
tile.setTile(cachedTileServing(tile.key())); tile.state = cachedTileServing(tile.key());
} }
currentFrame = frame; currentFrame = frame;
probeSequence.incrementAndGet(); cancelProbe();
probeExecutor.getQueue().clear();
startWorkers(); startWorkers();
publish(); publish();
return frame; return frame;
@@ -141,25 +148,41 @@ final class VisionRenderController implements AutoCloseable {
startWorkers(); startWorkers();
} }
synchronized void suspend() {
currentFrame = null;
cancelProbe();
}
/** /**
* A tile from another zoom level covering the given one, to draw while the real tile renders. * A tile from another zoom level covering the given one, to draw while the real tile renders.
*/ */
Fallback fallback(Frame frame, VisibleTile tile) { 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) { if (withoutHeights != null) {
return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D))); return new Fallback(List.of(new FallbackPiece(withoutHeights, 0, 0, TILE_PIXELS, 0D, 0D, 1D)));
} }
Fallback coarser = coarserFallback(frame, tile); Fallback coarser = coarserFallback(key, provisional);
return coarser != null ? coarser : finerFallback(frame, tile); 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. // 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) { private TileState cachedTileServing(TileKey key) {
MapTile exact = cache.get(key); CacheEntry exact = cache.get(key);
if (exact != null || key.heights()) { 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) { Progress progress(Frame frame) {
@@ -168,34 +191,44 @@ final class VisionRenderController implements AutoCloseable {
} }
int ready = 0; int ready = 0;
for (VisibleTile tile : frame.tiles()) { for (VisibleTile tile : frame.tiles()) {
if (tile.tile() != null && !tile.provisional) { if (tile.tile() != null && !tile.provisional()) {
ready++; ready++;
} }
} }
return new Progress(frame.tiles().size(), ready, activeWorkers.get()); return new Progress(frame.tiles().size(), ready, activeWorkers.get());
} }
<T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) { synchronized <T> void submitProbe(Frame frame, Callable<T> probe, Consumer<T> consumer) {
Objects.requireNonNull(probe, "probe"); Objects.requireNonNull(probe, "probe");
Objects.requireNonNull(consumer, "consumer"); Objects.requireNonNull(consumer, "consumer");
if (frame == null || closed.get() || currentFrame != frame) { if (frame == null || closed.get() || currentFrame != frame) {
return; return;
} }
long probeRevision = probeSequence.incrementAndGet(); cancelProbe();
probeExecutor.getQueue().clear(); long probeRevision = probeSequence.get();
try { try {
probeExecutor.execute(() -> runProbe(probeRevision, probe, consumer)); activeProbe = probeExecutor.submit(() -> runProbe(probeRevision, probe, consumer));
} catch (RejectedExecutionException ignored) { } catch (RejectedExecutionException ignored) {
probeExecutor.getQueue().clear(); probeExecutor.getQueue().clear();
} }
} }
private void cancelProbe() {
probeSequence.incrementAndGet();
if (activeProbe != null) {
activeProbe.cancel(true);
activeProbe = null;
}
probeExecutor.getQueue().clear();
}
@Override @Override
public synchronized void close() { public synchronized void close() {
if (!closed.compareAndSet(false, true)) { if (!closed.compareAndSet(false, true)) {
return; return;
} }
currentFrame = null; currentFrame = null;
cancelProbe();
renderExecutor.shutdownNow(); renderExecutor.shutdownNow();
probeExecutor.shutdownNow(); probeExecutor.shutdownNow();
cache.clear(); cache.clear();
@@ -285,7 +318,7 @@ final class VisionRenderController implements AutoCloseable {
private synchronized boolean hasWork() { private synchronized boolean hasWork() {
Frame frame = currentFrame; 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() { private synchronized TileJob claimNextTile() {
@@ -293,51 +326,63 @@ final class VisionRenderController implements AutoCloseable {
if (frame == null || closed.get()) { if (frame == null || closed.get()) {
return null; return null;
} }
VisibleTile tile = frame.nextMissing(tilesInFlight, true); VisibleTile tile = frame.nextMissing(tilesInFlight, true, nanoTime.getAsLong());
if (tile == null) { if (tile == null) {
return null; return null;
} }
tilesInFlight.add(tile.key()); 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); 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) { private void render(TileJob job) {
TileKey key = job.key(); TileKey key = job.key();
boolean cancelled = false;
try { try {
long previewRevision = job.renderer().previewRevision();
double tileSpan = TILE_PIXELS * key.blocksPerPixel(); double tileSpan = TILE_PIXELS * key.blocksPerPixel();
long revision = ProvisionalSampling.revision(); MapTileRenderer.TileRender work = job.work();
MapTile tile = job.renderer().renderTile( if (work == null) {
key.tileX() * tileSpan, work = job.renderer().prepareTile(key.tileX() * tileSpan, key.tileZ() * tileSpan,
key.tileZ() * tileSpan, key.blocksPerPixel(), TILE_PIXELS, key.layer(), key.heights());
key.blocksPerPixel(), }
TILE_PIXELS, MapTile tile = work.render(() -> !isWanted(key));
key.layer(), boolean provisional = !work.complete();
key.heights(), TileState previous = job.previous();
() -> !isWanted(key) long delay = 0L;
); if (provisional) {
boolean provisional = revision != ProvisionalSampling.revision(); 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) { synchronized (this) {
if (!isWanted(key)) { if (closed.get()) {
return; return;
} }
VisibleTile visible = currentFrame.tile(key); cache.put(key, state, provisional ? work : null);
if (provisional) { if (isWanted(key)) {
visible.markProvisional(tile, System.nanoTime()); currentFrame.tile(key).state = state;
} else {
cache.put(key, tile);
visible.setTile(tile);
} }
} }
publish(); publish();
} catch (CancellationException ignored) { } catch (CancellationException ignored) {
cancelled = true;
cache.discardWork(key);
} catch (Throwable error) { } catch (Throwable error) {
IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage());
} finally { } finally {
release(key); release(key, cancelled);
} }
} }
@@ -346,8 +391,7 @@ final class VisionRenderController implements AutoCloseable {
return !closed.get() && frame != null && frame.tile(key) != null; return !closed.get() && frame != null && frame.tile(key) != null;
} }
private Fallback coarserFallback(Frame frame, VisibleTile tile) { private Fallback coarserFallback(TileKey key, boolean provisional) {
TileKey key = tile.key();
for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) { for (int level = 1; level <= COARSER_FALLBACK_LEVELS; level++) {
long scale = 1L << level; long scale = 1L << level;
int sourceSize = TILE_PIXELS >> level; int sourceSize = TILE_PIXELS >> level;
@@ -357,8 +401,8 @@ final class VisionRenderController implements AutoCloseable {
} }
long parentX = Math.floorDiv(key.tileX(), scale); long parentX = Math.floorDiv(key.tileX(), scale);
long parentZ = Math.floorDiv(key.tileZ(), scale); long parentZ = Math.floorDiv(key.tileZ(), scale);
MapTile parent = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale, parentX, MapTile parent = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), parentScale,
parentZ)); parentX, parentZ), provisional);
if (parent != null) { if (parent != null) {
int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize; int sourceX = (int) (key.tileX() - parentX * scale) * sourceSize;
int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize; int sourceZ = (int) (key.tileZ() - parentZ * scale) * sourceSize;
@@ -368,8 +412,7 @@ final class VisionRenderController implements AutoCloseable {
return null; return null;
} }
private Fallback finerFallback(Frame frame, VisibleTile tile) { private Fallback finerFallback(TileKey key, boolean provisional) {
TileKey key = tile.key();
double childScale = key.blocksPerPixel() / 2D; double childScale = key.blocksPerPixel() / 2D;
if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) { if (childScale < MINIMUM_RENDER_BLOCKS_PER_PIXEL) {
return null; return null;
@@ -377,8 +420,8 @@ final class VisionRenderController implements AutoCloseable {
List<FallbackPiece> pieces = new ArrayList<>(4); List<FallbackPiece> pieces = new ArrayList<>(4);
for (int childZ = 0; childZ < 2; childZ++) { for (int childZ = 0; childZ < 2; childZ++) {
for (int childX = 0; childX < 2; childX++) { for (int childX = 0; childX < 2; childX++) {
MapTile child = cache.get(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale, MapTile child = fallbackTile(new TileKey(key.contentRevision(), key.layer(), key.heights(), childScale,
key.tileX() * 2L + childX, key.tileZ() * 2L + childZ)); key.tileX() * 2L + childX, key.tileZ() * 2L + childZ), provisional);
if (child != null) { if (child != null) {
pieces.add(new FallbackPiece(child, 0, 0, TILE_PIXELS, childX * 0.5D, childZ * 0.5D, 0.5D)); 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. // Tiles are sorted centre first, and everything before the cursor is drawn or claimed, so each search resumes there.
private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance) { private VisibleTile nextMissing(Set<TileKey> inFlight, boolean advance, long now) {
long previewRevision = spec.renderer().previewRevision();
for (int index = nextCandidate; index < tiles.size(); index++) { for (int index = nextCandidate; index < tiles.size(); index++) {
VisibleTile tile = tiles.get(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) { if (advance) {
nextCandidate = index + 1; nextCandidate = index + 1;
} }
@@ -548,10 +592,7 @@ final class VisionRenderController implements AutoCloseable {
static final class VisibleTile { static final class VisibleTile {
private final TileKey key; private final TileKey key;
private final double distanceSquared; private final double distanceSquared;
private volatile MapTile tile; private volatile TileState state;
private volatile boolean provisional;
private long retryDelay;
private long retryAt;
private VisibleTile(TileKey key, double distanceSquared) { private VisibleTile(TileKey key, double distanceSquared) {
this.key = key; this.key = key;
@@ -575,28 +616,19 @@ final class VisionRenderController implements AutoCloseable {
} }
MapTile tile() { MapTile tile() {
return tile; TileState current = state;
return current == null ? null : current.tile();
} }
boolean provisional() { boolean provisional() {
return provisional; TileState current = state;
return current != null && current.provisional();
} }
private void setTile(MapTile tile) { private boolean needsRender(long now, long previewRevision) {
this.tile = tile; TileState current = state;
provisional = false; return current == null || current.provisional() && (now - current.retryAt() >= 0L
retryDelay = 0L; || previewRevision != current.previewRevision() && now - current.earliestRetryAt() >= 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;
} }
} }
@@ -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 { private static final class WeightedTileCache {
@@ -658,14 +694,13 @@ final class VisionRenderController implements AutoCloseable {
this.entries = new LinkedHashMap<>(128, 0.75F, true); this.entries = new LinkedHashMap<>(128, 0.75F, true);
} }
synchronized MapTile get(TileKey key) { synchronized CacheEntry get(TileKey key) {
CacheEntry entry = entries.get(key); return entries.get(key);
return entry == null ? null : entry.tile();
} }
synchronized void put(TileKey key, MapTile tile) { synchronized void put(TileKey key, TileState state, MapTileRenderer.TileRender work) {
long tileBytes = tile.bytes(); long tileBytes = state.tile().bytes() + (work == null ? 0L : work.bytes());
CacheEntry previous = entries.put(key, new CacheEntry(tile, tileBytes)); CacheEntry previous = entries.put(key, new CacheEntry(state, work, tileBytes));
if (previous != null) { if (previous != null) {
bytes -= previous.bytes(); bytes -= previous.bytes();
} }
@@ -682,8 +717,18 @@ final class VisionRenderController implements AutoCloseable {
entries.clear(); entries.clear();
bytes = 0L; bytes = 0L;
} }
synchronized void discardWork(TileKey key) {
CacheEntry entry = entries.get(key);
if (entry == null || entry.work() == null) {
return;
}
long retainedBytes = entry.state().tile().bytes();
entries.put(key, new CacheEntry(entry.state(), null, retainedBytes));
bytes -= entry.bytes() - retainedBytes;
}
} }
private record CacheEntry(MapTile tile, long bytes) { private record CacheEntry(TileState state, MapTileRenderer.TileRender work, long bytes) {
} }
} }
@@ -21,6 +21,7 @@ package art.arcane.iris.studio.map.components;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; 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.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension; 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 art.arcane.volmlib.util.stream.ProceduralStream;
import java.awt.Color; import java.awt.Color;
import java.awt.image.BufferedImage;
import java.util.Arrays; import java.util.Arrays;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
@@ -73,6 +73,11 @@ public final class MapTileRenderer {
return labels; 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. * 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, boolean withHeights,
BooleanSupplier cancelled 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) { if (!Double.isFinite(startX) || !Double.isFinite(startZ) || !Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) {
throw new IllegalArgumentException("Map tile coordinates and scale must be finite"); 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"); throw new IllegalArgumentException("Map tile size must be positive");
} }
Objects.requireNonNull(layer, "layer"); Objects.requireNonNull(layer, "layer");
Objects.requireNonNull(cancelled, "cancelled"); return new TileRender(new TileRequest(startX, startZ, blocksPerPixel, size, layer, withHeights));
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);
} }
/** /**
@@ -166,15 +150,15 @@ public final class MapTileRenderer {
return sampler.identities; return sampler.identities;
} }
private double[] sampleHeights(TileGrid grid) { private double[] sampleHeights(HeightLattice lattice, BooleanSupplier cancelled) {
IrisComplex complex = engine.getComplex(); IrisComplex complex = engine.getComplex();
IrisDimension dimension = engine.getDimension(); IrisDimension dimension = engine.getDimension();
HydrologyRuntime water = complex.getHydrologyRuntime(); HydrologyRuntime water = complex.getHydrologyRuntime();
if (water == null || !water.enabled() || dimension != null && dimension.getTerrainTransform() != null) { 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()) { 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) { private int[] tileColors(int size, TileType type, short[] subjects) {
int[] colors = new int[size * size]; int[] colors = new int[size * size];
short previousSubject = MapLabels.NONE; 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 @FunctionalInterface
private interface IdentityShader { private interface IdentityShader {
int identity(double x, double z); int identity(double x, double z);
@@ -554,16 +645,20 @@ public final class MapTileRenderer {
private final ProceduralStream<Double> stream; private final ProceduralStream<Double> stream;
private final int pointsPerSide; private final int pointsPerSide;
private final double[] points; private final double[] points;
private final boolean[] resolved;
private int pending;
private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) { private HeightLattice(TileGrid grid, ProceduralStream<Double> stream) {
this.grid = grid; this.grid = grid;
this.stream = stream; this.stream = stream;
this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1; this.pointsPerSide = (grid.size() - 2) / LATTICE_PIXELS + LATTICE_MARGIN * 2 + 1;
this.points = new double[pointsPerSide * pointsPerSide]; this.points = new double[pointsPerSide * pointsPerSide];
this.resolved = new boolean[points.length];
this.pending = points.length;
} }
double[] interpolatedHeights() { double[] interpolatedHeights(BooleanSupplier cancelled) {
samplePoints(); samplePoints(cancelled);
int size = grid.size(); int size = grid.size();
double[] heights = new double[size * size]; double[] heights = new double[size * size];
for (int gridZ = 0; gridZ < size; gridZ++) { for (int gridZ = 0; gridZ < size; gridZ++) {
@@ -574,16 +669,33 @@ public final class MapTileRenderer {
return heights; 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. // 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 latticeStep = grid.step() * LATTICE_PIXELS;
double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep; double firstX = grid.worldX(1) - LATTICE_MARGIN * latticeStep;
double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep; double firstZ = grid.worldZ(1) - LATTICE_MARGIN * latticeStep;
for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) { for (int pointZ = 0; pointZ < pointsPerSide; pointZ++) {
checkCancelled(grid.cancelled()); checkCancelled(cancelled);
for (int pointX = 0; pointX < pointsPerSide; pointX++) { 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); firstZ + pointZ * latticeStep);
if (revision == ProvisionalSampling.revision()) {
resolved[index] = true;
pending--;
}
} }
} }
} }
@@ -70,6 +70,13 @@ public final class IrisRenderer {
this.renderer = Objects.requireNonNull(renderer, "renderer"); 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) { public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) {
return render(sx, sz, size, resolution, currentType, () -> false); 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 @FunctionalInterface
private interface PixelShader { private interface PixelShader {
int color(double x, double z); int color(double x, double z);
@@ -36,35 +36,85 @@ final class WaterMap {
private final double startX; private final double startX;
private final double startZ; private final double startZ;
private final double step; private final double step;
private final BooleanSupplier cancelled;
private final int[] ranks; private final int[] ranks;
private final boolean[] resolved;
private final IdentityHashMap<HydrologyTier, HydrologyFeatureType> channels = new IdentityHashMap<>(); private final IdentityHashMap<HydrologyTier, HydrologyFeatureType> channels = new IdentityHashMap<>();
private final Set<Long> lakes = new HashSet<>();
private BooleanSupplier cancelled;
private int[] plannedRanks;
private boolean plansComplete;
private boolean provisional; private 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.runtime = runtime;
this.resolution = resolution; this.resolution = resolution;
this.startX = startX; this.startX = startX;
this.startZ = startZ; this.startZ = startZ;
this.step = step; this.step = step;
this.cancelled = cancelled;
this.ranks = new int[resolution * resolution]; this.ranks = new int[resolution * resolution];
this.resolved = step <= RASTER_STEP ? new boolean[ranks.length] : null;
Arrays.fill(ranks, DRY); Arrays.fill(ranks, DRY);
provisional = true;
} }
static WaterMap draw(HydrologyRuntime runtime, int resolution, double startX, double startZ, double step, BooleanSupplier cancelled) { 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); WaterMap map = new WaterMap(runtime, resolution, startX, startZ, step);
HydrologyPlanPreview plans = runtime.planPreview((int) Math.floor(startX), (int) Math.floor(startZ), map.advance(cancelled);
(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);
}
return map; 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) { HydrologyFeatureType at(int pixelX, int pixelZ) {
int rank = ranks[pixelZ * resolution + pixelX]; int rank = ranks[pixelZ * resolution + pixelX];
return rank == DRY ? null : DRAWN.get(rank); return rank == DRY ? null : DRAWN.get(rank);
@@ -84,37 +134,39 @@ final class WaterMap {
} }
} }
private void drawRasters(HydrologyPlanPreview plans) { private boolean drawRasters(HydrologyPlanPreview plans) {
Set<Long> lakes = new HashSet<>();
for (Pond lake : plans.lakes()) {
lakes.add(lake.id());
}
Map<Long, ChunkWaterRaster> rasters = new HashMap<>(); Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
WaterMap planned = null; boolean pending = false;
for (int pixelZ = 0; pixelZ < resolution; pixelZ++) { for (int pixelZ = 0; pixelZ < resolution; pixelZ++) {
checkCancelled(); checkCancelled();
int minimumZ = (int) Math.floor(startZ + pixelZ * step); int minimumZ = (int) Math.floor(startZ + pixelZ * step);
int maximumZ = Math.max(minimumZ, (int) Math.ceil(startZ + (pixelZ + 1) * step) - 1); int maximumZ = Math.max(minimumZ, (int) Math.ceil(startZ + (pixelZ + 1) * step) - 1);
for (int pixelX = 0; pixelX < resolution; pixelX++) { 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 minimumX = (int) Math.floor(startX + pixelX * step);
int maximumX = Math.max(minimumX, (int) Math.ceil(startX + (pixelX + 1) * step) - 1); int maximumX = Math.max(minimumX, (int) Math.ceil(startX + (pixelX + 1) * step) - 1);
for (int z = minimumZ; z <= maximumZ; z++) { for (int z = minimumZ; z <= maximumZ; z++) {
for (int x = minimumX; x <= maximumX; x++) { for (int x = minimumX; x <= maximumX; x++) {
ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4); ChunkWaterRaster raster = raster(rasters, x >> 4, z >> 4);
if (raster.provisional()) { if (raster.provisional()) {
provisional = true; pending = true;
if (planned == null) { ready = false;
planned = new WaterMap(runtime, resolution, startX, startZ, step, cancelled); prepareFallback(plans);
planned.drawPlans(plans); ranks[index] = Math.min(ranks[index], plannedRanks[index]);
}
stamp(pixelX, pixelZ, planned.at(pixelX, pixelZ));
} else if (!raster.empty()) { } else if (!raster.empty()) {
stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes)); stamp(pixelX, pixelZ, columnType(raster, x & 15, z & 15, lakes));
} }
} }
} }
resolved[index] = ready;
} }
} }
return pending;
} }
private ChunkWaterRaster raster(Map<Long, ChunkWaterRaster> rasters, int chunkX, int chunkZ) { private ChunkWaterRaster raster(Map<Long, ChunkWaterRaster> rasters, int chunkX, int chunkZ) {
@@ -251,7 +303,7 @@ final class WaterMap {
} }
private void checkCancelled() { private void checkCancelled() {
if (cancelled.getAsBoolean()) { if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) {
throw new CancellationException("Vision render was cancelled"); throw new CancellationException("Vision render was cancelled");
} }
} }
@@ -70,6 +70,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -143,6 +144,95 @@ public class HydrologyRuntimeTest {
assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName()))); assertTrue(planners.plannerThreads().stream().noneMatch(name -> name.equals(Thread.currentThread().getName())));
} }
@Test(timeout = 180_000L)
public void previewRevisionAdvancesOnlyWhenPlansOrRastersBecomeAvailable() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
CountDownLatch blocker = block(pool);
CompletableFuture<Void> plans = runtime.prepareWindowsAround(0, 0, 0, 0, 1);
assertEquals(0L, runtime.previewRevision());
assertFalse(plans.isDone());
blocker.countDown();
plans.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertTrue(planned > 0L);
assertTrue(runtime.planPreview(0, 0, 15, 15).complete());
assertEquals(planned, runtime.previewRevision());
CountDownLatch rasterBlocker = block(pool);
for (int repeat = 0; repeat < 100; repeat++) {
assertTrue(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned, runtime.previewRevision());
}
rasterBlocker.countDown();
await(() -> runtime.previewRevision() == planned + 1L, "raster publication advances preview readiness");
ChunkWaterRaster ready = runtime.rasterPreview(0, 0);
assertFalse(ready.provisional());
for (int repeat = 0; repeat < 100; repeat++) {
assertSame(ready, runtime.rasterPreview(0, 0));
assertSame(ready, runtime.raster(0, 0));
assertEquals(planned + 1L, runtime.previewRevision());
}
assertFalse(runtime.raster(1, 0).provisional());
assertEquals(planned + 2L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void concurrentRasterPublicationAdvancesPreviewRevisionOnce() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyRuntime runtime = runtime(planners, pool, () -> false);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
CountDownLatch gate = new CountDownLatch(1);
planners.naturalGate = gate;
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread demand = new Thread(() -> {
try {
runtime.raster(0, 0);
} catch (Throwable error) {
failure.set(error);
}
});
demand.start();
try {
await(() -> planners.naturalSamples.get() > 0, "demanded raster starts");
assertTrue(runtime.rasterPreview(0, 0).provisional());
await(() -> planners.naturalSamples.get() >= 2, "preview raster starts beside demand");
assertEquals(planned, runtime.previewRevision());
} finally {
gate.countDown();
demand.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
}
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertFalse(demand.isAlive());
assertNull(failure.get());
assertFalse(runtime.rasterPreview(0, 0).provisional());
assertEquals(planned + 1L, runtime.previewRevision());
}
@Test(timeout = 180_000L)
public void failedRasterDoesNotAdvancePreviewRevision() throws Exception {
FakePlanners planners = new FakePlanners(settings(0, false));
ExecutorService pool = pool(planners, 1);
HydrologyPlanCache cache = new HydrologyPlanCache(planners.settings, planners::region, planners::window, pool, () -> false);
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, (x, z) -> {
throw new IllegalStateException("terrain sampling failed");
}, () -> false, planners.ticker::get);
runtimes.add(runtime);
runtime.prepareWindowsAround(0, 0, 0, 0, 1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long planned = runtime.previewRevision();
assertThrows(IllegalStateException.class, () -> runtime.raster(0, 0));
assertEquals(planned, runtime.previewRevision());
}
@Test(timeout = 180_000L) @Test(timeout = 180_000L)
public void rasterCachedPerChunk() throws Exception { public void rasterCachedPerChunk() throws Exception {
FakePlanners planners = new FakePlanners(settings()); FakePlanners planners = new FakePlanners(settings());
@@ -868,6 +958,7 @@ public class HydrologyRuntimeTest {
private static void assertDisabled(HydrologyRuntime runtime) { private static void assertDisabled(HydrologyRuntime runtime) {
assertFalse(runtime.enabled()); assertFalse(runtime.enabled());
assertEquals(0L, runtime.previewRevision());
assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.raster(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.rasterSnapshot(0, 0));
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.rasterAt(0, 0));
@@ -879,6 +970,7 @@ public class HydrologyRuntimeTest {
assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0)); assertSame(ChunkWaterRaster.EMPTY, runtime.rasterPreview(0, 0));
assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511)); assertEquals(HydrologyPlanPreview.EMPTY, runtime.planPreview(0, 0, 511, 511));
runtime.close(); runtime.close();
assertEquals(0L, runtime.previewRevision());
} }
private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception { private static void settle(FakePlanners planners, ExecutorService pool, int windows) throws Exception {
@@ -139,6 +139,73 @@ public class HydrologyPlanCacheTest {
assertEquals(KEY, results[0].plan().key()); assertEquals(KEY, results[0].plan().key());
} }
@Test
public void readyRevisionCountsSuccessfulPlanPublicationsWithoutCountingReads() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = WindowPlanner.lakeRegions(KEY).getFirst();
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(region);
await(() -> gate.arrivals() == 1, "region planning starts before publication");
assertEquals(0L, cache.readyRevision());
assertFalse(pending.isDone());
gate.release();
RegionPlan ready = pending.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
assertSame(ready, cache.regionIfReady(region).orElseThrow());
assertSame(ready, cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertSame(ready, cache.region(region));
assertEquals(1L, cache.readyRevision());
cache.previewWindow(KEY).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
long published = WindowPlanner.lakeRegions(KEY).size() + 1L;
assertEquals(published, cache.readyRevision());
assertTrue(cache.windowIfReady(KEY).isPresent());
cache.window(KEY);
assertEquals(published, cache.readyRevision());
}
@Test
public void failedPlansDoNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
RegionKey region = new RegionKey(0, 0);
planners.hook = key -> {
throw new IllegalStateException("region planning failed");
};
CompletableFuture<RegionPlan> failed = cache.previewRegion(region);
assertThrows(ExecutionException.class, () -> failed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
assertEquals(0L, cache.readyRevision());
planners.hook = key -> { };
cache.previewRegion(region).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals(1L, cache.readyRevision());
}
@Test
public void cancelledPlanCompletionDoesNotAdvanceReadyRevision() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
ExecutorService pool = pool(1);
HydrologyPlanCache cache = cache(planners, pool, () -> false);
Gate gate = new Gate();
planners.hook = key -> gate.pass();
CompletableFuture<RegionPlan> pending = cache.previewRegion(new RegionKey(0, 0));
await(() -> gate.arrivals() == 1, "region planning starts before cancellation");
cache.close();
gate.release();
pool.submit(() -> { }).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertTrue(pending.isCompletedExceptionally());
assertEquals(0L, cache.readyRevision());
}
@Test @Test
public void interruptedPlanIsNotCached() throws Exception { public void interruptedPlanIsNotCached() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted()); FakePlanners planners = new FakePlanners(unpersisted());
@@ -0,0 +1,69 @@
package art.arcane.iris.generation.noise;
import org.junit.Test;
import java.lang.reflect.Field;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class TerrainBatchScratchTest {
@Test
public void integerLatticesDistributeWithoutLongLinearProbeClusters() throws Exception {
TerrainBatchScratch scratch = new TerrainBatchScratch();
for (int origin : new int[]{0, -1536, 1536, 16_777_212}) {
scratch.clearLattice(4096);
for (int index = 0; index < 1024; index++) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
}
assertEquals(1024, scratch.latticeCount());
for (int index = 1023; index >= 0; index--) {
double x = origin + (index & 31) * 6D;
double z = origin + (index >> 5) * 6D;
assertEquals(index, scratch.lattice(x, z));
assertEquals(x, scratch.latticeX(index), 0D);
assertEquals(z, scratch.latticeZ(index), 0D);
}
assertTrue("clustered lattice at " + origin, longestCluster(scratch) < 32);
}
}
@Test
public void rawCoordinateIdentityAndInsertionOrderSurviveRehashingAndReset() {
TerrainBatchScratch scratch = new TerrainBatchScratch();
long[] bits = {0L, Long.MIN_VALUE, Double.doubleToRawLongBits(1D), Double.doubleToRawLongBits(-1D),
Double.doubleToRawLongBits(0.125D), Double.doubleToRawLongBits(-0.125D),
Double.doubleToRawLongBits(Double.POSITIVE_INFINITY), Double.doubleToRawLongBits(Double.NEGATIVE_INFINITY),
0x7FF8000000000001L, 0x7FF8000000000002L};
for (int pass = 0; pass < 2; pass++) {
scratch.clearLattice(bits.length * bits.length);
for (int x = 0; x < bits.length; x++) {
for (int z = 0; z < bits.length; z++) {
int expected = x * bits.length + z;
double worldX = Double.longBitsToDouble(bits[x]);
double worldZ = Double.longBitsToDouble(bits[z]);
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(expected, scratch.lattice(worldX, worldZ));
assertEquals(bits[x], Double.doubleToRawLongBits(scratch.latticeX(expected)));
assertEquals(bits[z], Double.doubleToRawLongBits(scratch.latticeZ(expected)));
}
}
assertEquals(bits.length * bits.length, scratch.latticeCount());
}
}
private static int longestCluster(TerrainBatchScratch scratch) throws Exception {
Field field = TerrainBatchScratch.class.getDeclaredField("latticeSlots");
field.setAccessible(true);
int[] slots = (int[]) field.get(scratch);
int longest = 0;
int length = 0;
for (int index = 0; index < slots.length * 2; index++) {
length = slots[index % slots.length] == 0 ? 0 : length + 1;
longest = Math.max(longest, length);
}
return longest;
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.spi.IrisLogging;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisEngineFailureReportingTest {
@Test
public void failedGenerationReportsOnceAndRetainsItsCause() throws Exception {
IrisEngine engine = mock(IrisEngine.class);
IllegalStateException failure = new IllegalStateException("Hydrology raster failed");
Field platformHooks = IrisEngine.class.getDeclaredField("platformHooks");
platformHooks.setAccessible(true);
platformHooks.set(engine, mock(EnginePlatformHooks.class));
doThrow(failure).when(engine).openGenerationHistoryCoordinateScope(-352, -144);
doCallRealMethod().when(engine).generate(-352, -144, null, null, false);
doCallRealMethod().when(engine).fail("Failed to generate -352, -144", failure);
doCallRealMethod().when(engine).hasFailed();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
WrongEngineBroException reported = assertThrows(WrongEngineBroException.class,
() -> engine.generate(-352, -144, null, null, false));
assertSame(failure, reported.getCause());
assertTrue(engine.hasFailed());
logging.verify(() -> IrisLogging.error("Failed to generate -352, -144"));
logging.verify(() -> IrisLogging.reportError(failure));
logging.verifyNoMoreInteractions();
}
}
}
@@ -0,0 +1,54 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.spi.LogLevel;
import org.junit.After;
import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class BukkitPlatformErrorReportingTest {
@After
public void releaseHost() {
BukkitPlatform.releaseHost();
}
@Test
public void contextualReportEmitsOneCompleteTraceThroughTheHost() {
ArrayList<LogEntry> messages = new ArrayList<>();
ArrayList<Throwable> reported = new ArrayList<>();
BukkitPlatform.hostBridge(new BukkitPlatform.HostBridge(
(level, message) -> messages.add(new LogEntry(level, message)), null,
failure -> {
reported.add(failure);
failure.printStackTrace(System.err);
}, null, null, null, null, null, null));
IllegalStateException failure = new IllegalStateException("raster failure",
new ArrayIndexOutOfBoundsException("invalid sample"));
ByteArrayOutputStream output = new ByteArrayOutputStream();
PrintStream previousError = System.err;
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8)) {
System.setErr(errors);
new BukkitPlatform().reportError("Generation failed", failure);
} finally {
System.setErr(previousError);
}
assertEquals(List.of(new LogEntry(LogLevel.ERROR, "Generation failed")), messages);
assertEquals(List.of(failure), reported);
String trace = output.toString(StandardCharsets.UTF_8);
assertEquals(1L, trace.lines().filter(line -> line.contains("raster failure")).count());
assertEquals(1L, trace.lines().filter(line -> line.contains("Caused by:")).count());
assertTrue(trace.contains("BukkitPlatformErrorReportingTest.contextualReportEmitsOneCompleteTraceThroughTheHost"));
}
private record LogEntry(LogLevel level, String message) {
}
}
@@ -14,7 +14,10 @@ import org.junit.Test;
import java.util.Locale; import java.util.Locale;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; 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.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -38,13 +41,23 @@ public class BukkitBindingTest {
Fixture fixture = new Fixture(); Fixture fixture = new Fixture();
when(fixture.lifecycle.isServerStopping()).thenReturn(true); when(fixture.lifecycle.isServerStopping()).thenReturn(true);
assertTrue(fixture.binding.isServerStopping()); assertTrue(fixture.binding.isServerStopping());
when(fixture.lifecycle.deferPluginClassLoaderClose()).thenReturn(true); fixture.binding.initializeLifecycle(getClass().getClassLoader());
assertTrue(fixture.binding.deferPluginClassLoaderClose()); verify(fixture.lifecycle).initializeLifecycle(getClass().getClassLoader());
fixture.binding.deferPluginClassLoaderClose();
fixture.binding.releasePluginClassLoaderClose(); fixture.binding.releasePluginClassLoaderClose();
verify(fixture.lifecycle).deferPluginClassLoaderClose(); verify(fixture.lifecycle).deferPluginClassLoaderClose();
verify(fixture.lifecycle).releasePluginClassLoaderClose(); 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 @Test
public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException { public void injectAttachesVanillaSpawnersOnlyToOverworldEnvironments() throws NoSuchFieldException, IllegalAccessException {
Fixture fixture = new Fixture(); Fixture fixture = new Fixture();
@@ -71,8 +84,8 @@ public class BukkitBindingTest {
} }
private static final class Fixture { private static final class Fixture {
private final NativeTerrainAccess terrain = mock(NativeTerrainAccess.class);
private final NativeWorldGeneration generation = mock(NativeWorldGeneration.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 NativeWorldLifecycleFactory.Controller lifecycle = mock(NativeWorldLifecycleFactory.Controller.class);
private final BukkitBinding binding; private final BukkitBinding binding;
@@ -0,0 +1,65 @@
package art.arcane.iris.platform.bukkit.nms;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.testsupport.PlatformBinding;
import org.junit.ClassRule;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class BukkitWorldLifecyclePolicyTest {
@ClassRule
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
@Test
public void nativeProvidersCloseBeforeThePluginClassLoaderIsReleased() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
agent.when(() -> Agent.releaseClassLoader(loader)).thenAnswer(invocation -> {
providers.verify(SlimJar::closeNativeProviders);
return null;
});
BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader);
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void nativeProviderCloseFailureStillReleasesThePluginClassLoader() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException failure = new IOException("provider close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(failure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(failure, reported.getCause());
agent.verify(() -> Agent.releaseClassLoader(loader));
}
}
@Test
public void providerAndLoaderCloseFailuresAreBothPreserved() throws Exception {
ClassLoader loader = mock(ClassLoader.class);
IOException providerFailure = new IOException("provider close failed");
IllegalStateException loaderFailure = new IllegalStateException("loader close failed");
try (MockedStatic<SlimJar> providers = mockStatic(SlimJar.class);
MockedStatic<Agent> agent = mockStatic(Agent.class)) {
providers.when(SlimJar::closeNativeProviders).thenThrow(providerFailure);
agent.when(() -> Agent.releaseClassLoader(loader)).thenThrow(loaderFailure);
IllegalStateException reported = assertThrows(IllegalStateException.class,
() -> BukkitWorldLifecyclePolicy.INSTANCE.releaseClassLoader(loader));
assertSame(loaderFailure, reported);
assertEquals(1, reported.getSuppressed().length);
assertSame(providerFailure, reported.getSuppressed()[0]);
}
}
}
@@ -0,0 +1,224 @@
package art.arcane.iris.studio.map;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapStyle;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.PickDisplay;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
import java.awt.image.BufferedImage;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MapTileProgressTest {
@Test
public void retriesOnlySampleUnresolvedHeightPointsAndReuseBiomeData() {
AtomicBoolean pending = new AtomicBoolean(true);
AtomicInteger heightSamples = new AtomicInteger();
AtomicInteger biomeSamples = new AtomicInteger();
Engine engine = engine(biomeSamples);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
heightSamples.incrementAndGet();
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-8D, -8D, 0.5D, 16, layer, true);
progress.render(() -> false);
int initialHeights = heightSamples.get();
int initialBiomes = biomeSamples.get();
assertFalse(progress.complete());
assertTrue(progress.bytes() > 0L);
progress.render(() -> false);
assertEquals(initialHeights + 1, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
pending.set(false);
MapTile settled = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
assertEquals(0L, progress.bytes());
progress.render(() -> false);
assertEquals(initialHeights + 2, heightSamples.get());
assertEquals(initialBiomes, biomeSamples.get());
MapTile expected = renderer.renderTile(-8D, -8D, 0.5D, 16, layer, true, () -> false);
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), settled.heightAt(x, z), 0D);
}
}
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(settled.image(MapStyle.PLAIN.withShading(true))));
}
@Test
public void completedWaterIsNotBlendedAgainWhileHeightsWait() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (pending.get()) {
ProvisionalSampling.mark();
}
return 60D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapTileRenderer.TileRender progress = renderer.prepareTile(0D, 0D, 1D, 16, MapLayer.of(TileType.BIOME), true);
MapTile first = progress.render(() -> false);
assertFalse(progress.complete());
assertNotEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(0, 0));
MapTile second = progress.render(() -> false);
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(second.image(MapStyle.PLAIN)));
pending.set(false);
MapTile complete = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
assertArrayEquals(pixels(first.image(MapStyle.PLAIN)), pixels(complete.image(MapStyle.PLAIN)));
}
@Test
public void twoBlockPixelsRefineMixedWaterAndHeightsToTheSameFinalTile() {
AtomicBoolean pending = new AtomicBoolean(true);
Engine engine = engine(new AtomicInteger());
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
when(runtime.rasterPreview(0, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : raster);
when(raster.role(anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int z = invocation.getArgument(1);
if (x == 1 && z < 4) {
return WaterRole.POND;
}
if (x == 7 && z < 8) {
return WaterRole.WATERFALL;
}
return x < 4 && z == 9 ? WaterRole.INLET : WaterRole.NONE;
});
when(engine.getComplex().getHydrologyRuntime()).thenReturn(runtime);
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
if (x == 0D && z == 0D && pending.get()) {
ProvisionalSampling.mark();
return 60D;
}
return 80D + x * 0.02D + z * 0.01D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.of(TileType.BIOME);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 2D, 16, layer, true);
MapTile first = progress.render(() -> false);
int[] firstPixels = pixels(first.image(MapStyle.PLAIN.withShading(true)));
assertFalse(progress.complete());
assertEquals(0xFF738249, first.image(MapStyle.PLAIN).getRGB(8, 8));
MapTile waiting = progress.render(() -> false);
assertFalse(progress.complete());
assertArrayEquals(firstPixels, pixels(waiting.image(MapStyle.PLAIN.withShading(true))));
pending.set(false);
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(0L, progress.bytes());
BufferedImage colors = actual.image(MapStyle.PLAIN);
assertEquals(0xFF738249, colors.getRGB(14, 14));
assertNotEquals(0xFF738249, colors.getRGB(8, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(11, 8));
assertNotEquals(colors.getRGB(8, 8), colors.getRGB(8, 12));
assertNotEquals(colors.getRGB(11, 8), colors.getRGB(8, 12));
assertArrayEquals(firstPixels, pixels(first.image(MapStyle.PLAIN.withShading(true))));
MapTile expected = renderer.renderTile(-16D, -16D, 2D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
assertEquals(expected.heightAt(x, z), actual.heightAt(x, z), 0D);
assertEquals(expected.subjectAt(x, z), actual.subjectAt(x, z));
assertEquals(expected.regionAt(x, z), actual.regionAt(x, z));
}
}
}
@Test
public void cancelledPreparationCanResumeWithoutReusingIncompleteHeightSamples() {
Engine engine = engine(new AtomicInteger());
AtomicInteger samples = new AtomicInteger();
when(engine.getComplex().getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> {
samples.incrementAndGet();
return x + z + 80D;
}));
MapTileRenderer renderer = new MapTileRenderer(engine);
MapLayer layer = MapLayer.biomes(PickDisplay.PICKS, false);
MapTileRenderer.TileRender progress = renderer.prepareTile(-16D, -16D, 1D, 16, layer, true);
assertThrows(CancellationException.class, () -> progress.render(() -> samples.get() >= 9));
assertFalse(progress.complete());
MapTile actual = progress.render(() -> false);
assertTrue(progress.complete());
assertEquals(81, samples.get());
MapTile expected = renderer.renderTile(-16D, -16D, 1D, 16, layer, true, () -> false);
assertArrayEquals(pixels(expected.image(MapStyle.PLAIN.withShading(true))),
pixels(actual.image(MapStyle.PLAIN.withShading(true))));
}
private static Engine engine(AtomicInteger biomeSamples) {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = new IrisBiome();
biome.setColor("#738249");
IrisRegion region = new IrisRegion();
when(engine.getComplex()).thenReturn(complex);
when(engine.getHeight()).thenReturn(256);
when(complex.getBaseBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> {
biomeSamples.incrementAndGet();
return biome;
}, Interpolated.of(value -> 0D, value -> biome)));
when(complex.getRegionStream()).thenReturn(ProceduralStream.of((x, z) -> region,
Interpolated.of(value -> 0D, value -> region)));
return engine;
}
private static int[] pixels(BufferedImage image) {
return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
}
}
@@ -1,20 +1,23 @@
package art.arcane.iris.studio.map; 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.MapLayer;
import art.arcane.iris.studio.map.components.MapTile; import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer; import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.TileType; import art.arcane.iris.studio.map.components.TileType;
import org.junit.Test; import org.junit.Test;
import java.awt.EventQueue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; 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.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class VisionRenderControllerTest { public class VisionRenderControllerTest {
@Test @Test
public void provisionalTilesRetryWithBackoffAndConvergeWithoutMoving() throws Exception { 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); MapTile tile = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger(); AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) when(work.render(any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
if (attempts.incrementAndGet() < 3) { attempts.incrementAndGet();
ProvisionalSampling.mark();
}
return tile; return tile;
}); });
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 3);
try (VisionRenderController controller = new VisionRenderController(() -> { })) { try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame frame = controller.request(spec(renderer, 1L)); VisionRenderController.Frame frame = controller.request(spec(renderer, 1L));
await(() -> frame.tiles().getFirst().tile() != null); await(() -> frame.tiles().getFirst().tile() != null);
@@ -55,30 +60,47 @@ public class VisionRenderControllerTest {
}); });
assertEquals(3, attempts.get()); assertEquals(3, attempts.get());
assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile()); assertSame(tile, controller.request(spec(renderer, 1L)).tiles().getFirst().tile());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
} }
} }
@Test @Test
public void provisionalTilesAreShownButNeverReusedAsFinal() throws Exception { public void resizeZoomAndSuspendRetainProvisionalImageWorkAndBackoff() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class); MapTileRenderer.TileRender work = mock(MapTileRenderer.TileRender.class);
MapTileRenderer renderer = renderer(work);
MapTile incomplete = mock(MapTile.class); MapTile incomplete = mock(MapTile.class);
MapTile complete = mock(MapTile.class); MapTile complete = mock(MapTile.class);
AtomicInteger attempts = new AtomicInteger(); AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) when(work.render(any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) { if (attempts.incrementAndGet() == 1) {
ProvisionalSampling.mark();
return incomplete; return incomplete;
} }
return complete; return complete;
}); });
when(work.complete()).thenAnswer(invocation -> attempts.get() >= 2);
try (VisionRenderController controller = new VisionRenderController(() -> { })) { try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame first = controller.request(spec(renderer, 1L)); 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()); 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)); 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()); assertEquals(2, attempts.get());
verify(renderer).prepareTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean());
VisionRenderController.Frame third = controller.request(spec(renderer, 1L)); VisionRenderController.Frame third = controller.request(spec(renderer, 1L));
assertSame(complete, third.tiles().getFirst().tile()); assertSame(complete, third.tiles().getFirst().tile());
assertEquals(2, attempts.get()); assertEquals(2, attempts.get());
@@ -87,10 +109,11 @@ public class VisionRenderControllerTest {
@Test @Test
public void contentRevisionInvalidatesFinalTiles() throws Exception { public void contentRevisionInvalidatesFinalTiles() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile tile = mock(MapTile.class); MapTile tile = mock(MapTile.class);
MapTileRenderer.TileRender work = finished(tile);
MapTileRenderer renderer = renderer(work);
AtomicInteger attempts = new AtomicInteger(); AtomicInteger attempts = new AtomicInteger();
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) when(work.render(any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
attempts.incrementAndGet(); attempts.incrementAndGet();
return tile; return tile;
@@ -109,14 +132,16 @@ public class VisionRenderControllerTest {
MapTileRenderer renderer = mock(MapTileRenderer.class); MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class); MapTile overview = mock(MapTile.class);
MapTile incomplete = 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 -> { .thenAnswer(invocation -> {
double scale = invocation.getArgument(2); double scale = invocation.getArgument(2);
if (scale < 4D) { if (scale < 4D) {
ProvisionalSampling.mark(); return detailWork;
return incomplete;
} }
return overview; return overviewWork;
}); });
try (VisionRenderController controller = new VisionRenderController(() -> { })) { try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer, VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
@@ -142,10 +167,11 @@ public class VisionRenderControllerTest {
@Test @Test
public void closeRejectsLatePublication() throws Exception { 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 entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any())) when(work.render(any()))
.thenAnswer(invocation -> { .thenAnswer(invocation -> {
entered.countDown(); entered.countDown();
while (release.getCount() != 0L) { while (release.getCount() != 0L) {
@@ -167,7 +193,261 @@ public class VisionRenderControllerTest {
controller.close(); controller.close();
} }
await(() -> controller.progress(frame).activeWorkers() == 0); 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) { private static VisionRenderController.RenderSpec spec(MapTileRenderer renderer, long revision) {
@@ -0,0 +1,100 @@
package art.arcane.iris.studio.render;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.water.Reach;
import org.junit.Test;
import java.util.concurrent.CancellationException;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WaterMapProgressTest {
@Test
public void completeDetailedRastersDoNotDrawPlanFallbacks() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
Reach reach = mock(Reach.class);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.thenReturn(new HydrologyPlanPreview(List.of(reach), List.of(), List.of(), List.of(), true));
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
WaterMap map = WaterMap.draw(runtime, 32, 0D, 0D, 1D, () -> false);
assertFalse(map.provisional());
verifyNoInteractions(reach);
}
@Test
public void completedPixelsSurviveRuntimeRasterEviction() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster pond = pond();
AtomicBoolean pending = new AtomicBoolean(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
when(runtime.rasterPreview(0, 0)).thenReturn(pond, ChunkWaterRaster.PROVISIONAL);
when(runtime.rasterPreview(1, 0)).thenAnswer(invocation -> pending.get() ? ChunkWaterRaster.PROVISIONAL : pond);
WaterMap map = new WaterMap(runtime, 32, 0D, 0D, 1D);
map.advance(() -> false);
assertTrue(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertNull(map.at(17, 1));
pending.set(false);
map.advance(() -> false);
assertFalse(map.provisional());
assertEquals(HydrologyFeatureType.POND, map.at(1, 1));
assertEquals(HydrologyFeatureType.POND, map.at(17, 1));
assertNull(map.at(1, 17));
assertTrue(map.bytes() < 16_384L);
verify(runtime, times(1)).rasterPreview(0, 0);
verify(runtime, times(2)).rasterPreview(1, 0);
verify(runtime, times(1)).planPreview(anyInt(), anyInt(), anyInt(), anyInt());
map.advance(() -> false);
verify(runtime, times(2)).rasterPreview(1, 0);
}
@Test
public void cancelledWaterSamplingResumesToTheSameFinalPixels() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
ChunkWaterRaster raster = pond();
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(HydrologyPlanPreview.EMPTY);
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(raster);
AtomicInteger rows = new AtomicInteger();
WaterMap map = new WaterMap(runtime, 32, -16.5D, -16.5D, 0.5D);
assertThrows(CancellationException.class, () -> map.advance(() -> rows.incrementAndGet() > 2));
assertTrue(map.provisional());
map.advance(() -> false);
assertFalse(map.provisional());
WaterMap expected = WaterMap.draw(runtime, 32, -16.5D, -16.5D, 0.5D, () -> false);
for (int z = 0; z < 32; z++) {
for (int x = 0; x < 32; x++) {
assertEquals(expected.at(x, z), map.at(x, z));
}
}
}
private static ChunkWaterRaster pond() {
ChunkWaterRaster raster = mock(ChunkWaterRaster.class);
when(raster.role(anyInt(), anyInt())).thenReturn(WaterRole.POND);
return raster;
}
}
+4
View File
@@ -13,6 +13,10 @@ Configuration kernelDependencies = configurations.create('generationKernelDepend
extendsFrom(configurations.implementation, configurations.slim, configurations.annotationProcessor) extendsFrom(configurations.implementation, configurations.slim, configurations.annotationProcessor)
} }
dependencies.add(kernelDependencies.name, libs.guava) 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<String> nativeRevisionModules = ['native-common', 'native-minecraft26_2-sources'] List<String> nativeRevisionModules = ['native-common', 'native-minecraft26_2-sources']
for (String adapter : rootProject.extensions.extraProperties.get('nativeDevelopmentAdapters')) { for (String adapter : rootProject.extensions.extraProperties.get('nativeDevelopmentAdapters')) {
nativeRevisionModules.add('native-' + adapter) nativeRevisionModules.add('native-' + adapter)
+6
View File
@@ -40,6 +40,12 @@ dependencies {
runtimeOnly(libs.oshi) runtimeOnly(libs.oshi)
runtimeOnly(libs.byteBuddy.core) runtimeOnly(libs.byteBuddy.core)
runtimeOnly(libs.byteBuddy.agent) 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) { studioProbeApi(libs.spigot) {
transitive = false transitive = false
} }