diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index eeacf7fe6..3c268dae5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -133,8 +133,11 @@ import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; import org.bukkit.event.HandlerList; import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; import org.bukkit.generator.BiomeProvider; import org.bukkit.generator.ChunkGenerator; import org.bukkit.plugin.IllegalPluginAccessException; @@ -206,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private volatile PlaceholderRegistration papiRegistration; private volatile IrisPapiListener papiListener; private volatile IrisPapiState papiState; + private volatile boolean papiLifecycleActive; private KMap, IrisService> services; // Copy-on-write: mutated on the main thread during enable() and iterated by the JVM // shutdown-hook thread during teardown; a plain list would CME and abort the teardown. @@ -867,7 +871,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { */ private CompletableFuture loadManagedWorld(String configuredWorldName) { return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName) - .thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message())); + .thenApply(this::managedWorldLoadResult); + } + + private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) { + if (!result.succeeded() && result.failure() != null) { + reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure()); + } + return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()); } public PendingWorldReplacementManager pendingWorldReplacements() { @@ -931,6 +942,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { ServerConfigurator.restartAtStartupBoundary(restartReason); return; } + papiLifecycleActive = true; + setupPapi(); reportLockedRuntime(); } @@ -977,6 +990,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onDisable() { + papiLifecycleActive = false; if (updateNotifier != null) { updateNotifier.close(); updateNotifier = null; @@ -1020,6 +1034,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { @Override public CompletionStage commitReload(ReloadAware.PreUnloadReason reason) { + papiLifecycleActive = false; teardownPapi(); if (IrisToolbelt.isServerStopping()) { quiesceRuntimeForServerShutdown("pre-unload:" + reason); @@ -1322,8 +1337,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - private void setupPapi() { - if (!PlaceholderRegistration.isPlaceholderApiEnabled()) { + @EventHandler + public void onPlaceholderApiEnable(PluginEnableEvent event) { + if ("PlaceholderAPI".equals(event.getPlugin().getName())) { + setupPapi(); + } + } + + @EventHandler + public void onPlaceholderApiDisable(PluginDisableEvent event) { + if ("PlaceholderAPI".equals(event.getPlugin().getName())) { + teardownPapi(); + } + } + + private synchronized void setupPapi() { + if (!papiLifecycleActive || papiRegistration != null + || !PlaceholderRegistration.isPlaceholderApiEnabled()) { return; } @@ -1350,7 +1380,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { papiRegistration = registration; } - private void teardownPapi() { + private synchronized void teardownPapi() { IrisPapiListener listener = papiListener; papiListener = null; diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index 7e8138ace..2e6bceb4a 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -28,34 +28,26 @@ permissions: dependencies: server: PlaceholderAPI: - load: BEFORE + load: OMIT required: false join-classpath: true CraftEngine: - load: BEFORE + load: OMIT required: false join-classpath: true Nexo: - load: BEFORE + load: OMIT required: false join-classpath: true Oraxen: - load: BEFORE + load: OMIT required: false join-classpath: true ItemsAdder: - load: BEFORE + load: OMIT required: false join-classpath: true SCore: - load: BEFORE - required: false - join-classpath: true - ExecutableItems: - load: BEFORE - required: false - join-classpath: false - MythicLib: load: OMIT required: false join-classpath: true @@ -64,11 +56,7 @@ dependencies: required: false join-classpath: true eco: - load: BEFORE - required: false - join-classpath: true - EcoItems: - load: BEFORE + load: OMIT required: false join-classpath: true MythicMobs: @@ -80,7 +68,7 @@ dependencies: required: false join-classpath: true KGenerators: - load: BEFORE + load: OMIT required: false join-classpath: true Multiverse-Core: @@ -88,6 +76,6 @@ dependencies: required: false join-classpath: true WorldEdit: - load: BEFORE + load: OMIT required: false join-classpath: true diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index ad1509e1d..d92da834f 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -3,18 +3,6 @@ version: ${version} main: ${main} folia-supported: true load: STARTUP -softdepend: - - PlaceholderAPI - - CraftEngine - - Nexo - - Oraxen - - ItemsAdder - - SCore - - ExecutableItems - - eco - - EcoItems - - KGenerators - - WorldEdit loadbefore: [ Multiverse-Core ] authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: VolmitSoftware.com diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-hotload-lifecycle.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-hotload-lifecycle.mjs new file mode 100644 index 000000000..46ddd04c6 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-hotload-lifecycle.mjs @@ -0,0 +1,71 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson, + logPosition, logSince } from './studio-support.mjs' + +export default { + name: 'iris-studio-hotload-lifecycle', + description: 'Generate cold chunks during Studio edits and close after saving an edit.', + async run(context) { + const fixture = await setup(context) + const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json') + const original = await readFile(biomeFile, 'utf8') + const logOffset = await logPosition(fixture) + let active + let x = 16384 + try { + active = await openStudio(context, fixture) + await sampleTerrain(context, fixture, 0, fixture.state.material) + for (let cycle = 0; cycle < 3; cycle++) { + await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => { + const manifestFile = fixture.manifestPath(active.key) + const before = await readJson(manifestFile) + const biome = await readJson(biomeFile) + const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block' + biome.name = `Lifecycle terrain ${cycle + 1}` + biome.layers[0].palette[0].block = `minecraft:${material}` + const startedAt = Date.now() + await writeJson(biomeFile, biome) + x += 512 + await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000) + const after = await fixture.wait(async () => { + const manifest = await readJson(manifestFile) + return manifest.activeActivationId !== before.activeActivationId ? manifest : false + }, 'Hotload did not complete while the player requested fresh chunks', 90000) + const activationDurationMs = Date.now() - startedAt + await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1, + 'Cold chunk teleport did not arrive after hotload', 30000) + context.report.iris.activations.push({ before: before.activeActivationId, + after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs }) + x += 2048 + await sampleTerrain(context, fixture, x, material) + }) + } + await context.step('Close Studio while a saved edit is activating', async () => { + const biome = await readJson(biomeFile) + biome.name = 'Lifecycle close terrain' + await writeJson(biomeFile, biome) + const pending = await fixture.wait(async () => { + const manifest = await readJson(fixture.manifestPath(active.key)) + return manifest.pendingActivationId !== null ? manifest : false + }, 'The saved close edit did not enter activation', 90000) + context.report.iris.closePendingActivationId = pending.pendingActivationId + await closeStudio(context, fixture, active) + active = undefined + await context.command('/minecraft:seed', /Seed:/i, 10000) + }) + await context.step('Reopen the edited pack and retain usable generation', async () => { + active = await openStudio(context, fixture) + await sampleTerrain(context, fixture, 0, 'gold_block') + await closeStudio(context, fixture, active) + active = undefined + }) + const log = await logSince(fixture, logOffset) + context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log), + 'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure') + } finally { + if (active) await closeStudio(context, fixture, active) + await writeFile(biomeFile, original) + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index 44034556a..8ee328b2c 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class PaperPluginMetadataTest { - private static final List OPTIONAL_PLUGIN_IDS = List.of( + private static final List JOINED_PLUGIN_IDS = List.of( "PlaceholderAPI", "CraftEngine", "Nexo", "Oraxen", "ItemsAdder", "SCore", - "ExecutableItems", - "MythicLib", "MMOItems", "eco", - "EcoItems", "MythicMobs", "MythicCrucible", "KGenerators", - "Multiverse-Core", "WorldEdit" ); - private static final List JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream() - .filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId)) - .toList(); - private static final List UNORDERED_PLUGIN_IDS = List.of( - "MythicLib", "MMOItems", "MythicMobs", "MythicCrucible"); - private static final List BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream() - .filter(pluginId -> !"Multiverse-Core".equals(pluginId)) - .filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId)) - .toList(); @Test public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception { @@ -75,10 +62,13 @@ public class PaperPluginMetadataTest { + " description: Allows survival players to fell Iris-managed trees with an axe.\n" + " default: op")); for (String pluginId : JOINED_PLUGIN_IDS) { - String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE"; - assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true))); + assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true))); } - assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false))); + assertFalse(metadata.contains(" ExecutableItems:")); + assertFalse(metadata.contains(" EcoItems:")); + assertFalse(metadata.contains(" MythicLib:")); + assertEquals(1, metadata.lines().filter(line -> line.equals(" load: AFTER")).count()); + assertEquals(0, metadata.lines().filter(line -> line.equals(" load: BEFORE")).count()); assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true))); } @@ -95,7 +85,7 @@ public class PaperPluginMetadataTest { Map> commands = metadata.getCommands(); assertTrue(commands.containsKey("iris")); assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases")); - assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend()); + assertTrue(metadata.getSoftDepend().isEmpty()); assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins()); Permission all = metadata.getPermissions().stream() .filter(permission -> "iris.all".equals(permission.getName())) diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiLifecycleTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiLifecycleTest.java new file mode 100644 index 000000000..2c87939ea --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/integration/IrisPapiLifecycleTest.java @@ -0,0 +1,103 @@ +package art.arcane.iris.integration; + +import art.arcane.iris.Iris; +import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.logging.Logger; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisPapiLifecycleTest { + @Test + public void lateEnableRegistersOnceAndReattachesAfterDisable() throws Exception { + Iris iris = plugin(true); + Plugin placeholders = namedPlugin("PlaceholderAPI"); + PluginManager plugins = mock(PluginManager.class); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic handlers = mockStatic(HandlerList.class); + MockedStatic availability = mockStatic(PlaceholderRegistration.class); + MockedStatic installer = mockStatic(IrisPapiInstaller.class); + MockedConstruction registrations = mockConstruction(PlaceholderRegistration.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + availability.when(PlaceholderRegistration::isPlaceholderApiEnabled).thenReturn(true); + installer.when(() -> IrisPapiInstaller.install(any(), any(), any())).thenReturn(true); + + iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders)); + iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders)); + assertEquals(1, registrations.constructed().size()); + verify(plugins).registerEvents(any(IrisPapiListener.class), eq(iris)); + + iris.onPlaceholderApiDisable(new PluginDisableEvent(namedPlugin("Unrelated"))); + verify(registrations.constructed().getFirst(), never()).unregister(); + + iris.onPlaceholderApiDisable(new PluginDisableEvent(placeholders)); + verify(registrations.constructed().getFirst()).unregister(); + handlers.verify(() -> HandlerList.unregisterAll(any(IrisPapiListener.class))); + + iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders)); + assertEquals(2, registrations.constructed().size()); + verify(plugins, times(2)).registerEvents(any(IrisPapiListener.class), eq(iris)); + } + } + + @Test + public void enableEventsCannotRegisterBeforeIrisIsReadyOrDuringShutdown() throws Exception { + Iris iris = plugin(false); + Plugin placeholders = namedPlugin("PlaceholderAPI"); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic installer = mockStatic(IrisPapiInstaller.class)) { + iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders)); + installer.verifyNoInteractions(); + } + } + + @Test + public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception { + Iris iris = plugin(true); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic installer = mockStatic(IrisPapiInstaller.class)) { + iris.onPlaceholderApiEnable(new PluginEnableEvent(namedPlugin("Unrelated"))); + installer.verifyNoInteractions(); + } + } + + private static Iris plugin(boolean active) throws ReflectiveOperationException { + Iris iris = mock(Iris.class); + when(iris.getLogger()).thenReturn(Logger.getAnonymousLogger()); + doCallRealMethod().when(iris).onPlaceholderApiEnable(any()); + doCallRealMethod().when(iris).onPlaceholderApiDisable(any()); + Field lifecycle = Iris.class.getDeclaredField("papiLifecycleActive"); + lifecycle.setAccessible(true); + lifecycle.setBoolean(iris, active); + return iris; + } + + private static Plugin namedPlugin(String name) { + Plugin plugin = mock(Plugin.class); + when(plugin.getName()).thenReturn(name); + return plugin; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index f61043f5a..513175cd9 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.world.pregen.MantleHeapPressure; import art.arcane.iris.world.pregen.PregenListener; import art.arcane.iris.world.pregen.PregenMantleBackpressure; import art.arcane.iris.world.pregen.PregeneratorMethod; @@ -74,8 +75,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod { sync = configuration.sync(); pauseGuard = new PauseWhenEmptyGuard(runtime); IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); - this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight()); - this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4)); + this.maxInFlight = memoryLimitedConcurrency(pregen.getModdedPregenInFlight(), MantleHeapPressure.memoryBudgetBytes()); + this.minInFlight = Math.min(maxInFlight, Math.max(4, Math.min(16, maxInFlight / 4))); this.semaphore = new Semaphore(maxInFlight, true); this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight); this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds()); @@ -86,7 +87,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod { pregen.getMantleBackpressureTimeoutMs(), () -> { }, - () -> "dim=" + runtime.worldIdentity()); + () -> "dim=" + runtime.worldIdentity(), + this::admissionCancelled); + } + + static int memoryLimitedConcurrency(int configuredLimit, long memoryBudgetBytes) { + long memoryLimit = Math.max(1L, memoryBudgetBytes / (256L * 1024L * 1024L)); + return (int) Math.min(Math.max(1, configuredLimit), memoryLimit); } @Override @@ -359,12 +366,26 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } listener.onChunkGenerating(x, z); try { - synchronized (permitMonitor) { - while (inFlight.get() >= adaptiveLimit.get()) { - permitMonitor.wait(500L); + while (true) { + synchronized (permitMonitor) { + while (inFlight.get() >= adaptiveLimit.get()) { + if (admissionCancelled()) { + return; + } + permitMonitor.wait(500L); + } } + semaphore.acquire(); + if (admissionCancelled()) { + semaphore.release(); + return; + } + if (!MantleHeapPressure.overHighWater()) { + break; + } + semaphore.release(); + backpressure.awaitHeapHeadroom(); } - semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; @@ -400,6 +421,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod { }); } + private boolean admissionCancelled() { + return Thread.currentThread().isInterrupted() || !runtime.isRunning(); + } + private void markSubmitted() { int current = inFlight.incrementAndGet(); inFlightPeak.accumulateAndGet(current, Math::max); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodMemoryTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodMemoryTest.java new file mode 100644 index 000000000..2275ebe7d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedPregenMethodMemoryTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.world.pregen.MantleHeapPressure; +import art.arcane.iris.world.pregen.PregenListener; +import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; +import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedPregenMethodMemoryTest { + @Test + public void configuredLimitIsBoundedByMemoryWithoutRaisingSmallerRequests() { + assertEquals(4, ModdedPregenMethod.memoryLimitedConcurrency(512, 1L << 30)); + assertEquals(2, ModdedPregenMethod.memoryLimitedConcurrency(2, 1L << 30)); + assertEquals(1, ModdedPregenMethod.memoryLimitedConcurrency(512, 128L << 20)); + } + + @Test + public void smallMemoryBudgetAndRisingPressureBlockRequestsUntilHeadroomReturns() throws Exception { + IrisSettings configured = new IrisSettings(); + configured.getPregen().setModdedPregenInFlight(512); + NativePregenRuntime runtime = mock(NativePregenRuntime.class); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + PregenListener listener = mock(PregenListener.class); + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + when(runtime.isRunning()).thenReturn(true); + when(runtime.loadChunk(0, 0)).thenReturn(first); + when(runtime.loadChunk(1, 0)).thenReturn(second); + when(engine.getHeight()).thenReturn(384); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (MockedStatic settings = mockStatic(IrisSettings.class); + MockedStatic pressure = mockStatic(MantleHeapPressure.class)) { + settings.when(IrisSettings::get).thenReturn(configured); + pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(256L << 20); + ModdedPregenMethod method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); + method.generateChunk(0, 0, listener); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch pressureObserved = new CountDownLatch(1); + AtomicBoolean pressured = new AtomicBoolean(); + Future pending = executor.submit(() -> { + try (MockedStatic workerPressure = mockStatic(MantleHeapPressure.class)) { + workerPressure.when(MantleHeapPressure::overHighWater).thenAnswer(call -> { + if (pressured.get()) { + pressureObserved.countDown(); + return true; + } + return false; + }); + started.countDown(); + method.generateChunk(1, 0, listener); + } + }); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> pending.get(100L, TimeUnit.MILLISECONDS)); + verify(runtime, never()).loadChunk(1, 0); + pressured.set(true); + first.complete(new ChunkLoad(true, null)); + assertTrue(pressureObserved.await(5L, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> pending.get(100L, TimeUnit.MILLISECONDS)); + verify(runtime, never()).loadChunk(1, 0); + pressured.set(false); + pending.get(5L, TimeUnit.SECONDS); + verify(runtime).loadChunk(1, 0); + second.complete(new ChunkLoad(true, null)); + verify(listener).onChunkGenerated(0, 0); + verify(listener).onChunkGenerated(1, 0); + } finally { + first.complete(new ChunkLoad(true, null)); + second.complete(new ChunkLoad(true, null)); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java index 9a11ca66e..bdf114107 100644 --- a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java @@ -18,6 +18,7 @@ package art.arcane.iris.configuration; +import art.arcane.iris.world.pregen.MantleHeapPressure; import art.arcane.iris.world.IrisPaperLikeBackendMode; import art.arcane.iris.world.IrisRuntimeSchedulerMode; @@ -275,7 +276,7 @@ public class IrisSettings { } public int getEffectiveResidentTectonicPlates(int worldHeight) { - return effectiveResidentTectonicPlates(worldHeight, Runtime.getRuntime().maxMemory()); + return effectiveResidentTectonicPlates(worldHeight, MantleHeapPressure.memoryBudgetBytes()); } int effectiveResidentTectonicPlates(int worldHeight, long maximumHeapBytes) { @@ -398,6 +399,7 @@ public class IrisSettings { @Data public static class IrisSettingsStudio { public boolean openVSCode = true; + public boolean blendChanges = true; public boolean disableTimeAndWeather = true; public boolean entitySpawning = true; public boolean autoStartDefaultStudio = false; diff --git a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java index 62270dc8a..bcb02f8dc 100644 --- a/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/generation/context/ChunkContext.java @@ -11,6 +11,7 @@ import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.mantle.CaveTerrainSnapshot; import art.arcane.iris.world.history.FloatingBiomeOverlay; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.util.hunk.Hunk; @@ -25,6 +26,7 @@ public class ChunkContext { private Hunk terrainBiomes; private FloatingBiomeOverlay floatingBiomes; private CaveTerrainSnapshot caveTerrain; + private TransitionTerrainColumn[] transitionTerrain; private final int x; private final int z; private final IrisComplex complex; @@ -167,6 +169,17 @@ public class ChunkContext { this.caveTerrain = caveTerrain; } + public void setTransitionTerrain(int localX, int localZ, Terrain3DColumn solids, int fluidHeight) { + if (transitionTerrain == null) { + transitionTerrain = new TransitionTerrainColumn[256]; + } + transitionTerrain[(localZ << 4) | localX] = new TransitionTerrainColumn(solids, fluidHeight); + } + + public TransitionTerrainColumn getTransitionTerrain(int localX, int localZ) { + return transitionTerrain == null ? null : transitionTerrain[(localZ << 4) | localX]; + } + public void setNaturalBiome(int localX, int y, int localZ, NativeBiome biome) { if (terrainBiomes != null) { terrainBiomes.setRaw(localX, y, localZ, biome); @@ -280,6 +293,9 @@ public class ChunkContext { } } + public record TransitionTerrainColumn(Terrain3DColumn solids, int fluidHeight) { + } + private static final class PrefillFillTask implements Runnable { private final ChunkedDataCache dataCache; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java index 25a2599c9..0ab2296d6 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSurfaceDecorator.java @@ -20,6 +20,8 @@ package art.arcane.iris.generation.decoration; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.volmlib.util.hunk.Hunk; @@ -60,7 +62,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { @BlockCoordinates public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, InferredType inferredType, int height, int max) { - int fluidHeight = getEngine().getMantle().getFluidHeight(realX, realZ); + int fluidHeight = surfaceFluidHeight(realX, realZ); if (inferredType == InferredType.SHORE && height < fluidHeight) { return; } @@ -100,6 +102,21 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { return height < fluidHeight && inferredType != InferredType.CAVE; } + int surfaceFluidHeight(int worldX, int worldZ) { + IrisContext current = IrisContext.get(); + ChunkContext chunk = current == null ? null : current.getChunkContext(); + if (chunk != null && current.getEngine() == getEngine() + && current.getGenerationSessionId() == getEngine().getGenerationSessionId() + && current.getGenerationSessionId() == chunk.getGenerationSessionId() + && worldX >> 4 == chunk.getX() >> 4 && worldZ >> 4 == chunk.getZ() >> 4) { + ChunkContext.TransitionTerrainColumn transition = chunk.getTransitionTerrain(worldX & 15, worldZ & 15); + if (transition != null) { + return transition.fluidHeight(); + } + } + return getEngine().getMantle().getFluidHeight(worldX, worldZ); + } + static boolean skipsFluid(InferredType inferredType) { return inferredType == InferredType.CAVE; } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java index b8202333e..05d704260 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java @@ -35,6 +35,8 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.generation.context.IrisContext; +import java.util.concurrent.TimeUnit; + import static art.arcane.iris.generation.runtime.EngineShutdownSequence.runCleanup; /** @@ -224,12 +226,14 @@ final class EngineHotloader { StudioGenerationUpdate update = null; boolean backgroundAdmissionClosed = false; try { + long startedAt = System.nanoTime(); engine.requireRunning("hotload the Studio pack"); GenerationHistory history = router.history(); update = StudioGenerationUpdate.prepare(engine, history); if (update == null) { return; } + long preparedAt = System.nanoTime(); engine.awaitNativeStructureBootstrap("Studio generation cutover"); engine.backgroundTasks.closeBackgroundTaskAdmission(); backgroundAdmissionClosed = true; @@ -243,15 +247,27 @@ final class EngineHotloader { engine.lifecycleState = LifecycleState.HOTLOADING; } engine.sealForTransition("Studio generation cutover", false); + long drainedAt = System.nanoTime(); IrisBoundarySignatureSampler.checkpoint(engine); + long checkpointedAt = System.nanoTime(); synchronized (engine.lifecycleLock) { if (engine.lifecycleState != LifecycleState.HOTLOADING || engine.runtime != previous) { throw new IllegalStateException("Studio engine changed while its generation cutover was draining."); } engine.prepareRuntimeHotload(); + IrisSettings settings = IrisSettings.get(); + int transitionWidth = settings.getStudio().isBlendChanges() + ? settings.getGenerator().getGenerationTransitionWidthBlocks() : 0; history.stageUpdate(update.source(), update.fingerprint(), update.dimensionContract(), - update.registryContract(), IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - cutover.promotePending(); + update.registryContract(), transitionWidth); + } + long stagedAt = System.nanoTime(); + cutover.promotePending(); + long promotedAt = System.nanoTime(); + synchronized (engine.lifecycleLock) { + if (engine.lifecycleState != LifecycleState.HOTLOADING) { + throw new IllegalStateException("Studio engine changed while its generation cutover was publishing."); + } IrisStructureLocator.invalidate(engine); StructureReachability.invalidate(engine); engine.runtimeBuilder.refreshStudioRuntimeServices(); @@ -259,19 +275,27 @@ final class EngineHotloader { engine.getEngineData().getStatistics().hotloaded(); engine.getPlatformHooks().applyWorldBoundary(engine); } + long completedAt = System.nanoTime(); + IrisLogging.debug("Studio update timing: world=%s total=%dms prepare=%dms drain=%dms checkpoint=%dms stage=%dms activation=%dms refresh=%dms", + engine.getWorld().name(), elapsedMillis(startedAt, completedAt), + elapsedMillis(startedAt, preparedAt), elapsedMillis(preparedAt, drainedAt), + elapsedMillis(drainedAt, checkpointedAt), elapsedMillis(checkpointedAt, stagedAt), + elapsedMillis(stagedAt, promotedAt), elapsedMillis(promotedAt, completedAt)); broadcastStudioHotload(false, ""); } catch (Throwable failure) { if (previous != null) { synchronized (engine.lifecycleLock) { if (engine.lifecycleState == LifecycleState.HOTLOADING || engine.lifecycleState == LifecycleState.RUNNING) { - boolean unchanged = false; + boolean activeGenerationUnchanged = false; try { - unchanged = router.history().manifest() == previousManifest; + GenerationManifest currentManifest = router.history().manifest(); + activeGenerationUnchanged = currentManifest.activeActivation().activationId() + == previousManifest.activeActivation().activationId(); } catch (Throwable historyFailure) { failure.addSuppressed(historyFailure); } - if (unchanged) { + if (activeGenerationUnchanged) { engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous); } else { engine.lifecycleState = LifecycleState.FAILED; @@ -305,6 +329,10 @@ final class EngineHotloader { } } + private static long elapsedMillis(long startedAt, long completedAt) { + return TimeUnit.NANOSECONDS.toMillis(completedAt - startedAt); + } + private void requireMutableGenerationRuntime(String operation) { if (engine.getGenerationHistoryRuntimeRouter().isPresent()) { throw new IllegalStateException("Cannot " + operation diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index 8f5bd62e9..97a1f2ec9 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -995,7 +995,7 @@ public class IrisCarveModifier extends EngineAssignedModifier zone.setCeiling(y - 1); if (zone.isValid(resolver.height) && (subterrainCell(worldX, floor, worldZ).occupied() - || !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ))) { + || !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ, context))) { if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) { mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING); } @@ -1112,6 +1112,17 @@ public class IrisCarveModifier extends EngineAssignedModifier && !getEngine().isAdditionalTerrainOwned(x, y, z); } + private boolean isNaturalTerrainOpening(IrisComplex complex, int x, int y, int z, ChunkContext context) { + ChunkContext.TransitionTerrainColumn transition = context == null ? null + : context.getTransitionTerrain(x & 15, z & 15); + if (transition == null) { + return isNaturalTerrainOpening(complex, x, y, z); + } + Terrain3DColumn natural = complex.terrainColumn(x, z, complex.sampleHydrologyColumn(x, z)); + return natural != null && IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, y) + && !getEngine().isAdditionalTerrainOwned(x, y, z); + } + /** The dimension's underground river bed palette, or null when nothing paints. */ private IrisRiverMaterialConfig undergroundBedMaterial() { IrisHydrology hydrology = getDimension().getHydrology(); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisDecorantActuator.java index 657b04dfb..cd58229fa 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisDecorantActuator.java @@ -174,8 +174,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator output, Terrain3DColumn column, + ChunkContext.TransitionTerrainColumn transition, IrisBiome biome, int localX, int localZ, int worldX, int worldZ) { - for (int span = 0; span + 1 < column.spanCount(); span++) { - int floorY = column.floor(span); - int ceilingY = column.ceiling(span + 1); + Terrain3DColumn ledges = transition == null ? column : transition.solids(); + for (int span = 0; span + 1 < ledges.spanCount(); span++) { + int floorY = ledges.floor(span); + int ceilingY = ledges.ceiling(span + 1); + if (transition != null && resolveTerrainLedge(column, span, transition) == null) { + continue; + } int headroom = ceilingY - floorY - 1; if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) { continue; @@ -280,6 +286,37 @@ public class IrisDecorantActuator extends EngineAssignedActuator blended.floor(span) && y < blended.ceiling(span + 1)) { + return resolveTerrainLedge(natural, span, transition) != null; + } + } + return false; + } + + record TerrainLedge(int floorY, int ceilingY) { + } + enum ShorelineDecorationMode { NONE, LEGACY, diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java index ef7f5143f..e62344077 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java @@ -3,6 +3,7 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.EngineAssignedComponent; import art.arcane.iris.generation.runtime.EngineStage; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.SavedTerrainChunk; @@ -85,6 +86,17 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen blocks.setRaw(localX, offset, localZ, replacement); } context.setTerrainHeight(localX, localZ, floor); + int fluidHeight = floor; + while (fluidHeight + 1 < voxels.size() + && voxels.get(fluidHeight + 1).phase() == BoundaryColumnGeometry.Phase.FLUID) { + fluidHeight++; + } + if (fluidHeight == floor) { + fluidHeight = -1; + } + context.setTransitionTerrain(localX, localZ, Terrain3DColumn.fromOccupancy( + floor, voxels.size(), offset -> voxels.get(offset).phase() == BoundaryColumnGeometry.Phase.SOLID), + fluidHeight); } } } diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index 95614dfc4..295621ed3 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -149,6 +149,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final AtomicReference> watcherHotload = new AtomicReference<>(); private volatile Engine engine; private volatile Looper hotloader; + private volatile boolean studioUpdatesPaused; private volatile StudioMode lastMode; private volatile UUID lastJigsawStudioRequestId; private volatile boolean jigsawStudioActive; @@ -978,6 +979,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return future; } + @Override + public CompletableFuture quiesceStudioUpdatesAsync() { + studioUpdatesPaused = true; + cancelWatcherHotload(); + Looper activeHotloader = hotloader; + hotloader = null; + if (activeHotloader != null) { + activeHotloader.interrupt(); + } + return withExclusiveControlFuture(() -> { }); + } + @Override public void quiesceForServerShutdown() { cancelWatcherHotload(); @@ -1071,26 +1084,33 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void hotload() { - if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } - withExclusiveControl(() -> getEngine().hotload()); + withExclusiveControl(() -> { + if (!studioUpdatesPaused && !closing) { + getEngine().hotload(); + } + }); } private void hotloadFromWatcher() { - if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } CompletableFuture pending = new CompletableFuture<>(); + long queuedAt = System.nanoTime(); watcherHotload.set(pending); try { - if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) + if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) || Thread.currentThread().isInterrupted()) { pending.cancel(true); } else { J.a(() -> completeExclusiveControlFuture(loadLock, () -> { - if (!initialEntryPending && shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (!initialEntryPending && !studioUpdatesPaused && shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + IrisLogging.debug("Studio update admission: world=%s wait=%dms", + getTarget().getWorld().name(), TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - queuedAt)); getEngine().hotload(); } }, pending)); diff --git a/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java index 82e0c6520..23ba05a1c 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java @@ -66,6 +66,8 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { } } + CompletableFuture quiesceStudioUpdatesAsync(); + default void quiesceForServerShutdown() { } diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java index c3cd6daf1..9a0c0c9a7 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java @@ -483,6 +483,7 @@ public final class StudioOpenCoordinator { IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); } }, + () -> provider == null ? CompletableFuture.completedFuture(null) : provider.quiesceStudioUpdatesAsync(), () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> deleteFolder @@ -516,6 +517,7 @@ public final class StudioOpenCoordinator { static CompletableFuture sequenceStudioClose( Supplier> evacuate, Runnable enterMaintenance, + Supplier> quiesceUpdates, Supplier> unload, Supplier> closeGenerator, Supplier> deleteFolders @@ -523,8 +525,9 @@ public final class StudioOpenCoordinator { return invokePhase(evacuate) .thenCompose(ignored -> { enterMaintenance.run(); - return invokePhase(unload); + return invokePhase(quiesceUpdates); }) + .thenCompose(ignored -> invokePhase(unload)) .thenCompose(ignored -> invokePhase(closeGenerator)) .thenCompose(ignored -> invokePhase(deleteFolders)); } diff --git a/core/src/main/java/art/arcane/iris/studio/workspace/IrisProject.java b/core/src/main/java/art/arcane/iris/studio/workspace/IrisProject.java index 638c7ee16..624c47c1d 100644 --- a/core/src/main/java/art/arcane/iris/studio/workspace/IrisProject.java +++ b/core/src/main/java/art/arcane/iris/studio/workspace/IrisProject.java @@ -20,12 +20,10 @@ package art.arcane.iris.studio.workspace; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.studio.object.StudioOpenCoordinator; -import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; -import art.arcane.iris.world.task.J; import lombok.Data; import org.bukkit.World; @@ -136,8 +134,6 @@ public class IrisProject { ); StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed); future.whenComplete((result, throwable) -> { - World maintenanceWorld = null; - boolean maintenanceActive = false; try { if (throwable != null) { failed.set(true); @@ -148,17 +144,7 @@ public class IrisProject { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_STUDIO_OPEN_FAILED, MessageArgument.untrusted("error", String.valueOf(error.getMessage())))); return; } - - maintenanceWorld = result.world(); - if (maintenanceWorld != null) { - IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open"); - maintenanceActive = true; - } } finally { - if (maintenanceActive && maintenanceWorld != null) { - World worldToRelease = maintenanceWorld; - J.a(() -> IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"), 300); - } complete.set(true); } }); diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java index 69e8ef12c..1a52b69b2 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java @@ -365,13 +365,12 @@ public class IrisCreator { worldKey, IrisWorldStorage.levelRoot().getName() ); - BukkitWorldConfiguration.register( + bukkitRegistered = BukkitWorldConfiguration.register( BUKKIT_YML, registrationName, dimension, seed - ); - bukkitRegistered = true; + ) == BukkitWorldConfiguration.Registration.CREATED; World createdWorld = world; CompletableFuture multiverseRegistration = J.sfut( () -> IrisServices.get(MultiverseCoreLink.class) diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationTransition.java b/core/src/main/java/art/arcane/iris/world/history/GenerationTransition.java index 57743cbc7..ce3ed3939 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationTransition.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationTransition.java @@ -22,8 +22,8 @@ public record GenerationTransition( if (algorithmVersion < 1) { throw new IllegalArgumentException("Transition algorithm version must be positive."); } - if (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS) { - throw new IllegalArgumentException("Transition width must be between " + if (widthBlocks != 0 && (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS)) { + throw new IllegalArgumentException("Transition width must be zero or between " + MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks."); } if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) { diff --git a/core/src/main/java/art/arcane/iris/world/history/IrisBoundarySignatureSampler.java b/core/src/main/java/art/arcane/iris/world/history/IrisBoundarySignatureSampler.java index 5f0848385..547edb955 100644 --- a/core/src/main/java/art/arcane/iris/world/history/IrisBoundarySignatureSampler.java +++ b/core/src/main/java/art/arcane/iris/world/history/IrisBoundarySignatureSampler.java @@ -4,7 +4,11 @@ import art.arcane.iris.generation.runtime.IrisEngine; import java.io.IOException; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -47,43 +51,69 @@ public final class IrisBoundarySignatureSampler implements GenerationBoundarySig private static final class LiveCapture implements TerrainBoundarySignatureStore.SignatureSampler { private final IrisEngine engine; - private final LinkedHashMap chunks = new LinkedHashMap<>(32, 0.75F, true); + private final LinkedHashMap> chunks = new LinkedHashMap<>(32, 0.75F, true); private LiveCapture(IrisEngine engine) { this.engine = engine; } + @Override + public void prepareChunks(List coordinates) { + if (coordinates.size() > MAXIMUM_CACHED_CHUNKS) { + throw new IllegalArgumentException("Terrain capture batches cannot exceed 32 chunks."); + } + try { + for (GenerationBoundary.ChunkCoordinate coordinate : coordinates) { + captureChunk(coordinate.chunkX(), coordinate.chunkZ()); + } + } catch (RuntimeException | Error failure) { + cancelPendingCaptures(); + throw failure; + } + } + @Override public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException { int chunkX = Math.floorDiv(blockX, 16); int chunkZ = Math.floorDiv(blockZ, 16); - long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ); - SavedTerrainChunk terrain = chunks.get(key); - if (terrain == null) { - terrain = captureChunk(chunkX, chunkZ); - chunks.put(key, terrain); - if (chunks.size() > MAXIMUM_CACHED_CHUNKS) { - Map.Entry oldest = chunks.firstEntry(); - chunks.remove(oldest.getKey()); - } - } - return terrain.column(blockX, blockZ); - } - - private SavedTerrainChunk captureChunk(int chunkX, int chunkZ) throws IOException { try { - return engine.getPlatformHooks().captureSavedTerrainChunk(engine, chunkX, chunkZ) - .get(30, TimeUnit.SECONDS); + return captureChunk(chunkX, chunkZ).get(30, TimeUnit.SECONDS).column(blockX, blockZ); } catch (InterruptedException failure) { + cancelPendingCaptures(); Thread.currentThread().interrupt(); throw new IOException("Interrupted while capturing terrain at " + chunkX + "," + chunkZ, failure); - } catch (ExecutionException | TimeoutException failure) { + } catch (ExecutionException | TimeoutException | CancellationException failure) { + cancelPendingCaptures(); throw new IOException("Unable to capture terrain at " + chunkX + "," + chunkZ, failure); } } + private CompletableFuture captureChunk(int chunkX, int chunkZ) { + long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ); + CompletableFuture terrain = chunks.get(key); + if (terrain != null) { + return terrain; + } + terrain = Objects.requireNonNull(engine.getPlatformHooks().captureSavedTerrainChunk(engine, chunkX, chunkZ), + "Terrain capture future"); + chunks.put(key, terrain); + if (chunks.size() > MAXIMUM_CACHED_CHUNKS) { + Map.Entry> oldest = chunks.firstEntry(); + chunks.remove(oldest.getKey()); + oldest.getValue().cancel(false); + } + return terrain; + } + + private void cancelPendingCaptures() { + for (CompletableFuture capture : chunks.values()) { + capture.cancel(false); + } + } + @Override public void close() throws IOException { + cancelPendingCaptures(); try { checkpoint(engine); } finally { diff --git a/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java b/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java index 8088aa2f6..93dcd09e0 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/TerrainBoundarySignatureStore.java @@ -66,6 +66,7 @@ public final class TerrainBoundarySignatureStore { private static final int SHARD_FIXED_BODY_BYTES = 12; private static final int CHECKSUM_BYTES = Integer.BYTES; private static final int CELL_SIZE = 32; + private static final int PREPARED_CELL_WINDOW = 8; private static final int SUPER_CELL_SIZE = 32; private static final int MAXIMUM_CACHED_SHARDS = 64; private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE; @@ -148,24 +149,34 @@ public final class TerrainBoundarySignatureStore { LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates); Long2ObjectOpenHashMap cells = new Long2ObjectOpenHashMap<>(cellKeys.length); int count = 0; - for (long cellKey : cellKeys) { - BitSet mask = columns.remove(cellKey); - List signatures = new ArrayList<>(mask.cardinality()); - for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) { - int blockX = Math.addExact(Math.multiplyExact(cellX(cellKey), CELL_SIZE), index / CELL_SIZE); - int blockZ = Math.addExact(Math.multiplyExact(cellZ(cellKey), CELL_SIZE), index % CELL_SIZE); - TerrainBoundarySignature signature = Objects.requireNonNull( - sampler.sample(blockX, blockZ), "boundary signature" - ); - if (signature.blockX() != blockX || signature.blockZ() != blockZ) { - throw new IOException("Boundary signature coordinates changed while sampling " - + blockX + "," + blockZ + "."); + for (int offset = 0; offset < cellKeys.length; offset += PREPARED_CELL_WINDOW) { + int end = Math.min(cellKeys.length, offset + PREPARED_CELL_WINDOW); + List chunks = new ArrayList<>((end - offset) * 4); + for (int index = offset; index < end; index++) { + long cellKey = cellKeys[index]; + addSampledChunks(cellKey, columns.get(cellKey), chunks); + } + sampler.prepareChunks(List.copyOf(chunks)); + for (int cellIndex = offset; cellIndex < end; cellIndex++) { + long cellKey = cellKeys[cellIndex]; + BitSet mask = columns.remove(cellKey); + List signatures = new ArrayList<>(mask.cardinality()); + for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) { + int blockX = Math.addExact(Math.multiplyExact(cellX(cellKey), CELL_SIZE), index / CELL_SIZE); + int blockZ = Math.addExact(Math.multiplyExact(cellZ(cellKey), CELL_SIZE), index % CELL_SIZE); + TerrainBoundarySignature signature = Objects.requireNonNull( + sampler.sample(blockX, blockZ), "boundary signature" + ); + if (signature.blockX() != blockX || signature.blockZ() != blockZ) { + throw new IOException("Boundary signature coordinates changed while sampling " + + blockX + "," + blockZ + "."); + } + signatures.add(canonicalizeSignature(signature)); } - signatures.add(canonicalizeSignature(signature)); + String hash = CellShard.publish(directory, signatures, cellX(cellKey), cellZ(cellKey)); + cells.put(cellKey, new CellReference(hash, signatures.size())); + count = Math.addExact(count, signatures.size()); } - String hash = CellShard.publish(directory, signatures, cellX(cellKey), cellZ(cellKey)); - cells.put(cellKey, new CellReference(hash, signatures.size())); - count = Math.addExact(count, signatures.size()); } byte[] identity = computeIdentity(activationId, count, cellKeys, cells); return publishCatalog(activationId, new PublishedCatalog( @@ -174,6 +185,27 @@ public final class TerrainBoundarySignatureStore { } } + private static void addSampledChunks(long cellKey, BitSet columns, + List chunks) { + int minimumX = Math.multiplyExact(cellX(cellKey), CELL_SIZE); + int minimumZ = Math.multiplyExact(cellZ(cellKey), CELL_SIZE); + int chunksPerSide = CELL_SIZE / GenerationBoundary.CHUNK_SIZE; + int prepared = 0; + for (int index = columns.nextSetBit(0); index >= 0; index = columns.nextSetBit(index + 1)) { + int localX = index / CELL_SIZE; + int localZ = index % CELL_SIZE; + int chunkIndex = localX / GenerationBoundary.CHUNK_SIZE * chunksPerSide + + localZ / GenerationBoundary.CHUNK_SIZE; + int chunkBit = 1 << chunkIndex; + if ((prepared & chunkBit) == 0) { + prepared |= chunkBit; + chunks.add(new GenerationBoundary.ChunkCoordinate( + Math.floorDiv(Math.addExact(minimumX, localX), GenerationBoundary.CHUNK_SIZE), + Math.floorDiv(Math.addExact(minimumZ, localZ), GenerationBoundary.CHUNK_SIZE))); + } + } + } + private Snapshot publishCatalog(long activationId, PublishedCatalog catalog) throws IOException { forceDirectory(directory); Path target = snapshotPath(activationId); @@ -678,6 +710,9 @@ public final class TerrainBoundarySignatureStore { public interface SignatureSampler extends AutoCloseable { TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException; + default void prepareChunks(List chunks) throws IOException { + } + @Override default void close() throws IOException { } diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java b/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java index f082b73f1..a90300a4f 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionGenerationPlan.java @@ -32,11 +32,11 @@ public final class TransitionGenerationPlan { throw new IllegalArgumentException("Unsupported generation transition algorithm version " + specification.algorithmVersion()); } - terrainSampler = new TransitionBoundarySampler( + terrainSampler = specification.widthBlocks() == 0 ? null : new TransitionBoundarySampler( specification.widthBlocks(), terrainSignatures ); - terrainBand = new TransitionBandIndex(boundary, terrainSampler); + terrainBand = terrainSampler == null ? null : new TransitionBandIndex(boundary, terrainSampler); newTerrain = TerrainSample.newTerrain(specification.widthBlocks()); } @@ -97,11 +97,11 @@ public final class TransitionGenerationPlan { } public boolean hasTransitionAtChunk(int chunkX, int chunkZ) { - return terrainBand.contains(chunkX, chunkZ); + return terrainBand != null && terrainBand.contains(chunkX, chunkZ); } public BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) { - if (!terrainBand.contains(blockX >> 4, blockZ >> 4)) { + if (!hasTransitionAtChunk(blockX >> 4, blockZ >> 4)) { return BoundaryGeometryInfluence.none(); } return terrainSampler.geometryAt(blockX, blockZ); @@ -111,7 +111,7 @@ public final class TransitionGenerationPlan { if (boundary.isHistoricalBlock(blockX, blockZ)) { return TerrainSample.historicalTerrain(); } - return terrainBand.contains(blockX >> 4, blockZ >> 4) ? terrainSampler.sample(blockX, blockZ) : newTerrain; + return hasTransitionAtChunk(blockX >> 4, blockZ >> 4) ? terrainSampler.sample(blockX, blockZ) : newTerrain; } public Optional historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { @@ -137,15 +137,15 @@ public final class TransitionGenerationPlan { } long terrainCatalogProbeCount() { - return terrainSampler.catalogProbeCount(); + return terrainSampler == null ? 0L : terrainSampler.catalogProbeCount(); } long terrainShardLoadCount() { - return terrainSampler.shardLoadCount(); + return terrainSampler == null ? 0L : terrainSampler.shardLoadCount(); } long terrainCandidateBuildCount() { - return terrainSampler.candidateBuildCount(); + return terrainSampler == null ? 0L : terrainSampler.candidateBuildCount(); } public record Specification( @@ -171,8 +171,8 @@ public final class TransitionGenerationPlan { if (oldEpochId.equals(newEpochId)) { throw new IllegalArgumentException("Transition epoch IDs must differ"); } - if (widthBlocks <= 0) { - throw new IllegalArgumentException("Transition width must be positive"); + if (widthBlocks < 0) { + throw new IllegalArgumentException("Transition width cannot be negative"); } if (algorithmVersion < 1) { throw new IllegalArgumentException("Transition algorithm version must be positive"); diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index b508d30da..6d8d48f30 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -144,7 +144,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.chunkAccessMode = chunkAsyncMethodSelection.mode(); int detectedWorkerPoolThreads = resolveWorkerPoolThreads(); int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors()); - this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, Runtime.getRuntime().maxMemory(), + this.pregenWorkerThreads = pregenWorkerThreadTarget(detectedCpuThreads, MantleHeapPressure.memoryBudgetBytes(), !foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0); int workerThreadsForCap = foliaRuntime ? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads) @@ -166,7 +166,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { int configuredThreads = foliaRuntime ? computeFoliaRecommendedCap(workerThreadsForCap) : computePaperLikeRecommendedCap(workerThreadsForCap); - configuredThreads = heapLimitedConcurrency(configuredThreads, Runtime.getRuntime().maxMemory()); + configuredThreads = heapLimitedConcurrency(configuredThreads, MantleHeapPressure.memoryBudgetBytes()); this.threads = selectConcurrencyCap(configuredThreads, strictSerial); this.workerPoolThreads = detectedWorkerPoolThreads; this.runtimeCpuThreads = detectedCpuThreads; @@ -304,7 +304,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { binding.releaseChunk(world, chunkX, chunkZ); } }; - return new PregenChunkRetention(tickets, radius, retentionCapacity(Runtime.getRuntime().maxMemory()), + return new PregenChunkRetention(tickets, radius, retentionCapacity(MantleHeapPressure.memoryBudgetBytes()), minRegionX, minRegionZ, maxRegionX, maxRegionZ); } catch (Throwable e) { PregenDiagnostics.probeFailed("pregen chunk retention for world " + world.getName(), e); @@ -996,7 +996,14 @@ public class AsyncPregenMethod implements PregeneratorMethod { PregenAdmissionGate.Wait waited; try { - waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled); + while (true) { + waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled); + if (waited == null || !MantleHeapPressure.overHighWater()) { + break; + } + admission.release(); + backpressure.awaitHeapHeadroom(MantleHeapPressure::overHighWater, this::reclaimHeapPressure); + } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; diff --git a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java index 74e3b251c..c1844849d 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/IrisPregenerator.java @@ -445,7 +445,7 @@ public class IrisPregenerator { try { generator.reclaimMemory(); } catch (Throwable e) { - IrisLogging.reportError("Pregen could not reclaim completed server chunks under heap pressure.", e); + IrisLogging.reportError("Pregen could not reclaim completed server chunks under memory pressure.", e); } try { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java index 5dce2e6cb..8f7ae1478 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/MantleHeapPressure.java @@ -19,6 +19,7 @@ package art.arcane.iris.world.pregen; import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.diagnostics.ContainerMemory; import javax.management.MBeanServer; import javax.management.ObjectName; @@ -31,6 +32,8 @@ import java.util.function.LongSupplier; public final class MantleHeapPressure { private static final double HIGH_WATER = 0.92D; private static final double LOW_WATER = 0.82D; + private static final double CONTAINER_HIGH_WATER = 0.90D; + private static final double CONTAINER_LOW_WATER = 0.80D; private static final long MAXIMUM_HYSTERESIS_MS = 60_000L; private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName(); private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer( @@ -42,7 +45,7 @@ public final class MantleHeapPressure { // An escalation step in the reclaim policy, rate limited by that policy rather than by // anything the operator did. What is actionable is the wait budget being exceeded. (double fraction) -> IrisLogging.info( - "Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.", + "Iris memory pressure remained at %.1f%% of its safety budget after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.", fraction * 100.0D), (String context, Throwable failure) -> IrisLogging.reportError(context, failure))); private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate( @@ -51,11 +54,47 @@ public final class MantleHeapPressure { MAXIMUM_HYSTERESIS_MS, System::currentTimeMillis, PANIC_RECLAIMER::resetEpisode); + private static final HeapPressureGate CONTAINER_PRESSURE_GATE = new HeapPressureGate( + CONTAINER_HIGH_WATER, + CONTAINER_LOW_WATER, + Long.MAX_VALUE, + System::currentTimeMillis, + PANIC_RECLAIMER::resetEpisode); private MantleHeapPressure() { } public static double usedFraction() { + return pressureFraction(heapUsedFraction(), ContainerMemory.snapshot().pressureFraction()); + } + + public static long memoryBudgetBytes() { + return memoryBudgetBytes(Runtime.getRuntime().maxMemory(), ContainerMemory.snapshot().limitBytes()); + } + + static long memoryBudgetBytes(long heapBytes, long containerBytes) { + return Math.min(heapBytes, containerBytes - containerBytes / 4L); + } + + static double pressureFraction(double heapFraction, double containerFraction) { + if (containerFraction >= CONTAINER_HIGH_WATER) { + return Math.max(heapFraction, HIGH_WATER + (containerFraction - CONTAINER_HIGH_WATER)); + } + double containerPressure = containerFraction <= 0D ? 0D + : LOW_WATER + (containerFraction - CONTAINER_LOW_WATER) + * (HIGH_WATER - LOW_WATER) / (CONTAINER_HIGH_WATER - CONTAINER_LOW_WATER); + return Math.max(heapFraction, containerPressure); + } + + public static String describe() { + ContainerMemory.Snapshot container = ContainerMemory.snapshot(); + String heap = Math.round(heapUsedFraction() * 100D) + "% heap"; + return container.supported() + ? heap + ", " + Math.round(container.pressureFraction() * 100D) + "% container working memory" + : heap; + } + + private static double heapUsedFraction() { MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); long max = heap.getMax(); if (max <= 0L) { @@ -71,7 +110,9 @@ public final class MantleHeapPressure { } public static boolean overHighWater() { - return PRESSURE_GATE.update(usedFraction()); + boolean heapPressure = PRESSURE_GATE.update(heapUsedFraction()); + boolean containerPressure = CONTAINER_PRESSURE_GATE.update(ContainerMemory.snapshot().pressureFraction()); + return heapPressure || containerPressure; } public static double reclaimUrgency(double fraction) { @@ -86,7 +127,7 @@ public final class MantleHeapPressure { public static void requestPanicReclaim() { double fraction = usedFraction(); - PANIC_RECLAIMER.request(fraction, PRESSURE_GATE.update(fraction)); + PANIC_RECLAIMER.request(fraction, overHighWater()); } static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java index 293987213..1be125d47 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenMantleBackpressure.java @@ -215,16 +215,16 @@ public final class PregenMantleBackpressure { if (elapsed >= nextWarningElapsed) { nextWarningElapsed = elapsed + warningInterval; lastLog = logNow; - IrisLogging.warn("Pregen heap pressure is still blocking new chunk submissions after " + elapsed + "ms at " - + Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; waiting for headroom. " + IrisLogging.warn("Pregen memory pressure is still blocking new chunk submissions after " + elapsed + "ms at " + + MantleHeapPressure.describe() + "; waiting for headroom. " + diagnostics.get()); onBudgetTimeout.run(); } if (logNow - lastLog >= LOG_INTERVAL_MS) { lastLog = logNow; - IrisLogging.info("Pregen heap pressure: pausing generation at " - + Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom" + IrisLogging.info("Pregen memory pressure: pausing generation at " + + MantleHeapPressure.describe() + "; evicting tectonic plates and waiting for headroom" + (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + "."); } diff --git a/core/src/main/java/art/arcane/iris/world/task/J.java b/core/src/main/java/art/arcane/iris/world/task/J.java index 6285415dd..5b97a8ca3 100644 --- a/core/src/main/java/art/arcane/iris/world/task/J.java +++ b/core/src/main/java/art/arcane/iris/world/task/J.java @@ -289,6 +289,9 @@ public class J { runnable.run(); return true; } + if (!canSchedule()) { + return false; + } if (runRegionImmediate(world, chunkX, chunkZ, runnable)) { return true; @@ -383,6 +386,9 @@ public class J { if (delayTicks <= 0) { return runRegion(world, chunkX, chunkZ, runnable); } + if (!canSchedule()) { + return false; + } if (runRegionDelayed(world, chunkX, chunkZ, runnable, delayTicks)) { return true; diff --git a/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java index 497421b08..ea1a44d32 100644 --- a/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java +++ b/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java @@ -71,6 +71,19 @@ public class IrisSettingsDefaultsTest { assertEquals(8_192, settings.getGenerationTransitionWidthBlocks()); } + @Test + public void studioBlendingDefaultsOnAndAcceptsDisabledHotload() { + IrisSettings defaults = new IrisSettings(); + assertTrue(defaults.getStudio().isBlendChanges()); + assertTrue(new Gson().fromJson("{\"studio\":{}}", IrisSettings.class).getStudio().isBlendChanges()); + assertTrue(new Gson().toJson(defaults).contains("\"blendChanges\":true")); + + IrisSettings changed = IrisSettings.parseHotloadSnapshot("{\"studio\":{\"blendChanges\":false}}"); + + assertFalse(changed.getStudio().isBlendChanges()); + assertEquals(256, changed.getGenerator().getGenerationTransitionWidthBlocks()); + } + @Test public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() { IrisSettings previous = IrisSettings.settings; diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorCaveContextTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorCaveContextTest.java index 75e716150..77f6b7858 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorCaveContextTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorCaveContextTest.java @@ -5,9 +5,12 @@ import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; public class IrisDecoratorCaveContextTest { @ClassRule @@ -105,6 +109,48 @@ public class IrisDecoratorCaveContextTest { assertFalse(IrisSurfaceDecorator.skipsFluid(null)); } + @Test + public void dryBlendedSurfaceRunsBiomeDecoratorDespiteHigherModeledWater() { + Engine engine = mock(Engine.class); + doReturn(mock(SeedManager.class)).when(engine).getSeedManager(); + doReturn(mock(IrisData.class)).when(engine).getData(); + doReturn(7L).when(engine).getGenerationSessionId(); + IrisDimension dimension = mock(IrisDimension.class); + doReturn(dimension).when(engine).getDimension(); + EngineMantle mantle = mock(EngineMantle.class); + doReturn(mantle).when(engine).getMantle(); + doReturn(63).when(mantle).getFluidHeight(anyInt(), anyInt()); + + IrisDecorator decorator = mock(IrisDecorator.class); + doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any()); + doReturn(true).when(decorator).isStacking(); + doReturn(true).when(decorator).isForcePlace(); + doReturn(1).when(decorator).getHeight(any(), anyDouble(), anyDouble(), any()); + IrisBiome biome = mock(IrisBiome.class); + doReturn(InferredType.LAND).when(biome).getInferredType(); + doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE); + ChunkContext chunk = mock(ChunkContext.class); + doReturn(7L).when(chunk).getGenerationSessionId(); + doReturn(new ChunkContext.TransitionTerrainColumn(Terrain3DColumn.unshaped(40, 128), -1)) + .when(chunk).getTransitionTerrain(0, 0); + Hunk output = Hunk.newArrayHunk(16, 128, 16); + output.fill(IrisPlatforms.get().registries().air()); + IrisSurfaceDecorator surface = new IrisSurfaceDecorator(engine); + + try (IrisContext.Scope scope = IrisContext.open(engine, 7L, chunk)) { + surface.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, 40, 88); + assertEquals(-1, surface.surfaceFluidHeight(0, 0)); + assertEquals(63, surface.surfaceFluidHeight(16, 0)); + } + + verify(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any()); + assertFalse(DecoratorCore.SCRATCH_OPTS.get().underwater); + assertEquals(63, surface.surfaceFluidHeight(0, 0)); + try (IrisContext.Scope scope = IrisContext.open(engine, 8L, chunk)) { + assertEquals(63, surface.surfaceFluidHeight(0, 0)); + } + } + @Test @SuppressWarnings("unchecked") public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRecoveryTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRecoveryTest.java new file mode 100644 index 000000000..3ca14d6e9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRecoveryTest.java @@ -0,0 +1,152 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.bukkit.nms.BukkitGenerationPolicy; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationActivation; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.GenerationManifest; +import art.arcane.iris.world.history.IrisBoundarySignatureSampler; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EngineHotloaderRecoveryTest { + @Test + public void failedBoundaryCaptureKeepsNativeGenerationAvailableWithPendingUpdate() throws Exception { + RecoveryFixture fixture = new RecoveryFixture(1L); + long previousSession = fixture.sessions.currentSessionId(); + + fixture.failPromotion(); + + assertEquals(IrisEngine.LifecycleState.RUNNING, fixture.engine.lifecycleState); + assertSame(fixture.previousRuntime, fixture.engine.runtime); + assertFalse(fixture.closing.get()); + try (GenerationSessionLease lease = new BukkitGenerationPolicy(fixture.engine, null) + .acquireLease("bukkit_nms_build_surface")) { + assertNotEquals(previousSession, lease.sessionId()); + assertEquals(1, fixture.sessions.activeLeases()); + } + verify(fixture.background).openBackgroundTaskAdmission(); + } + + @Test + public void failureAfterDurableActivationDoesNotReopenPreviousGeneration() throws Exception { + RecoveryFixture fixture = new RecoveryFixture(2L); + + fixture.failPromotion(); + + assertEquals(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState); + assertTrue(fixture.closing.get()); + assertThrows(IllegalStateException.class, () -> new BukkitGenerationPolicy(fixture.engine, null) + .acquireLease("bukkit_nms_build_surface")); + } + + @Test + public void unreadableHistoryPreservesFailureAndKeepsGenerationSealed() throws Exception { + RecoveryFixture fixture = new RecoveryFixture(1L); + IllegalStateException historyFailure = new IllegalStateException("Manifest unavailable"); + when(fixture.history.manifest()).thenReturn(fixture.previousManifest).thenThrow(historyFailure); + + fixture.failPromotion(); + + assertEquals(IrisEngine.LifecycleState.FAILED, fixture.engine.lifecycleState); + assertTrue(fixture.closing.get()); + assertSame(historyFailure, fixture.failure.getSuppressed()[0]); + } + + private static void setField(IrisEngine engine, String name, Object value) throws ReflectiveOperationException { + Field field = IrisEngine.class.getDeclaredField(name); + field.setAccessible(true); + field.set(engine, value); + } + + private static final class RecoveryFixture { + private final IrisEngine engine = mock(IrisEngine.class); + private final EngineRuntime previousRuntime = mock(EngineRuntime.class); + private final GenerationSessionManager sessions = new GenerationSessionManager(true); + private final AtomicBoolean closing = new AtomicBoolean(); + private final EngineBackgroundTasks background = mock(EngineBackgroundTasks.class); + private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + private final GenerationHistory history = mock(GenerationHistory.class); + private final GenerationManifest previousManifest = mock(GenerationManifest.class); + private final GenerationHistoryRuntimeRouter.StudioCutover cutover = mock( + GenerationHistoryRuntimeRouter.StudioCutover.class); + private final StudioGenerationUpdate update = mock(StudioGenerationUpdate.class); + private final IOException failure = new IOException("Unable to capture terrain boundary"); + + private RecoveryFixture(long currentActivationId) throws Exception { + setField(engine, "lifecycleLock", new Object()); + setField(engine, "backgroundTasks", background); + setField(engine, "runtimeBuilder", new EngineRuntimeBuilder(engine)); + engine.runtime = previousRuntime; + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + when(engine.isStudio()).thenReturn(true); + when(engine.isClosing()).thenAnswer(invocation -> closing.get()); + when(engine.getClosing()).thenReturn(closing); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.acquireGenerationLease(anyString())).thenAnswer(invocation -> + sessions.acquireForEngine(engine, invocation.getArgument(0, String.class))); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(router.beginStudioCutover(anyLong())).thenReturn(cutover); + when(background.drainBackgroundTasks(anyString())).thenReturn( + new EngineBackgroundTasks.BackgroundTaskDrain(null, true)); + IrisWorld world = mock(IrisWorld.class); + when(world.name()).thenReturn("studio-recovery"); + when(engine.getWorld()).thenReturn(world); + GenerationActivation previousActivation = mock(GenerationActivation.class); + when(previousActivation.activationId()).thenReturn(1L); + when(previousManifest.activeActivation()).thenReturn(previousActivation); + GenerationManifest stagedManifest = mock(GenerationManifest.class); + GenerationActivation currentActivation = mock(GenerationActivation.class); + when(currentActivation.activationId()).thenReturn(currentActivationId); + when(stagedManifest.activeActivation()).thenReturn(currentActivation); + when(history.manifest()).thenReturn(previousManifest, stagedManifest); + doAnswer(invocation -> { + closing.set(true); + sessions.sealAndAwait(invocation.getArgument(0, String.class), 1000L); + return null; + }).when(engine).sealForTransition(anyString(), anyBoolean()); + doThrow(failure).when(cutover).promotePending(); + } + + private void failPromotion() throws Exception { + IrisSettings settings = mock(IrisSettings.class, RETURNS_DEEP_STUBS); + try (MockedStatic updates = mockStatic(StudioGenerationUpdate.class); + MockedStatic signatures = mockStatic(IrisBoundarySignatureSampler.class); + MockedStatic configuration = mockStatic(IrisSettings.class)) { + updates.when(() -> StudioGenerationUpdate.prepare(engine, history)).thenReturn(update); + configuration.when(IrisSettings::get).thenReturn(settings); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> new EngineHotloader(engine).hotloadSilently()); + assertSame(failure, thrown.getCause()); + } + verify(cutover).close(); + verify(update).close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRetirementTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRetirementTest.java new file mode 100644 index 000000000..8ba2ead37 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineHotloaderRetirementTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.structure.placement.IrisStructureLocator; +import art.arcane.iris.structure.placement.StructureReachability; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.world.history.IrisBoundarySignatureSampler; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EngineHotloaderRetirementTest { + @Test + public void studioPromotionDrainsHydrologyWorkersOutsideLifecycleLock() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + Object lifecycleLock = new Object(); + setField(engine, "lifecycleLock", lifecycleLock); + EngineBackgroundTasks background = mock(EngineBackgroundTasks.class); + when(background.drainBackgroundTasks(anyString())).thenReturn( + new EngineBackgroundTasks.BackgroundTaskDrain(null, true)); + setField(engine, "backgroundTasks", background); + EngineRuntimeBuilder runtimeBuilder = mock(EngineRuntimeBuilder.class); + setField(engine, "runtimeBuilder", runtimeBuilder); + engine.runtime = mock(EngineRuntime.class); + engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + GenerationHistory history = mock(GenerationHistory.class); + when(router.history()).thenReturn(history); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(engine.isStudio()).thenReturn(true); + IrisWorld world = mock(IrisWorld.class); + when(world.name()).thenReturn("studio"); + when(engine.getWorld()).thenReturn(world); + when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); + when(engine.getEngineData()).thenReturn(mock(IrisEngineData.class, RETURNS_DEEP_STUBS)); + when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class)); + when(engine.getPrefetchSaveStarted()).thenReturn(new AtomicBoolean()); + GenerationHistoryRuntimeRouter.StudioCutover cutover = mock( + GenerationHistoryRuntimeRouter.StudioCutover.class); + when(router.beginStudioCutover(anyLong())).thenReturn(cutover); + AtomicBoolean drained = new AtomicBoolean(); + doAnswer(invocation -> { + assertFalse(Thread.holdsLock(lifecycleLock)); + CompletableFuture planning = CompletableFuture.runAsync(() -> { + synchronized (lifecycleLock) { + drained.set(true); + } + }); + planning.get(5L, TimeUnit.SECONDS); + return null; + }).when(cutover).promotePending(); + StudioGenerationUpdate update = mock(StudioGenerationUpdate.class); + IrisSettings settings = mock(IrisSettings.class, RETURNS_DEEP_STUBS); + + try (MockedStatic updates = mockStatic(StudioGenerationUpdate.class); + MockedStatic signatures = mockStatic(IrisBoundarySignatureSampler.class); + MockedStatic structures = mockStatic(IrisStructureLocator.class); + MockedStatic reachability = mockStatic(StructureReachability.class); + MockedStatic configuration = mockStatic(IrisSettings.class)) { + updates.when(() -> StudioGenerationUpdate.prepare(engine, history)).thenReturn(update); + configuration.when(IrisSettings::get).thenReturn(settings); + + new EngineHotloader(engine).hotloadSilently(); + } + + assertTrue(drained.get()); + verify(runtimeBuilder).refreshStudioRuntimeServices(); + verify(cutover).close(); + verify(update).close(); + } + + private static void setField(IrisEngine engine, String name, Object value) throws ReflectiveOperationException { + Field field = IrisEngine.class.getDeclaredField(name); + field.setAccessible(true); + field.set(engine, value); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisBlendedDecorationTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisBlendedDecorationTest.java new file mode 100644 index 000000000..94fa5dcd1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisBlendedDecorationTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.terrain.Terrain3DColumn; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class IrisBlendedDecorationTest { + @Test + public void shiftedNaturalOpeningUsesRemainingBlendedFloorAndCeiling() { + Terrain3DColumn natural = column(5, 15); + ChunkContext.TransitionTerrainColumn transition = transition(column(3, 12)); + + assertEquals(new IrisDecorantActuator.TerrainLedge(3, 12), + IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition)); + assertTrue(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 4)); + assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 12)); + } + + @Test + public void splitOpeningDecoratesEachSupportedBlendedLedge() { + Terrain3DColumn natural = column(3, 20); + Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32, + y -> y <= 5 || y >= 10 && y <= 12 || y >= 18); + ChunkContext.TransitionTerrainColumn transition = transition(blended); + + assertEquals(new IrisDecorantActuator.TerrainLedge(5, 10), + IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition)); + assertEquals(new IrisDecorantActuator.TerrainLedge(12, 18), + IrisDecorantActuator.resolveTerrainLedge(natural, 1, transition)); + } + + @Test + public void unrelatedCaveOpeningRetainsCaveDecorationOwnership() { + Terrain3DColumn natural = column(18, 25); + ChunkContext.TransitionTerrainColumn transition = transition(column(3, 12)); + + assertNull(IrisDecorantActuator.resolveTerrainLedge(natural, 0, transition)); + assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 8)); + } + + @Test + public void filledOpeningDoesNotRetainTerrainDecorationTargets() { + Terrain3DColumn natural = column(3, 12); + ChunkContext.TransitionTerrainColumn transition = transition( + Terrain3DColumn.fromOccupancy(31, 32, y -> true)); + + assertFalse(IrisDecorantActuator.isTransitionTerrainOpening(natural, transition, 8)); + } + + private Terrain3DColumn column(int floorY, int ceilingY) { + return Terrain3DColumn.fromOccupancy(31, 32, y -> y <= floorY || y >= ceilingY); + } + + private ChunkContext.TransitionTerrainColumn transition(Terrain3DColumn solids) { + return new ChunkContext.TransitionTerrainColumn(solids, solids.topY()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java index ff72a989e..b09d8dc85 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java @@ -246,6 +246,37 @@ public class IrisNaturalCaveScanTest { assertTrue(fixture.markers.isEmpty()); } + @Test + public void blendedShiftedNaturalOpeningRetainsSurfaceDecorationOwnership() throws Exception { + Fixture fixture = new Fixture(); + Terrain3DColumn natural = Terrain3DColumn.fromOccupancy(31, 32, y -> y <= 5 || y >= 12); + Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32, y -> y < 4 || y > 8); + doReturn(natural).when(fixture.complex).terrainColumn(anyInt(), anyInt(), any()); + ChunkContext context = mock(ChunkContext.class); + doReturn(new ChunkContext.TransitionTerrainColumn(blended, 31)) + .when(context).getTransitionTerrain(0, 0); + + fixture.modifier.decorateNaturalCaves(-32, 48, fixture.cavity(), context); + + assertTrue(fixture.calls.isEmpty()); + assertTrue(fixture.markers.isEmpty()); + } + + @Test + public void blendedUnrelatedCaveRemainsDecorated() throws Exception { + Fixture fixture = new Fixture(); + Terrain3DColumn natural = Terrain3DColumn.fromOccupancy(31, 32, y -> y <= 18 || y >= 25); + Terrain3DColumn blended = Terrain3DColumn.fromOccupancy(31, 32, y -> y < 4 || y > 8); + doReturn(natural).when(fixture.complex).terrainColumn(anyInt(), anyInt(), any()); + ChunkContext context = mock(ChunkContext.class); + doReturn(new ChunkContext.TransitionTerrainColumn(blended, 31)) + .when(context).getTransitionTerrain(0, 0); + + fixture.modifier.decorateNaturalCaves(-32, 48, fixture.cavity(), context); + + assertEquals(List.of("floor:0:0:3:3", "ceiling:0:0:8:3"), fixture.calls); + } + private static void verifyScan(int width, int depth) throws Exception { Fixture fixture = new Fixture(); Hunk output = fixture.output(width, depth); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java index ab15563e6..79aa3ad96 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.NativeTerrainReceipt; @@ -106,6 +107,8 @@ public class IrisTransitionGeometryActuatorTest { assertSame(stone, blocks.getRaw(0, 3, 0)); assertSame(air, blocks.getRaw(0, 4, 0)); verify(context).setTerrainHeight(0, 0, 3); + verify(context).setTransitionTerrain(0, 0, + Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), -1); } @Test @@ -121,12 +124,40 @@ public class IrisTransitionGeometryActuatorTest { assertSame(air, blocks.getRaw(0, 2, 0)); } + @Test + public void preservesTheActualBlendedSurfaceWaterHeight() { + NativeBlockState water = state("minecraft:water[level=0]", false); + when(water.isSolid()).thenReturn(false); + when(water.isFluid()).thenReturn(true); + when(registries.blockOrNull(water.key())).thenReturn(water); + ChunkContext context = context("minecraft:stone", 6); + Hunk blocks = filled(); + for (int y = 4; y <= 6; y++) { + blocks.setRaw(0, y, 0, water); + } + + new IrisTransitionGeometryActuator(mock(Engine.class)).generate(16, 0, blocks, + Hunk.newArrayHunk(16, 16, 16), false, context); + + assertSame(water, blocks.getRaw(0, 4, 0)); + assertSame(water, blocks.getRaw(0, 6, 0)); + assertSame(air, blocks.getRaw(0, 7, 0)); + verify(context).setTransitionTerrain(0, 0, + Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), 6); + } + private ChunkContext context(String material) { + return context(material, 3); + } + + private ChunkContext context(String material, int fluidHeight) { ArrayList voxels = new ArrayList<>(16); for (int y = 0; y < 16; y++) { boolean solid = y == 0 || y == 3; - voxels.add(new BoundaryColumnGeometry.Voxel(solid ? material : "minecraft:air", - solid ? BoundaryColumnGeometry.Phase.SOLID : BoundaryColumnGeometry.Phase.AIR, "", false)); + boolean fluid = y > 3 && y <= fluidHeight; + voxels.add(new BoundaryColumnGeometry.Voxel(solid ? material : fluid ? "minecraft:water[level=0]" : "minecraft:air", + solid ? BoundaryColumnGeometry.Phase.SOLID : fluid ? BoundaryColumnGeometry.Phase.FLUID : BoundaryColumnGeometry.Phase.AIR, + fluid ? "minecraft:water[level=0]" : "", false)); } BoundaryGeometryInfluence influence = new BoundaryGeometryInfluence(0D, 0D, List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D))); diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java index d3f237754..1e4b55828 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java @@ -130,6 +130,32 @@ public class BukkitChunkGeneratorCloseTest { closed.join(); } + @Test + public void studioQuiesceCancelsWatcherAndDrainsExclusiveControlWithoutClosingEngine() throws Exception { + CloseFixture fixture = new CloseFixture(); + CompletableFuture pending = new CompletableFuture<>(); + fixture.watcherHotload.set(pending); + Looper watcher = mock(Looper.class); + setField(fixture.generator, "hotloader", watcher); + CompletableFuture drained = fixture.queueOperation(); + + CompletableFuture quiesced = fixture.generator.quiesceStudioUpdatesAsync(); + + assertTrue(pending.isCancelled()); + assertNull(fixture.watcherHotload.get()); + assertFalse(quiesced.isDone()); + assertFalse(fixture.generator.isClosing()); + verify(watcher).interrupt(); + fixture.generator.hotload(); + verify(fixture.engine, times(0)).hotload(); + verify(fixture.engine, times(0)).close(); + + fixture.operation.get().run(); + drained.complete(null); + quiesced.join(); + verify(fixture.engine, times(0)).close(); + } + @Test public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception { CloseFixture fixture = new CloseFixture(); diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java index 0ff45e3d0..442692b67 100644 --- a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java @@ -19,18 +19,65 @@ import static org.mockito.Mockito.mockStatic; public class StudioOpenCoordinatorCloseSequenceTest { @Test - public void unloadCompletesBeforeGeneratorCloseAndFolderDeletion() { + public void updatesDrainBeforeUnloadAndGeneratorClose() { ArrayList phases = new ArrayList<>(); StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), () -> phases.add("maintenance"), + () -> phase(phases, "quiesce-updates"), () -> phase(phases, "unload"), () -> phase(phases, "close-generator"), () -> phase(phases, "delete-folders") ).join(); - assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator", "delete-folders"), phases); + } + + @Test + public void activeUpdateKeepsWorldLoadedUntilTerrainCaptureFinishes() { + ArrayList phases = new ArrayList<>(); + CompletableFuture update = new CompletableFuture<>(); + CompletableFuture closed = StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), + () -> { + phases.add("quiesce-updates"); + return update; + }, + () -> phase(phases, "unload"), + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders")); + + assertFalse(closed.isDone()); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates"), phases); + + update.complete(null); + closed.join(); + + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", + "close-generator", "delete-folders"), phases); + } + + @Test + public void failedUpdateDrainKeepsWorldAndGeneratorAvailable() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("update remains active"); + CompletableFuture closed = StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), + () -> CompletableFuture.failedFuture(failure), + () -> phase(phases, "unload"), + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders")); + + try { + closed.join(); + fail("Expected update drain failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + assertEquals(List.of("evacuate", "maintenance"), phases); } @Test @@ -42,6 +89,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), () -> phases.add("maintenance"), + () -> phase(phases, "quiesce-updates"), () -> { phases.add("unload"); return CompletableFuture.failedFuture(failure); @@ -54,7 +102,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "maintenance", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases); } @Test @@ -69,6 +117,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { return CompletableFuture.failedFuture(failure); }, () -> phases.add("maintenance"), + () -> phase(phases, "quiesce-updates"), () -> phase(phases, "unload"), () -> phase(phases, "close-generator"), () -> phase(phases, "delete-folders") @@ -90,6 +139,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), () -> phases.add("maintenance"), + () -> phase(phases, "quiesce-updates"), () -> phase(phases, "unload"), () -> { phases.add("close-generator"); @@ -102,7 +152,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator"), phases); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator"), phases); } @Test @@ -115,6 +165,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { CompletableFuture close = StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), () -> phases.add("maintenance"), + () -> phase(phases, "quiesce-updates"), () -> { phases.add("unload"); return unload; @@ -126,13 +177,13 @@ public class StudioOpenCoordinatorCloseSequenceTest { warning.get().run(); assertFalse(close.isDone()); assertFalse(unload.isDone()); - assertEquals(List.of("evacuate", "maintenance", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases); configurator.verifyNoInteractions(); unload.complete(null); close.join(); - assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload", "close-generator", "delete-folders"), phases); configurator.verifyNoInteractions(); } } diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java index 4c123f06b..29a4d254c 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java @@ -17,6 +17,32 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mockStatic; public class GenerationHistoryTest extends GenerationHistorySupport { + @Test + public void cutoffActivationKeepsItsRecipeAcrossReloadAndLaterBlendedUpdates() throws Exception { + Path world = temporaryFolder.newFolder("cutoff-update-world").toPath(); + Path packA = createPack("cutoff-update-a", "alpha"); + Path packB = createPack("cutoff-update-b", "beta"); + GenerationHistory history = createHistory(world, packA); + int[][] chunks = {{0, 0}}; + writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), chunks); + history.stageUpdate(packB, fingerprint(packB), contract(), GenerationRegistryContract.empty(), 0); + history.promotePending(signaturesForChunks(chunks)); + + GenerationHistory restarted = GenerationHistory.open(world); + TransitionGenerationPlan cutoff = restarted.transitionPlan(2L); + + assertEquals(0, restarted.activeActivation().transition().widthBlocks()); + assertEquals(1D, cutoff.newEpochWeightAt(16, 8), 0D); + assertFalse(cutoff.hasTransitionAtChunk(1, 0)); + assertEquals(1L, restarted.resolveActivation(0, 0).activationId()); + restarted.stageUpdate(packA, fingerprint(packA), contract(), GenerationRegistryContract.empty(), 32); + restarted.promotePending(signaturesForChunks(chunks)); + + assertEquals(0, restarted.transitionPlan(2L).widthBlocks()); + assertEquals(32, restarted.transitionPlan(3L).widthBlocks()); + assertTrue(restarted.transitionPlan(3L).newEpochWeightAt(16, 8) < 1D); + } + @Test public void freshPublicationReusesOnlyItsInternalVerification() throws Exception { Path world = temporaryFolder.newFolder("verified-publication-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationTransitionTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationTransitionTest.java index 0b620cdb5..cb41f1281 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationTransitionTest.java @@ -69,6 +69,17 @@ public class GenerationTransitionTest { ); } + @Test + public void zeroWidthCutoffSurvivesActivationRoundTrip() { + GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 0) + .completeTransition(BOUNDARY, TERRAIN); + + GenerationActivation restored = GenerationActivation.fromJson(activation.toJson()); + + assertEquals(activation, restored); + assertEquals(0, restored.transition().widthBlocks()); + } + @Test public void activationRoundTripRetainsItsTransition() { GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256) diff --git a/core/src/test/java/art/arcane/iris/world/history/IrisBoundarySignatureSamplerTest.java b/core/src/test/java/art/arcane/iris/world/history/IrisBoundarySignatureSamplerTest.java index ca5ba0f33..b056cec7c 100644 --- a/core/src/test/java/art/arcane/iris/world/history/IrisBoundarySignatureSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/IrisBoundarySignatureSamplerTest.java @@ -5,11 +5,22 @@ import art.arcane.iris.generation.runtime.EnginePlatformHooks; import org.junit.Test; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletableFuture; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -60,8 +71,186 @@ public class IrisBoundarySignatureSamplerTest { verify(hooks, never()).flushSavedTerrainCapture(engine); } + @Test + public void submitsTheWholeBatchBeforeWaitingForAnUnresolvedChunk() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(first); + when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenReturn(second); + when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null)); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine); + ExecutorService worker = Executors.newSingleThreadExecutor()) { + Future prepared = worker.submit(() -> { + capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(-2, 0), + new GenerationBoundary.ChunkCoordinate(-1, 0))); + return null; + }); + prepared.get(3, TimeUnit.SECONDS); + verify(hooks).captureSavedTerrainChunk(engine, -2, 0); + verify(hooks).captureSavedTerrainChunk(engine, -1, 0); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + Future sampled = worker.submit(() -> capture.sample(-32, 8)); + SavedTerrainChunk firstTerrain = terrain(-2, 0); + second.complete(terrain(-1, 0)); + assertFalse(sampled.isDone()); + first.complete(firstTerrain); + assertSame(firstTerrain.column(-32, 8), sampled.get(3, TimeUnit.SECONDS)); + assertEquals("example:physical", capture.sample(-16, 8).biomeAtSample(0)); + verify(hooks, never()).flushSavedTerrainCapture(engine); + } + verify(hooks).flushSavedTerrainCapture(engine); + } + + @Test + public void preparingFullWindowsDoesNotEvictAnyCaptureBeforeItIsSampled() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + List> requests = new ArrayList<>(); + when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> { + CompletableFuture capture = new CompletableFuture<>(); + requests.add(capture); + return capture; + }); + when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null)); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine); + ExecutorService worker = Executors.newSingleThreadExecutor()) { + for (int window = 0; window < 2; window++) { + List coordinates = new ArrayList<>(32); + int first = window * 32; + for (int index = 0; index < 32; index++) { + coordinates.add(new GenerationBoundary.ChunkCoordinate(first + index, 0)); + } + worker.submit(() -> { + capture.prepareChunks(List.copyOf(coordinates)); + return null; + }).get(3, TimeUnit.SECONDS); + assertEquals(first + 32, requests.size()); + for (int index = first; index < first + 32; index++) { + assertFalse(requests.get(index).isDone()); + } + for (int index = first; index < first + 32; index++) { + SavedTerrainChunk terrain = terrain(index, 0); + assertTrue(requests.get(index).complete(terrain)); + assertSame(terrain.column(index * 16, 0), capture.sample(index * 16, 0)); + } + } + assertEquals(64, requests.size()); + for (CompletableFuture request : requests) { + assertFalse(request.isCancelled()); + } + verify(hooks, never()).flushSavedTerrainCapture(engine); + } + verify(hooks).flushSavedTerrainCapture(engine); + } + + @Test + public void failedBatchCaptureCancelsPendingChunksBeforeTheFinalCheckpoint() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + IOException failure = new IOException("capture failed"); + CompletableFuture pending = new CompletableFuture<>(); + when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(CompletableFuture.failedFuture(failure)); + when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenReturn(pending); + when(hooks.flushSavedTerrainCapture(engine)).thenAnswer(invocation -> { + assertTrue(pending.isCancelled()); + return CompletableFuture.completedFuture(null); + }); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) { + capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(-2, 0), + new GenerationBoundary.ChunkCoordinate(-1, 0))); + IOException propagated = assertThrows(IOException.class, () -> capture.sample(-32, 8)); + assertSame(failure, propagated.getCause().getCause()); + assertTrue(pending.isCancelled()); + verify(hooks, never()).flushSavedTerrainCapture(engine); + } + verify(hooks).flushSavedTerrainCapture(engine); + } + + @Test + public void failedBatchSubmissionCancelsRequestsAlreadySubmitted() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + CompletableFuture pending = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("capture submission failed"); + when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(pending); + when(hooks.captureSavedTerrainChunk(engine, -1, 0)).thenThrow(failure); + when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null)); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) { + assertSame(failure, assertThrows(IllegalStateException.class, () -> capture.prepareChunks(List.of( + new GenerationBoundary.ChunkCoordinate(-2, 0), new GenerationBoundary.ChunkCoordinate(-1, 0))))); + assertTrue(pending.isCancelled()); + } + verify(hooks).flushSavedTerrainCapture(engine); + } + + @Test + public void pendingCacheIsBoundedAndCloseCancelsRemainingRequests() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + List> requests = new ArrayList<>(); + when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> { + CompletableFuture future = new CompletableFuture<>(); + requests.add(future); + return future; + }); + when(hooks.flushSavedTerrainCapture(engine)).thenAnswer(invocation -> { + for (CompletableFuture request : requests) { + assertTrue(request.isCancelled()); + } + return CompletableFuture.completedFuture(null); + }); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) { + for (int chunkX = 0; chunkX < 40; chunkX++) { + capture.prepareChunks(List.of(new GenerationBoundary.ChunkCoordinate(chunkX, 0))); + int pendingCount = 0; + for (CompletableFuture request : requests) { + if (!request.isDone()) { + pendingCount++; + } + } + assertEquals(Math.min(chunkX + 1, 32), pendingCount); + } + assertEquals(40, requests.size()); + assertTrue(requests.get(7).isCancelled()); + assertFalse(requests.get(8).isDone()); + verify(hooks, never()).flushSavedTerrainCapture(engine); + } + verify(hooks).flushSavedTerrainCapture(engine); + } + + @Test + public void preparationDeduplicatesRequestsAndRejectsOversizedBatches() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + when(engine.getPlatformHooks()).thenReturn(hooks); + CompletableFuture pending = new CompletableFuture<>(); + when(hooks.captureSavedTerrainChunk(engine, -2, 0)).thenReturn(pending); + when(hooks.flushSavedTerrainCapture(engine)).thenReturn(CompletableFuture.completedFuture(null)); + GenerationBoundary.ChunkCoordinate coordinate = new GenerationBoundary.ChunkCoordinate(-2, 0); + try (TerrainBoundarySignatureStore.SignatureSampler capture = IrisBoundarySignatureSampler.INSTANCE.open(engine)) { + capture.prepareChunks(List.of(coordinate, coordinate)); + capture.prepareChunks(List.of(coordinate)); + verify(hooks).captureSavedTerrainChunk(engine, -2, 0); + assertThrows(IllegalArgumentException.class, + () -> capture.prepareChunks(Collections.nCopies(33, coordinate))); + } + assertTrue(pending.isCancelled()); + } + private static SavedTerrainChunk terrain() throws IOException { - return SavedTerrainChunk.captureBoundary(-2, 0, -16, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { + return terrain(-2, 0); + } + + private static SavedTerrainChunk terrain(int chunkX, int chunkZ) throws IOException { + return SavedTerrainChunk.captureBoundary(chunkX, chunkZ, -16, 16, "minecraft:noise", new SavedTerrainChunk.VoxelSource() { @Override public BoundaryColumnGeometry.Voxel voxel(int localX, int y, int localZ) { return new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); diff --git a/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java b/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java index 1a10757dd..b83d78fe0 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TerrainBoundarySignatureStoreTest.java @@ -14,6 +14,8 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.ArrayList; import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.OptionalInt; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; @@ -24,6 +26,7 @@ import java.util.zip.CRC32; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -31,6 +34,160 @@ public class TerrainBoundarySignatureStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void preparesOnlyDistinctSampledChunksInImmutableBoundedWindows() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("prepared-cells").toPath()); + List coordinates = List.of( + new GenerationBoundary.ChunkCoordinate(-2, -2), + new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(-2, 2), + new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(0, 1), + new GenerationBoundary.ChunkCoordinate(1, 0), + new GenerationBoundary.ChunkCoordinate(1, 1)); + GenerationBoundary boundary = GenerationBoundary.freeze("a".repeat(64), coordinates); + List> batches = new ArrayList<>(); + Set sampled = new HashSet<>(); + TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() { + private List prepared = List.of(); + + @Override + public void prepareChunks(List chunks) { + assertTrue(chunks.size() <= 32); + assertEquals(chunks.size(), new HashSet<>(chunks).size()); + assertThrows(UnsupportedOperationException.class, + () -> chunks.add(new GenerationBoundary.ChunkCoordinate(100, 100))); + batches.add(chunks); + prepared = chunks; + } + + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) { + GenerationBoundary.ChunkCoordinate chunk = new GenerationBoundary.ChunkCoordinate( + Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)); + assertTrue(prepared.contains(chunk)); + sampled.add(chunk); + return simpleSignature(blockX, blockZ, 70); + } + }; + + store.publish(1L, boundary, sampler); + + assertEquals(List.of(List.of( + new GenerationBoundary.ChunkCoordinate(-2, -2), new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(-2, 2), new GenerationBoundary.ChunkCoordinate(0, 0), + new GenerationBoundary.ChunkCoordinate(0, 1), new GenerationBoundary.ChunkCoordinate(1, 0), + new GenerationBoundary.ChunkCoordinate(1, 1))), batches); + assertEquals(new HashSet<>(coordinates), sampled); + } + + @Test + public void preparesAtMostEightCellsBeforeSamplingTheNextWindow() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("bounded-windows").toPath()); + List coordinates = new ArrayList<>(); + for (int cell = 0; cell < 9; cell++) { + int minimumX = cell * 4 - 16; + for (int localX = 0; localX < 2; localX++) { + for (int chunkZ = -2; chunkZ < 0; chunkZ++) { + coordinates.add(new GenerationBoundary.ChunkCoordinate(minimumX + localX, chunkZ)); + } + } + } + GenerationBoundary boundary = GenerationBoundary.freeze("d".repeat(64), coordinates); + List> batches = new ArrayList<>(); + Set sampled = new HashSet<>(); + TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() { + private List prepared = List.of(); + + @Override + public void prepareChunks(List chunks) { + assertEquals(batches.size() * 32, sampled.size()); + assertTrue(chunks.size() <= 32); + assertEquals(chunks.size(), new HashSet<>(chunks).size()); + batches.add(chunks); + prepared = chunks; + } + + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) { + GenerationBoundary.ChunkCoordinate chunk = new GenerationBoundary.ChunkCoordinate( + Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)); + assertTrue(prepared.contains(chunk)); + sampled.add(chunk); + return simpleSignature(blockX, blockZ, 70); + } + }; + + TerrainBoundarySignatureStore.Snapshot actual = store.publish(3L, boundary, sampler); + TerrainBoundarySignatureStore sequentialStore = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("bounded-windows-sequential").toPath()); + TerrainBoundarySignatureStore.Snapshot expected = sequentialStore.publish(3L, boundary, + (blockX, blockZ) -> simpleSignature(blockX, blockZ, 70)); + + assertEquals(List.of(List.copyOf(coordinates.subList(0, 32)), List.copyOf(coordinates.subList(32, 36))), batches); + assertEquals(new HashSet<>(coordinates), sampled); + assertEquals(expected.identity(), actual.identity()); + assertArrayEquals(Files.readAllBytes(sequentialStore.snapshotPath(3L)), Files.readAllBytes(store.snapshotPath(3L))); + } + + @Test + public void preparingChunksPreservesExactSnapshotBytesAndSignatures() throws Exception { + TerrainBoundarySignatureStore preparedStore = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("prepared-snapshot").toPath()); + TerrainBoundarySignatureStore sequentialStore = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("sequential-snapshot").toPath()); + GenerationBoundary boundary = GenerationBoundary.freeze("b".repeat(64), List.of( + new GenerationBoundary.ChunkCoordinate(-2, -1), new GenerationBoundary.ChunkCoordinate(-1, -1), + new GenerationBoundary.ChunkCoordinate(0, 0), new GenerationBoundary.ChunkCoordinate(1, 0))); + List prepared = new ArrayList<>(); + TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() { + @Override + public void prepareChunks(List chunks) { + prepared.addAll(chunks); + } + + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) { + return simpleSignature(blockX, blockZ, 70); + } + }; + TerrainBoundarySignatureStore.Snapshot actual = preparedStore.publish(7L, boundary, sampler); + TerrainBoundarySignatureStore.Snapshot expected = sequentialStore.publish(7L, boundary, + (blockX, blockZ) -> simpleSignature(blockX, blockZ, 70)); + + assertEquals(4, prepared.size()); + assertEquals(expected.identity(), actual.identity()); + assertArrayEquals(Files.readAllBytes(sequentialStore.snapshotPath(7L)), + Files.readAllBytes(preparedStore.snapshotPath(7L))); + boundary.forEachExposedBlockColumn((blockX, blockZ) -> assertSignatureEquals( + expected.signatureAt(blockX, blockZ).orElseThrow(), actual.signatureAt(blockX, blockZ).orElseThrow())); + } + + @Test + public void preparationFailurePreventsSamplingAndCatalogPublication() throws Exception { + TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( + temporaryFolder.newFolder("failed-preparation").toPath()); + GenerationBoundary boundary = GenerationBoundary.freeze("c".repeat(64), + List.of(new GenerationBoundary.ChunkCoordinate(0, 0))); + IOException failure = new IOException("chunk preparation failed"); + TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() { + @Override + public void prepareChunks(List chunks) throws IOException { + throw failure; + } + + @Override + public TerrainBoundarySignature sample(int blockX, int blockZ) { + throw new AssertionError("Sampling continued after preparation failed"); + } + }; + + assertSame(failure, assertThrows(IOException.class, () -> store.publish(1L, boundary, sampler))); + assertFalse(Files.exists(store.snapshotPath(1L))); + } + @Test public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception { TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java index 2725806b4..6bd8f1b99 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionGenerationPlanTest.java @@ -77,6 +77,32 @@ public class TransitionGenerationPlanTest { assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D); } + @Test + public void zeroWidthKeepsCutoffsAndHistoricalOwnershipWithoutSamplingOldTerrain() throws Exception { + GenerationBoundary boundary = GenerationBoundary.freeze("cutoff", List.of( + new GenerationBoundary.ChunkCoordinate(0, 0))); + TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(2L, boundary); + TransitionGenerationPlan plan = new TransitionGenerationPlan( + specification(2L, "old", "new", 0, "cutoff", snapshot.identity()), boundary, snapshot); + TransitionDisplacementField field = new TransitionDisplacementField(plan, + new TransitionDisplacementField.Sources((x, z) -> 104D, 128)); + + assertEquals(0D, plan.newEpochWeightAt(15, 8), 0D); + assertEquals(1D, plan.newEpochWeightAt(16, 8), 0D); + assertEquals(1D, plan.hydrologyWeightAt(16, 8), 0D); + assertFalse(plan.terrainSampleAt(16, 8).hasHistoricalSignature()); + assertFalse(plan.hasTransitionAtChunk(1, 0)); + assertEquals(BoundaryGeometryInfluence.none(), plan.geometryAt(16, 8)); + assertTrue(plan.historicalPhysicalBiomeKeyAt(16, 64, 8).isEmpty()); + assertFalse(plan.allowsNewFootprint(15, 8, 16, 8)); + assertTrue(plan.allowsNewFootprint(16, 8, 17, 8)); + assertEquals(104D, field.height(16, 8, 104D), 0D); + assertEquals(48D, field.fluidHeight(16, 8, 48D), 0D); + assertEquals(0L, plan.terrainCatalogProbeCount()); + assertEquals(0L, plan.terrainShardLoadCount()); + assertEquals(0L, plan.terrainCandidateBuildCount()); + } + @Test public void computesTransitionForEnclosedNewTerritory() throws Exception { GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of( @@ -336,7 +362,7 @@ public class TransitionGenerationPlanTest { assertThrows(IllegalArgumentException.class, () -> specification(1L, "same", "same", 16, "actual", snapshot.identity())); assertThrows(IllegalArgumentException.class, - () -> specification(1L, "old", "new", 0, "actual", snapshot.identity())); + () -> specification(1L, "old", "new", -1, "actual", snapshot.identity())); assertThrows(IllegalArgumentException.class, () -> new TransitionGenerationPlan( specification(1L, "old", "new", 16, "different", snapshot.identity()), diff --git a/core/src/test/java/art/arcane/iris/world/pregen/IrisPregeneratorInitTest.java b/core/src/test/java/art/arcane/iris/world/pregen/IrisPregeneratorInitTest.java index 7b51cf1dc..135d0b1ac 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/IrisPregeneratorInitTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/IrisPregeneratorInitTest.java @@ -206,7 +206,7 @@ public class IrisPregeneratorInitTest { pressure.verify(MantleHeapPressure::requestPanicReclaim); if (failReclaim) { logging.verify(() -> IrisLogging.reportError( - "Pregen could not reclaim completed server chunks under heap pressure.", failure)); + "Pregen could not reclaim completed server chunks under memory pressure.", failure)); } } } diff --git a/core/src/test/java/art/arcane/iris/world/pregen/MantleContainerPressureTest.java b/core/src/test/java/art/arcane/iris/world/pregen/MantleContainerPressureTest.java new file mode 100644 index 000000000..cf111b8dd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/pregen/MantleContainerPressureTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.world.pregen; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MantleContainerPressureTest { + @Test + public void containerPressureReclaimsEvenWhenTheHeapHasHeadroom() { + double pressure = MantleHeapPressure.pressureFraction(0.45D, 0.90D); + assertEquals(0.92D, pressure, 1E-9D); + assertEquals(1D, MantleHeapPressure.reclaimUrgency(pressure), 0D); + } + + @Test + public void heapPressureStillControlsReclaimOutsideContainers() { + assertEquals(0.95D, MantleHeapPressure.pressureFraction(0.95D, 0D), 0D); + assertEquals(0.45D, MantleHeapPressure.pressureFraction(0.45D, 0D), 0D); + } + + @Test + public void containerBudgetsReserveMemoryOutsideTheHeap() { + assertEquals(3L << 30, MantleHeapPressure.memoryBudgetBytes(8L << 30, 4L << 30)); + assertEquals(2L << 30, MantleHeapPressure.memoryBudgetBytes(2L << 30, 4L << 30)); + assertEquals(8L << 30, MantleHeapPressure.memoryBudgetBytes(8L << 30, Long.MAX_VALUE)); + } + + @Test + public void containerPressureCannotResumeBecauseAWarningDeadlineExpired() { + AtomicLong clock = new AtomicLong(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.90D, 0.80D, Long.MAX_VALUE, clock::get, () -> {}); + assertTrue(gate.update(0.95D)); + clock.set(600_000L); + assertTrue(gate.update(0.85D)); + assertFalse(gate.update(0.80D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/task/JRegionTaskAdmissionTest.java b/core/src/test/java/art/arcane/iris/world/task/JRegionTaskAdmissionTest.java new file mode 100644 index 000000000..6e9838c99 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/task/JRegionTaskAdmissionTest.java @@ -0,0 +1,97 @@ +package art.arcane.iris.world.task; + +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.plugin.Plugin; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.CALLS_REAL_METHODS; + +public class JRegionTaskAdmissionTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + @Before + public void clearPlatform() { + IrisPlatforms.unbind(); + } + + @After + public void cleanup() { + IrisPlatforms.unbind(); + } + + @Test + public void missingPlatformRejectsRegionTasks() { + World world = mock(World.class); + AtomicBoolean ran = new AtomicBoolean(); + + assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true))); + assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1)); + assertFalse(ran.get()); + } + + @Test + public void disabledBukkitPluginRejectsRegionTasks() { + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + Plugin plugin = mock(Plugin.class); + when(plugin.isEnabled()).thenReturn(false); + World world = mock(World.class); + AtomicBoolean ran = new AtomicBoolean(); + + try (MockedStatic bukkit = mockStatic(BukkitPlatform.class); + MockedStatic server = mockStatic(Bukkit.class)) { + bukkit.when(BukkitPlatform::hasPlugin).thenReturn(true); + bukkit.when(BukkitPlatform::plugin).thenReturn(plugin); + + assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true))); + assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1)); + CompletableFuture future = J.runRegionFuture(world, 0, 0, () -> ran.set(true)); + + assertTrue(future.isCompletedExceptionally()); + assertFalse(ran.get()); + } + } + + @Test + public void ownedFoliaCleanupStillRunsWithoutAScheduler() { + World world = mock(World.class); + AtomicBoolean ran = new AtomicBoolean(); + + try (MockedStatic scheduling = mockStatic(J.class, CALLS_REAL_METHODS)) { + scheduling.when(J::isFolia).thenReturn(true); + scheduling.when(() -> J.isOwnedByCurrentRegion(world, 0, 0)).thenReturn(true); + + assertTrue(J.runRegion(world, 0, 0, () -> ran.set(true))); + assertTrue(ran.get()); + ran.set(false); + assertTrue(J.runRegion(world, 0, 0, () -> ran.set(true), 0)); + assertTrue(ran.get()); + ran.set(false); + CompletableFuture future = J.runRegionFuture(world, 0, 0, () -> ran.set(true)); + assertFalse(future.isCompletedExceptionally()); + assertTrue(ran.get()); + ran.set(false); + assertFalse(J.runRegion(world, 0, 0, () -> ran.set(true), 1)); + assertFalse(ran.get()); + } + } +}