Compare commits

..
Author SHA1 Message Date
Brian Neumann-Fopiano a521109a6a Updated release version
- Iris version is 4.3.3-26.2
- Loader build defaults use the current release version
2026-10-12 14:47:39 -04:00
Brian Neumann-Fopiano a9f5145c0f Fixed Iris artifact builds
- Loader jars exclude Bukkit dependency bootstrap classes
- Artifact verification rejects bootstrap references and executable payloads
- Verifier tests cover nested jars and bundled compression libraries
- Local VolmLib native setup finishes before Iris native setup
2026-10-12 11:34:41 -04:00
Brian Neumann-Fopiano e3673afb1e Fixed mining and timeout settings
- Pending biome reads no longer cancel block breaking
- Custom loot resolves saved biome history after accepted breaks
- Native drops survive failed reads and runtime teardown without duplication
- Tree felling captures the original tool for fallback drops
- Modded block loot resolves one historical biome context
- Timeout settings persist atomically through the actual server configuration paths
- Tests cover deferred loot, teardown and timeout persistence
2026-10-12 09:27:26 -04:00
Brian Neumann-Fopiano 2145e90b1c Rebase 2026-10-11 10:35:59 -04:00
SwiftSwamp d480abad50 Added shared update notices
- Iris joins shared Volmit update checks and reports
- The iris.update permission controls update visibility
- general.updateNotifications defaults to enabled
- Accepted settings changes reconfigure update checks
- Shutdown removes the settings listener and closes update checks
- Tests cover persisted opt-outs and notifier lifecycle
2026-10-09 09:30:12 -06:00
69 changed files with 3272 additions and 503 deletions

No files matched your search

@@ -37,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager; import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.WorldRefusalReporter; import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch; import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.update.IrisUpdateNotifier;
import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome; import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome;
@@ -132,8 +133,11 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event; import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList; import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener; 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.BiomeProvider;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.IllegalPluginAccessException;
@@ -205,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile PlaceholderRegistration papiRegistration; private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener; private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState; private volatile IrisPapiState papiState;
private volatile boolean papiLifecycleActive;
private KMap<Class<? extends IrisService>, IrisService> services; private KMap<Class<? extends IrisService>, IrisService> services;
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM // 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. // shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
@@ -215,6 +220,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this); private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this); private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private BukkitLanguageSwitcher languageSwitcher; private BukkitLanguageSwitcher languageSwitcher;
private IrisUpdateNotifier updateNotifier;
private BukkitDebugDump debugDump; private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread; private volatile Thread serverLifecycleThread;
@@ -637,6 +643,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(), languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all", new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions())); DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
updateNotifier = new IrisUpdateNotifier(this);
PaperLibBootstrap.install(); PaperLibBootstrap.install();
SimdSupport.install(); SimdSupport.install();
timings.mark("bootstrap"); timings.mark("bootstrap");
@@ -864,7 +871,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
*/ */
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) { private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, 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() { public PendingWorldReplacementManager pendingWorldReplacements() {
@@ -928,6 +942,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
ServerConfigurator.restartAtStartupBoundary(restartReason); ServerConfigurator.restartAtStartupBoundary(restartReason);
return; return;
} }
papiLifecycleActive = true;
setupPapi();
reportLockedRuntime(); reportLockedRuntime();
} }
@@ -974,6 +990,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
public void onDisable() { public void onDisable() {
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
updateNotifier.close();
updateNotifier = null;
}
if (debugDump != null) { if (debugDump != null) {
debugDump.close(); debugDump.close();
debugDump = null; debugDump = null;
@@ -1013,6 +1035,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
@Override @Override
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) { public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping()) { if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
@@ -1261,6 +1285,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
&& preservation.hasActiveResources(); && preservation.hasActiveResources();
} }
private void preservePendingBlockDrops() {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null && generator.getEngine() != null
&& generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) {
manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> {
if (failure != null) {
Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure);
}
});
}
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) { private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>(); List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>(); List<PlatformChunkGenerator> generators = new ArrayList<>();
@@ -1315,8 +1353,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
} }
private void setupPapi() { @EventHandler
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) { 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; return;
} }
@@ -1343,7 +1396,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
papiRegistration = registration; papiRegistration = registration;
} }
private void teardownPapi() { private synchronized void teardownPapi() {
IrisPapiListener listener = papiListener; IrisPapiListener listener = papiListener;
papiListener = null; papiListener = null;
@@ -0,0 +1,37 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import java.util.function.Consumer;
public final class IrisUpdateNotifier implements AutoCloseable {
private final Consumer<IrisSettings> settingsListener = this::reconfigure;
private final BukkitUpdateService updates;
private volatile boolean enabled;
private boolean closed;
public IrisUpdateNotifier(Plugin plugin) {
enabled = IrisSettings.get().getGeneral().isUpdateNotifications();
updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options(
"VolmitSoftware", "Iris", "iris.update", () -> enabled));
IrisSettings.addSettingsListener(settingsListener);
reconfigure(IrisSettings.get());
}
private synchronized void reconfigure(IrisSettings settings) {
if (closed) {
return;
}
enabled = settings.getGeneral().isUpdateNotifications();
updates.reconfigure();
}
@Override
public synchronized void close() {
closed = true;
IrisSettings.removeSettingsListener(settingsListener);
updates.close();
}
}
@@ -3,19 +3,33 @@ package art.arcane.iris.world.tree;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Collection;
import java.util.Objects;
final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter { final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter {
private final TreeFellerSVC service; private final TreeFellerSVC service;
private final FellingRun run; private final FellingRun run;
private final ItemStack tool;
RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) { RoutedBlockBreakEvent(Options options) {
super(block, player); super(options.block(), options.player());
this.run = run; run = options.run();
this.service = service; service = options.service();
tool = Objects.requireNonNull(options.tool(), "tool").clone();
}
@Override
public Collection<ItemStack> captureNativeDrops() {
return getBlock().getDrops(tool);
} }
@Override @Override
public boolean routeDrop(Object drop) { public boolean routeDrop(Object drop) {
return service.isServiceEnabled() && run.presentation.routeDrop(drop); return service.isServiceEnabled() && run.presentation.routeDrop(drop);
} }
record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) {
}
} }
@@ -387,7 +387,8 @@ final class TreeFellingRunner {
return; return;
} }
BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service); BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, run.candidate.player(), run, service, reservation.toolForDrops()));
service.managedEvents.add(probe); service.managedEvents.add(probe);
try { try {
Bukkit.getPluginManager().callEvent(probe); Bukkit.getPluginManager().callEvent(probe);
@@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com website: VolmitSoftware.com
description: More than a Dimension! description: More than a Dimension!
permissions: permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump: iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports. description: Allows creating and optionally uploading diagnostic reports.
default: op default: op
@@ -25,34 +28,26 @@ permissions:
dependencies: dependencies:
server: server:
PlaceholderAPI: PlaceholderAPI:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
CraftEngine: CraftEngine:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Nexo: Nexo:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Oraxen: Oraxen:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
ItemsAdder: ItemsAdder:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
SCore: SCore:
load: BEFORE
required: false
join-classpath: true
ExecutableItems:
load: BEFORE
required: false
join-classpath: false
MythicLib:
load: OMIT load: OMIT
required: false required: false
join-classpath: true join-classpath: true
@@ -61,11 +56,7 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
eco: eco:
load: BEFORE load: OMIT
required: false
join-classpath: true
EcoItems:
load: BEFORE
required: false required: false
join-classpath: true join-classpath: true
MythicMobs: MythicMobs:
@@ -77,7 +68,7 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
KGenerators: KGenerators:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Multiverse-Core: Multiverse-Core:
@@ -85,6 +76,6 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
WorldEdit: WorldEdit:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
@@ -3,18 +3,6 @@ version: ${version}
main: ${main} main: ${main}
folia-supported: true folia-supported: true
load: STARTUP load: STARTUP
softdepend:
- PlaceholderAPI
- CraftEngine
- Nexo
- Oraxen
- ItemsAdder
- SCore
- ExecutableItems
- eco
- EcoItems
- KGenerators
- WorldEdit
loadbefore: [ Multiverse-Core ] loadbefore: [ Multiverse-Core ]
authors: [ cyberpwn, NextdoorPsycho, Vatuu ] authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com website: VolmitSoftware.com
@@ -23,6 +11,9 @@ commands:
iris: iris:
aliases: [ ir, irs ] aliases: [ ir, irs ]
permissions: permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump: iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports. description: Allows creating and optionally uploading diagnostic reports.
default: op default: op
@@ -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)
}
}
}
@@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class PaperPluginMetadataTest { public class PaperPluginMetadataTest {
private static final List<String> OPTIONAL_PLUGIN_IDS = List.of( private static final List<String> JOINED_PLUGIN_IDS = List.of(
"PlaceholderAPI", "PlaceholderAPI",
"CraftEngine", "CraftEngine",
"Nexo", "Nexo",
"Oraxen", "Oraxen",
"ItemsAdder", "ItemsAdder",
"SCore", "SCore",
"ExecutableItems",
"MythicLib",
"MMOItems", "MMOItems",
"eco", "eco",
"EcoItems",
"MythicMobs", "MythicMobs",
"MythicCrucible", "MythicCrucible",
"KGenerators", "KGenerators",
"Multiverse-Core",
"WorldEdit" "WorldEdit"
); );
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
.toList();
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
private static final List<String> BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"Multiverse-Core".equals(pluginId))
.filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId))
.toList();
@Test @Test
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception { 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" + " description: Allows survival players to fell Iris-managed trees with an axe.\n"
+ " default: op")); + " default: op"));
for (String pluginId : JOINED_PLUGIN_IDS) { for (String pluginId : JOINED_PLUGIN_IDS) {
String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE"; assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true)));
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, 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))); assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
} }
@@ -95,7 +85,7 @@ public class PaperPluginMetadataTest {
Map<String, Map<String, Object>> commands = metadata.getCommands(); Map<String, Map<String, Object>> commands = metadata.getCommands();
assertTrue(commands.containsKey("iris")); assertTrue(commands.containsKey("iris"));
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases")); 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()); assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
Permission all = metadata.getPermissions().stream() Permission all = metadata.getPermissions().stream()
.filter(permission -> "iris.all".equals(permission.getName())) .filter(permission -> "iris.all".equals(permission.getName()))
@@ -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> bukkit = mockStatic(Bukkit.class);
MockedStatic<HandlerList> handlers = mockStatic(HandlerList.class);
MockedStatic<PlaceholderRegistration> availability = mockStatic(PlaceholderRegistration.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class);
MockedConstruction<PlaceholderRegistration> 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> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
installer.verifyNoInteractions();
}
}
@Test
public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception {
Iris iris = plugin(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> 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;
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
public class IrisUpdateNotifierTest {
@Test
public void acceptedSettingsReconfigureChecksAndClosingRemovesTheListener() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
Plugin plugin = mock(Plugin.class);
BukkitUpdateService service = mock(BukkitUpdateService.class);
try (MockedStatic<BukkitUpdateService> registration = mockStatic(BukkitUpdateService.class)) {
registration.when(() -> BukkitUpdateService.register(eq(plugin), any())).thenReturn(service);
IrisUpdateNotifier notifier = new IrisUpdateNotifier(plugin);
try {
ArgumentCaptor<BukkitUpdateService.Options> options = ArgumentCaptor.forClass(BukkitUpdateService.Options.class);
registration.verify(() -> BukkitUpdateService.register(eq(plugin), options.capture()));
assertEquals("VolmitSoftware", options.getValue().owner());
assertEquals("Iris", options.getValue().repository());
assertEquals("iris.update", options.getValue().permission());
assertTrue(options.getValue().enabled().getAsBoolean());
clearInvocations(service);
assertFalse(IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"updateNotifications\":false}}", candidate -> false));
assertTrue(options.getValue().enabled().getAsBoolean());
verifyNoInteractions(service);
IrisSettings.installHotloadSnapshot("{\"general\":{\"updateNotifications\":false}}");
assertFalse(options.getValue().enabled().getAsBoolean());
verify(service).reconfigure();
IrisSettings.settings.getGeneral().setUpdateNotifications(true);
assertFalse(options.getValue().enabled().getAsBoolean());
} finally {
notifier.close();
}
verify(service).close();
clearInvocations(service);
IrisSettings.installHotloadSnapshot("{}");
verifyNoInteractions(service);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.world.tree;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.junit.Test;
import java.util.Collection;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class RoutedBlockBreakEventTest {
@Test
public void nativeLootUsesTheReservedToolCloneWithoutReadingPlayerInventory() {
Block block = mock(Block.class);
Player player = mock(Player.class);
ItemStack reservedTool = mock(ItemStack.class);
ItemStack capturedTool = mock(ItemStack.class);
ItemStack drop = mock(ItemStack.class);
Collection<ItemStack> expected = List.of(drop);
when(reservedTool.clone()).thenReturn(capturedTool);
when(block.getDrops(capturedTool)).thenReturn(expected);
RoutedBlockBreakEvent event = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, player, mock(FellingRun.class), mock(TreeFellerSVC.class), reservedTool));
reservedTool.setType(Material.AIR);
assertSame(expected, event.captureNativeDrops());
verify(reservedTool).clone();
verify(block).getDrops(capturedTool);
verify(block, never()).getDrops(reservedTool);
verifyNoInteractions(player);
}
}
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.2-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -313,6 +313,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package. // loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**') exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry -> filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.2-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -305,6 +305,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package. // loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**') exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry -> filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
@@ -31,6 +31,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockDropHooks;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.structure.placement.LootResolver; import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
@@ -181,26 +182,30 @@ public final class ModdedBlockBreakHandler {
NativeBlockState brokenState, NativeBlockState brokenState,
Engine engine Engine engine
) { ) {
KList<IrisBlockDrops> providers = providers(engine, position, brokenState); BiomeEnvironment environment = engine.getBiomeEnvironment(position.x(),
if (providers.isEmpty()) { position.y() - engine.getMinHeight(), position.z());
return Result.empty(); try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
} KList<IrisBlockDrops> providers = providers(environment, brokenState);
if (providers.isEmpty()) {
return Result.empty();
}
KList<NativeItemStack> drops = new KList<>(); KList<NativeItemStack> drops = new KList<>();
boolean replaceVanillaDrops = false; boolean replaceVanillaDrops = false;
for (IrisBlockDrops provider : providers) { for (IrisBlockDrops provider : providers) {
replaceVanillaDrops |= provider.isReplaceVanillaDrops(); replaceVanillaDrops |= provider.isReplaceVanillaDrops();
for (IrisLoot loot : provider.getDrops()) { for (IrisLoot loot : provider.getDrops()) {
if (!LootResolver.oneIn(RNG.r, loot.getRarity())) { if (!LootResolver.oneIn(RNG.r, loot.getRarity())) {
continue; continue;
} }
NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level)); NativeItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, ModdedItemTranslator.context(level));
if (stack != null && !stack.isEmpty()) { if (stack != null && !stack.isEmpty()) {
drops.add(stack); drops.add(stack);
}
} }
} }
return new Result(drops, replaceVanillaDrops, null);
} }
return new Result(drops, replaceVanillaDrops, null);
} }
private static void removeMarker(Engine engine, NativeBlockPoint position) { private static void removeMarker(Engine engine, NativeBlockPoint position) {
@@ -225,22 +230,17 @@ public final class ModdedBlockBreakHandler {
engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class); engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class);
} }
private static KList<IrisBlockDrops> providers(Engine engine, NativeBlockPoint position, NativeBlockState brokenState) { private static KList<IrisBlockDrops> providers(BiomeEnvironment environment, NativeBlockState brokenState) {
KList<IrisBlockDrops> providers = new KList<>(); KList<IrisBlockDrops> providers = new KList<>();
IrisData data = engine.getData(); IrisData data = environment.data();
int relativeY = position.y() - engine.getMinHeight(); IrisBiome biome = environment.biome();
IrisBiome biome = engine.getBiome(position.x(), relativeY, position.z()); addMatching(providers, biome.getBlockDrops(), brokenState, data);
if (biome != null) {
addMatching(providers, biome.getBlockDrops(), brokenState, data);
}
if (skipsParents(providers)) { if (skipsParents(providers)) {
return providers; return providers;
} }
IrisRegion region = engine.getRegion(position.x(), relativeY, position.z()); IrisRegion region = environment.region();
if (region != null) { addMatching(providers, region.getBlockDrops(), brokenState, data);
addMatching(providers, region.getBlockDrops(), brokenState, data); addMatching(providers, environment.dimension().getBlockDrops(), brokenState, data);
}
addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data);
return providers; return providers;
} }
@@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings; 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.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure; import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod; import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -74,8 +75,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
sync = configuration.sync(); sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime); pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight()); this.maxInFlight = memoryLimitedConcurrency(pregen.getModdedPregenInFlight(), MantleHeapPressure.memoryBudgetBytes());
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4)); this.minInFlight = Math.min(maxInFlight, Math.max(4, Math.min(16, maxInFlight / 4)));
this.semaphore = new Semaphore(maxInFlight, true); this.semaphore = new Semaphore(maxInFlight, true);
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight); this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds()); this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
@@ -86,7 +87,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
pregen.getMantleBackpressureTimeoutMs(), 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 @Override
@@ -359,12 +366,26 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
listener.onChunkGenerating(x, z); listener.onChunkGenerating(x, z);
try { try {
synchronized (permitMonitor) { while (true) {
while (inFlight.get() >= adaptiveLimit.get()) { synchronized (permitMonitor) {
permitMonitor.wait(500L); 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) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return; return;
@@ -400,6 +421,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}); });
} }
private boolean admissionCancelled() {
return Thread.currentThread().isInterrupted() || !runtime.isRunning();
}
private void markSubmitted() { private void markSubmitted() {
int current = inFlight.incrementAndGet(); int current = inFlight.incrementAndGet();
inFlightPeak.accumulateAndGet(current, Math::max); inFlightPeak.accumulateAndGet(current, Math::max);
@@ -18,6 +18,21 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.loot.IrisBlockDrops;
import art.arcane.iris.world.loot.IrisLoot;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeItemStack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
@@ -27,9 +42,22 @@ import org.junit.Test;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import org.mockito.MockedStatic;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
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 ModdedBlockBreakHandlerTest { public class ModdedBlockBreakHandlerTest {
@BeforeClass @BeforeClass
@@ -48,4 +76,97 @@ public class ModdedBlockBreakHandlerTest {
assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false)); assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false));
assertFalse(ModdedBlockBreakHandler.matchesState(north, ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null), false)); assertFalse(ModdedBlockBreakHandler.matchesState(north, ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null), false));
} }
@Test
public void historicalEnvironmentSuppliesAllRulesAndScopesMatchingAndLoot() {
try (DropFixture fixture = new DropFixture()) {
fixture.dimensionRule.setReplaceVanillaDrops(true);
ModdedBlockBreakHandler.Result result = fixture.evaluate();
assertTrue(result.replaceVanillaDrops());
assertEquals(3, result.drops().size());
assertSame(fixture.item, result.drops().getFirst());
assertFalse(fixture.scoped.get());
verify(fixture.engine).getBiomeEnvironment(16, 136, 32);
verify(fixture.engine).openBiomeEnvironmentScope(fixture.environment);
verify(fixture.engine, never()).getData();
verify(fixture.engine, never()).getDimension();
verify(fixture.engine, never()).getBiome(anyInt(), anyInt(), anyInt());
verify(fixture.engine, never()).getRegion(anyInt(), anyInt(), anyInt());
}
}
@Test
public void historicalBiomeCanSkipRegionAndDimensionDrops() {
try (DropFixture fixture = new DropFixture()) {
fixture.biomeRule.setSkipParents(true);
fixture.dimensionRule.setReplaceVanillaDrops(true);
ModdedBlockBreakHandler.Result result = fixture.evaluate();
assertFalse(result.replaceVanillaDrops());
assertEquals(1, result.drops().size());
assertFalse(fixture.scoped.get());
verify(fixture.region, never()).getBlockDrops();
verify(fixture.dimension, never()).getBlockDrops();
}
}
private static final class DropFixture implements AutoCloseable {
private final NativeWorld world = mock(NativeWorld.class);
private final Engine engine = mock(Engine.class);
private final IrisBiome biome = mock(IrisBiome.class);
private final IrisRegion region = mock(IrisRegion.class);
private final IrisDimension dimension = mock(IrisDimension.class);
private final IrisData data = mock(IrisData.class);
private final BiomeEnvironment environment = new BiomeEnvironment(7L, biome, region, dimension, data);
private final ModdedBlockState state = ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null);
private final NativeItemStack item = mock(NativeItemStack.class);
private final IrisLoot loot = new IrisLoot().setRarity(1);
private final IrisBlockData configuredBlock = mock(IrisBlockData.class);
private final IrisBlockDrops biomeRule = rule();
private final IrisBlockDrops regionRule = rule();
private final IrisBlockDrops dimensionRule = rule();
private final AtomicBoolean scoped = new AtomicBoolean();
private final MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
private final MockedStatic<ModdedItemTranslator> translator = mockStatic(ModdedItemTranslator.class);
private DropFixture() {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
generators.when(() -> NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class)).thenReturn(generator);
when(generator.engineIfBound()).thenReturn(engine);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getBiomeEnvironment(16, 136, 32)).thenReturn(environment);
when(engine.openBiomeEnvironmentScope(environment)).thenAnswer(invocation -> {
assertTrue(scoped.compareAndSet(false, true));
return (BiomeEnvironment.Scope) () -> scoped.set(false);
});
when(biome.getBlockDrops()).thenReturn(new KList<>(biomeRule));
when(region.getBlockDrops()).thenReturn(new KList<>(regionRule));
when(dimension.getBlockDrops()).thenReturn(new KList<>(dimensionRule));
when(configuredBlock.getBlockData(data)).thenAnswer(invocation -> {
assertTrue(scoped.get());
return state;
});
translator.when(() -> ModdedItemTranslator.stack(eq(loot), eq(RNG.r), any())).thenAnswer(invocation -> {
assertTrue(scoped.get());
return item;
});
}
private IrisBlockDrops rule() {
return new IrisBlockDrops().setBlocks(new KList<>(configuredBlock)).setDrops(new KList<>(loot));
}
private ModdedBlockBreakHandler.Result evaluate() {
return ModdedBlockBreakHandler.evaluateManagedDrops(world, new NativeBlockPoint(16, 72, 32), state);
}
@Override
public void close() {
translator.close();
generators.close();
}
}
} }
@@ -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<ChunkLoad> first = new CompletableFuture<>();
CompletableFuture<ChunkLoad> 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<IrisSettings> settings = mockStatic(IrisSettings.class);
MockedStatic<MantleHeapPressure> 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<MantleHeapPressure> 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));
}
}
}
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.2-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.3.0.33-beta')) String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.3.0.33-beta'))
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -302,6 +302,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
// loader has. Shipping it only adds dead classes that reference a missing package. // loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**') exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry -> filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
+13 -1
View File
@@ -12,6 +12,7 @@ import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree import org.gradle.api.file.FileTree
import org.gradle.api.plugins.JavaPlugin import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.initialization.IncludedBuild
import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.jvm.tasks.Jar import org.gradle.jvm.tasks.Jar
@@ -64,7 +65,7 @@ interface IrisFileOperations {
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations) IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
group = 'art.arcane' group = 'art.arcane'
version = providers.gradleProperty('irisVersion').getOrElse('4.3.2-26.2') version = providers.gradleProperty('irisVersion').getOrElse('4.3.3-26.2')
project(':core') { project(':core') {
group = 'art.arcane' group = 'art.arcane'
@@ -78,6 +79,9 @@ String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.3-6
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.3.0.33-beta') String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.3.0.33-beta')
String volmLibShared = 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT' String volmLibShared = 'com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT'
File localVolmLibCheckout = (File) gradle.extensions.extraProperties.get('volmLibDirectory') File localVolmLibCheckout = (File) gradle.extensions.extraProperties.get('volmLibDirectory')
IncludedBuild localVolmLibBuild = localVolmLibCheckout == null ? null : gradle.includedBuilds.find { IncludedBuild includedBuild ->
includedBuild.projectDir.canonicalFile == localVolmLibCheckout.canonicalFile
}
Map<String, String> volmLibStamp = (Map<String, String>) gradle.extensions.extraProperties.get('volmLibStamp') Map<String, String> volmLibStamp = (Map<String, String>) gradle.extensions.extraProperties.get('volmLibStamp')
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-" String coordinatePrefix = "${minecraftVersion}-"
@@ -136,6 +140,14 @@ nmsBindings.each { String key ->
project(":adapters:bukkit:nms:${key}") { project(":adapters:bukkit:nms:${key}") {
apply plugin: JavaPlugin apply plugin: JavaPlugin
apply plugin: PaperweightUser apply plugin: PaperweightUser
if (localVolmLibBuild != null) {
tasks.named('paperweightUserdevSetup').configure {
dependsOn(localVolmLibBuild.task(":native:${key}:paperweightUserdevSetup"))
if (key == 'v26_2_R1') {
dependsOn(localVolmLibBuild.task(':native-common:paperweightUserdevSetup'))
}
}
}
NativeDevelopmentTargets.Target nativeTarget = NativeDevelopmentTargets.require(key) NativeDevelopmentTargets.Target nativeTarget = NativeDevelopmentTargets.require(key)
dependencies.extensions.getByType(PaperweightUserDependenciesExtension) dependencies.extensions.getByType(PaperweightUserDependenciesExtension)
.paperDevBundle(nativeTarget.paperBundle()) .paperDevBundle(nativeTarget.paperBundle())
@@ -24,6 +24,10 @@ public final class ModdedArtifactVerifier {
"net/jpountz/lz4/LZ4BlockOutputStream" "net/jpountz/lz4/LZ4BlockOutputStream"
); );
private static final String INTERNAL_OSHI_REFERENCE = "oshi/SystemInfo"; private static final String INTERNAL_OSHI_REFERENCE = "oshi/SystemInfo";
private static final List<String> BUKKIT_DEPENDENCY_BOOTSTRAP_CLASSES = List.of(
"art/arcane/iris/platform/bootstrap/SlimJar",
"art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries"
);
private static final List<String> BUNDLED_RUNTIME_PREFIXES = List.of( private static final List<String> BUNDLED_RUNTIME_PREFIXES = List.of(
"net/jpountz/", "net/jpountz/",
"oshi/", "oshi/",
@@ -97,6 +101,8 @@ public final class ModdedArtifactVerifier {
Set<String> asmEntries = new LinkedHashSet<>(); Set<String> asmEntries = new LinkedHashSet<>();
Set<String> shippedNestedJars = new LinkedHashSet<>(); Set<String> shippedNestedJars = new LinkedHashSet<>();
Set<String> bukkitSupertypeClasses = new LinkedHashSet<>(); Set<String> bukkitSupertypeClasses = new LinkedHashSet<>();
Set<String> dependencyBootstrapViolations = new LinkedHashSet<>();
Set<String> executableEntries = new LinkedHashSet<>();
boolean oshiReference = false; boolean oshiReference = false;
Enumeration<JarEntry> entries = jar.entries(); Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) { while (entries.hasMoreElements()) {
@@ -106,6 +112,9 @@ public final class ModdedArtifactVerifier {
} }
String name = entry.getName(); String name = entry.getName();
if (name.toLowerCase(Locale.ROOT).endsWith(".exe")) {
executableEntries.add(name);
}
if (startsWithAny(name, BUNDLED_RUNTIME_PREFIXES)) { if (startsWithAny(name, BUNDLED_RUNTIME_PREFIXES)) {
bundledRuntimeEntries.add(name); bundledRuntimeEntries.add(name);
} }
@@ -116,7 +125,7 @@ public final class ModdedArtifactVerifier {
shippedNestedJars.add(name); shippedNestedJars.add(name);
} }
if (isNestedJar(name)) { if (isNestedJar(name)) {
inspectNestedJar(jar, entry, bundledRuntimeEntries, privateReferenceClasses); inspectNestedJar(jar, entry, bundledRuntimeEntries, privateReferenceClasses, dependencyBootstrapViolations, executableEntries);
} }
if (!name.endsWith(".class")) { if (!name.endsWith(".class")) {
continue; continue;
@@ -124,6 +133,7 @@ public final class ModdedArtifactVerifier {
byte[] classFile = readEntryBytes(jar, entry); byte[] classFile = readEntryBytes(jar, entry);
String bytecode = new String(classFile, StandardCharsets.ISO_8859_1); String bytecode = new String(classFile, StandardCharsets.ISO_8859_1);
inspectDependencyBootstrap(name, name, bytecode, dependencyBootstrapViolations);
if (containsAny(bytecode, PRIVATE_REFERENCE_PREFIXES)) { if (containsAny(bytecode, PRIVATE_REFERENCE_PREFIXES)) {
privateReferenceClasses.add(name); privateReferenceClasses.add(name);
} }
@@ -145,6 +155,13 @@ public final class ModdedArtifactVerifier {
} }
} }
if (!executableEntries.isEmpty()) {
throw new GradleException(artifact.getName() + " contains executable .exe entries: " + preview(executableEntries));
}
if (!dependencyBootstrapViolations.isEmpty()) {
throw new GradleException(artifact.getName() + " contains Bukkit dependency bootstrap code: "
+ preview(dependencyBootstrapViolations));
}
if (!bundledRuntimeEntries.isEmpty()) { if (!bundledRuntimeEntries.isEmpty()) {
throw new GradleException(artifact.getName() + " duplicates Minecraft runtime libraries: " throw new GradleException(artifact.getName() + " duplicates Minecraft runtime libraries: "
+ preview(bundledRuntimeEntries)); + preview(bundledRuntimeEntries));
@@ -364,7 +381,9 @@ public final class ModdedArtifactVerifier {
private static void inspectNestedJar(JarFile jar, JarEntry nestedEntry, private static void inspectNestedJar(JarFile jar, JarEntry nestedEntry,
Set<String> bundledRuntimeEntries, Set<String> bundledRuntimeEntries,
Set<String> privateReferenceClasses) throws IOException { Set<String> privateReferenceClasses,
Set<String> dependencyBootstrapViolations,
Set<String> executableEntries) throws IOException {
String nestedName = nestedEntry.getName(); String nestedName = nestedEntry.getName();
if (isNamedRuntimeLibrary(nestedName)) { if (isNamedRuntimeLibrary(nestedName)) {
bundledRuntimeEntries.add(nestedName); bundledRuntimeEntries.add(nestedName);
@@ -380,11 +399,15 @@ public final class ModdedArtifactVerifier {
String name = entry.getName(); String name = entry.getName();
String path = nestedName + "!/" + name; String path = nestedName + "!/" + name;
if (name.toLowerCase(Locale.ROOT).endsWith(".exe")) {
executableEntries.add(path);
}
if (startsWithAny(name, BUNDLED_RUNTIME_PREFIXES)) { if (startsWithAny(name, BUNDLED_RUNTIME_PREFIXES)) {
bundledRuntimeEntries.add(path); bundledRuntimeEntries.add(path);
} }
if (name.endsWith(".class")) { if (name.endsWith(".class")) {
String bytecode = new String(nestedJar.readAllBytes(), StandardCharsets.ISO_8859_1); String bytecode = new String(nestedJar.readAllBytes(), StandardCharsets.ISO_8859_1);
inspectDependencyBootstrap(name, path, bytecode, dependencyBootstrapViolations);
if (containsAny(bytecode, PRIVATE_REFERENCE_PREFIXES)) { if (containsAny(bytecode, PRIVATE_REFERENCE_PREFIXES)) {
privateReferenceClasses.add(path); privateReferenceClasses.add(path);
} }
@@ -393,6 +416,15 @@ public final class ModdedArtifactVerifier {
} }
} }
private static void inspectDependencyBootstrap(String entry, String path, String bytecode, Set<String> violations) {
String topLevel = topLevelClass(entry);
for (String forbidden : BUKKIT_DEPENDENCY_BOOTSTRAP_CLASSES) {
if (topLevel.equals(forbidden) || bytecode.contains(forbidden)) {
violations.add(path + " -> " + forbidden);
}
}
}
private static boolean isNestedJar(String name) { private static boolean isNestedJar(String name) {
return name.endsWith(".jar") return name.endsWith(".jar")
&& (name.startsWith(NESTED_JAR_DIRECTORY) || name.startsWith("META-INF/jarjar/")); && (name.startsWith(NESTED_JAR_DIRECTORY) || name.startsWith("META-INF/jarjar/"));
@@ -4,6 +4,8 @@ import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.objectweb.asm.ClassWriter; import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes; import org.objectweb.asm.Opcodes;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Type;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File; import java.io.File;
@@ -44,6 +46,91 @@ public class ModdedArtifactVerifierTest {
ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES); ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES);
} }
@Test
public void rejectsBukkitDependencyBootstrapClassesAndNestedClasses() throws Exception {
for (String internalName : List.of("art/arcane/iris/platform/bootstrap/SlimJar",
"art/arcane/iris/platform/bootstrap/SlimJar$BootstrapLogger",
"art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries",
"art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries$Provider")) {
Map<String, byte[]> entries = validEntries();
entries.put(internalName + ".class", classExtending(internalName, "java/lang/Object"));
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains("Bukkit dependency bootstrap code"));
assertTrue(failure.getMessage().contains(internalName));
}
}
@Test
public void rejectsDanglingBukkitDependencyBootstrapReferences() throws Exception {
for (String internalName : List.of("art/arcane/iris/platform/bootstrap/SlimJar",
"art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries")) {
Map<String, byte[]> entries = validEntries();
String caller = "art/arcane/iris/modded/BootstrapCaller";
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, caller, null, "java/lang/Object", null);
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
"dependencyType", "()Ljava/lang/Class;", null, null);
method.visitCode();
method.visitLdcInsn(Type.getObjectType(internalName));
method.visitInsn(Opcodes.ARETURN);
method.visitMaxs(1, 0);
method.visitEnd();
writer.visitEnd();
entries.put(caller + ".class", writer.toByteArray());
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains(caller));
assertTrue(failure.getMessage().contains(internalName));
}
}
@Test
public void rejectsBukkitDependencyBootstrapInsideJarJar() throws Exception {
Map<String, byte[]> entries = validEntries();
String internalName = "art/arcane/iris/platform/bootstrap/SlimJar";
entries.put("META-INF/jarjar/dependency-support.jar", createNestedArtifact(Map.of(
internalName + ".class", classExtending(internalName, "java/lang/Object"))));
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains("Bukkit dependency bootstrap code"));
assertTrue(failure.getMessage().contains("dependency-support.jar!/" + internalName));
}
@Test
public void rejectsExecutableEntriesRegardlessOfCase() throws Exception {
for (String executable : List.of("bin/installer.exe", "native/windows/helper.EXE")) {
Map<String, byte[]> entries = validEntries();
entries.put(executable, new byte[]{1});
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains("executable .exe entries"));
assertTrue(failure.getMessage().contains(executable));
}
}
@Test
public void rejectsExecutableInsideJarJar() throws Exception {
Map<String, byte[]> entries = validEntries();
entries.put("META-INF/jarjar/native-support.jar", createNestedArtifact(Map.of(
"native/helper.exe", new byte[]{1})));
GradleException failure = assertThrows(GradleException.class,
() -> ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES));
assertTrue(failure.getMessage().contains("executable .exe entries"));
assertTrue(failure.getMessage().contains("native-support.jar!/native/helper.exe"));
}
@Test
public void acceptsBundledCompressionLibraries() throws Exception {
Map<String, byte[]> entries = validEntries();
entries.put("native/windows-x86_64/jlibdeflate.dll", new byte[]{1});
entries.put("native/linux-x86_64/libjlibdeflate.so", new byte[]{1});
entries.put("native/osx-aarch64/libjlibdeflate.dylib", new byte[]{1});
entries.put("com/fulcrumgenomics/jlibdeflate/NativeLoader.class",
classExtending("com/fulcrumgenomics/jlibdeflate/NativeLoader", "java/lang/Object"));
ModdedArtifactVerifier.verify(createArtifact(entries), REQUIRED_ENTRIES);
}
@Test @Test
public void rejectsClientLanguagesWithoutPackMetadata() throws Exception { public void rejectsClientLanguagesWithoutPackMetadata() throws Exception {
Map<String, byte[]> entries = validEntries(); Map<String, byte[]> entries = validEntries();
@@ -18,6 +18,7 @@
package art.arcane.iris.configuration; package art.arcane.iris.configuration;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.IrisPaperLikeBackendMode; import art.arcane.iris.world.IrisPaperLikeBackendMode;
import art.arcane.iris.world.IrisRuntimeSchedulerMode; import art.arcane.iris.world.IrisRuntimeSchedulerMode;
@@ -33,11 +34,14 @@ import lombok.Data;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import java.util.function.Predicate; import java.util.function.Predicate;
@Data @Data
public class IrisSettings { public class IrisSettings {
private static final Object SETTINGS_LOCK = new Object(); private static final Object SETTINGS_LOCK = new Object();
private static final CopyOnWriteArrayList<Consumer<IrisSettings>> LISTENERS = new CopyOnWriteArrayList<>();
private static final IrisSettings BOOTSTRAP_DEFAULTS = new IrisSettings(); private static final IrisSettings BOOTSTRAP_DEFAULTS = new IrisSettings();
public static volatile IrisSettings settings; public static volatile IrisSettings settings;
private IrisSettingsGeneral general = new IrisSettingsGeneral(); private IrisSettingsGeneral general = new IrisSettingsGeneral();
@@ -80,6 +84,7 @@ public class IrisSettings {
current = read(); current = read();
settings = current; settings = current;
notifySettingsChanged(current);
return current; return current;
} }
} }
@@ -130,6 +135,7 @@ public class IrisSettings {
IrisSettings parsed = parseHotloadSnapshot(rawJson); IrisSettings parsed = parseHotloadSnapshot(rawJson);
synchronized (SETTINGS_LOCK) { synchronized (SETTINGS_LOCK) {
settings = parsed; settings = parsed;
notifySettingsChanged(parsed);
} }
return parsed; return parsed;
} }
@@ -142,6 +148,7 @@ public class IrisSettings {
} }
synchronized (SETTINGS_LOCK) { synchronized (SETTINGS_LOCK) {
settings = parsed; settings = parsed;
notifySettingsChanged(parsed);
} }
return true; return true;
} }
@@ -165,6 +172,20 @@ public class IrisSettings {
return parsed; return parsed;
} }
public static void addSettingsListener(Consumer<IrisSettings> listener) {
LISTENERS.add(Objects.requireNonNull(listener));
}
public static void removeSettingsListener(Consumer<IrisSettings> listener) {
LISTENERS.remove(listener);
}
private static void notifySettingsChanged(IrisSettings current) {
for (Consumer<IrisSettings> listener : LISTENERS) {
listener.accept(current);
}
}
public void forceSave() { public void forceSave() {
File s = IrisPlatforms.get().dataFile("iris.json"); File s = IrisPlatforms.get().dataFile("iris.json");
@@ -255,7 +276,7 @@ public class IrisSettings {
} }
public int getEffectiveResidentTectonicPlates(int worldHeight) { public int getEffectiveResidentTectonicPlates(int worldHeight) {
return effectiveResidentTectonicPlates(worldHeight, Runtime.getRuntime().maxMemory()); return effectiveResidentTectonicPlates(worldHeight, MantleHeapPressure.memoryBudgetBytes());
} }
int effectiveResidentTectonicPlates(int worldHeight, long maximumHeapBytes) { int effectiveResidentTectonicPlates(int worldHeight, long maximumHeapBytes) {
@@ -310,6 +331,7 @@ public class IrisSettings {
public static class IrisSettingsGeneral { public static class IrisSettingsGeneral {
public String language = "en_US"; public String language = "en_US";
public boolean metrics = true; public boolean metrics = true;
public boolean updateNotifications = true;
public boolean commandSounds = true; public boolean commandSounds = true;
public boolean debug = false; public boolean debug = false;
public boolean dumpMantleOnError = false; public boolean dumpMantleOnError = false;
@@ -377,6 +399,7 @@ public class IrisSettings {
@Data @Data
public static class IrisSettingsStudio { public static class IrisSettingsStudio {
public boolean openVSCode = true; public boolean openVSCode = true;
public boolean blendChanges = true;
public boolean disableTimeAndWeather = true; public boolean disableTimeAndWeather = true;
public boolean entitySpawning = true; public boolean entitySpawning = true;
public boolean autoStartDefaultStudio = false; public boolean autoStartDefaultStudio = false;
@@ -11,6 +11,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot; import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
import art.arcane.iris.world.history.FloatingBiomeOverlay; import art.arcane.iris.world.history.FloatingBiomeOverlay;
import art.arcane.iris.generation.terrain.IrisRegion; 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.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeBiome; import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.hunk.Hunk;
@@ -25,6 +26,7 @@ public class ChunkContext {
private Hunk<NativeBiome> terrainBiomes; private Hunk<NativeBiome> terrainBiomes;
private FloatingBiomeOverlay floatingBiomes; private FloatingBiomeOverlay floatingBiomes;
private CaveTerrainSnapshot caveTerrain; private CaveTerrainSnapshot caveTerrain;
private TransitionTerrainColumn[] transitionTerrain;
private final int x; private final int x;
private final int z; private final int z;
private final IrisComplex complex; private final IrisComplex complex;
@@ -167,6 +169,17 @@ public class ChunkContext {
this.caveTerrain = caveTerrain; 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) { public void setNaturalBiome(int localX, int y, int localZ, NativeBiome biome) {
if (terrainBiomes != null) { if (terrainBiomes != null) {
terrainBiomes.setRaw(localX, y, localZ, biome); 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 static final class PrefillFillTask implements Runnable {
private final ChunkedDataCache<?> dataCache; private final ChunkedDataCache<?> dataCache;
@@ -20,6 +20,8 @@ package art.arcane.iris.generation.decoration;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine; 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.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.volmlib.util.hunk.Hunk; import art.arcane.volmlib.util.hunk.Hunk;
@@ -60,7 +62,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
@BlockCoordinates @BlockCoordinates
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
Hunk<NativeBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) { Hunk<NativeBlockState> 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) { if (inferredType == InferredType.SHORE && height < fluidHeight) {
return; return;
} }
@@ -100,6 +102,21 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
return height < fluidHeight && inferredType != InferredType.CAVE; 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) { static boolean skipsFluid(InferredType inferredType) {
return inferredType == InferredType.CAVE; return inferredType == InferredType.CAVE;
} }
@@ -35,6 +35,8 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.generation.context.IrisContext; import art.arcane.iris.generation.context.IrisContext;
import java.util.concurrent.TimeUnit;
import static art.arcane.iris.generation.runtime.EngineShutdownSequence.runCleanup; import static art.arcane.iris.generation.runtime.EngineShutdownSequence.runCleanup;
/** /**
@@ -224,12 +226,14 @@ final class EngineHotloader {
StudioGenerationUpdate update = null; StudioGenerationUpdate update = null;
boolean backgroundAdmissionClosed = false; boolean backgroundAdmissionClosed = false;
try { try {
long startedAt = System.nanoTime();
engine.requireRunning("hotload the Studio pack"); engine.requireRunning("hotload the Studio pack");
GenerationHistory history = router.history(); GenerationHistory history = router.history();
update = StudioGenerationUpdate.prepare(engine, history); update = StudioGenerationUpdate.prepare(engine, history);
if (update == null) { if (update == null) {
return; return;
} }
long preparedAt = System.nanoTime();
engine.awaitNativeStructureBootstrap("Studio generation cutover"); engine.awaitNativeStructureBootstrap("Studio generation cutover");
engine.backgroundTasks.closeBackgroundTaskAdmission(); engine.backgroundTasks.closeBackgroundTaskAdmission();
backgroundAdmissionClosed = true; backgroundAdmissionClosed = true;
@@ -243,15 +247,27 @@ final class EngineHotloader {
engine.lifecycleState = LifecycleState.HOTLOADING; engine.lifecycleState = LifecycleState.HOTLOADING;
} }
engine.sealForTransition("Studio generation cutover", false); engine.sealForTransition("Studio generation cutover", false);
long drainedAt = System.nanoTime();
IrisBoundarySignatureSampler.checkpoint(engine); IrisBoundarySignatureSampler.checkpoint(engine);
long checkpointedAt = System.nanoTime();
synchronized (engine.lifecycleLock) { synchronized (engine.lifecycleLock) {
if (engine.lifecycleState != LifecycleState.HOTLOADING || engine.runtime != previous) { if (engine.lifecycleState != LifecycleState.HOTLOADING || engine.runtime != previous) {
throw new IllegalStateException("Studio engine changed while its generation cutover was draining."); throw new IllegalStateException("Studio engine changed while its generation cutover was draining.");
} }
engine.prepareRuntimeHotload(); engine.prepareRuntimeHotload();
IrisSettings settings = IrisSettings.get();
int transitionWidth = settings.getStudio().isBlendChanges()
? settings.getGenerator().getGenerationTransitionWidthBlocks() : 0;
history.stageUpdate(update.source(), update.fingerprint(), update.dimensionContract(), history.stageUpdate(update.source(), update.fingerprint(), update.dimensionContract(),
update.registryContract(), IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); update.registryContract(), transitionWidth);
cutover.promotePending(); }
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); IrisStructureLocator.invalidate(engine);
StructureReachability.invalidate(engine); StructureReachability.invalidate(engine);
engine.runtimeBuilder.refreshStudioRuntimeServices(); engine.runtimeBuilder.refreshStudioRuntimeServices();
@@ -259,19 +275,27 @@ final class EngineHotloader {
engine.getEngineData().getStatistics().hotloaded(); engine.getEngineData().getStatistics().hotloaded();
engine.getPlatformHooks().applyWorldBoundary(engine); 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, ""); broadcastStudioHotload(false, "");
} catch (Throwable failure) { } catch (Throwable failure) {
if (previous != null) { if (previous != null) {
synchronized (engine.lifecycleLock) { synchronized (engine.lifecycleLock) {
if (engine.lifecycleState == LifecycleState.HOTLOADING if (engine.lifecycleState == LifecycleState.HOTLOADING
|| engine.lifecycleState == LifecycleState.RUNNING) { || engine.lifecycleState == LifecycleState.RUNNING) {
boolean unchanged = false; boolean activeGenerationUnchanged = false;
try { try {
unchanged = router.history().manifest() == previousManifest; GenerationManifest currentManifest = router.history().manifest();
activeGenerationUnchanged = currentManifest.activeActivation().activationId()
== previousManifest.activeActivation().activationId();
} catch (Throwable historyFailure) { } catch (Throwable historyFailure) {
failure.addSuppressed(historyFailure); failure.addSuppressed(historyFailure);
} }
if (unchanged) { if (activeGenerationUnchanged) {
engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous); engine.runtimeBuilder.restoreRuntimeAfterFailedTransition(previous);
} else { } else {
engine.lifecycleState = LifecycleState.FAILED; 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) { private void requireMutableGenerationRuntime(String operation) {
if (engine.getGenerationHistoryRuntimeRouter().isPresent()) { if (engine.getGenerationHistoryRuntimeRouter().isPresent()) {
throw new IllegalStateException("Cannot " + operation throw new IllegalStateException("Cannot " + operation
@@ -539,9 +539,20 @@ public class IrisEngine implements Engine {
if (saved.isPresent()) { if (saved.isPresent()) {
return saved.get(); return saved.get();
} }
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored = return resolveUnsavedBiomeEnvironment(x, y, z);
openGenerationHistoryReadScope(x, z, "resolve a biome environment")) { }
return Engine.super.getBiomeEnvironment(x, y, z);
public CompletableFuture<BiomeEnvironment> getBiomeEnvironmentAsync(int x, int y, int z) {
try {
if (!usesSavedBiomeEnvironment()) {
return CompletableFuture.completedFuture(getBiomeEnvironment(x, y, z));
}
int worldY = y + getWorld().minHeight();
return generationHistoryRuntimeRouter.biomes().readChunkAsync(Math.floorDiv(x, 16), Math.floorDiv(z, 16))
.thenApply(session -> session.resolve(x, worldY, z, false)
.orElseGet(() -> resolveUnsavedBiomeEnvironment(x, y, z)));
} catch (RuntimeException | Error failure) {
return CompletableFuture.failedFuture(failure);
} }
} }
@@ -577,6 +588,13 @@ public class IrisEngine implements Engine {
return binding; return binding;
} }
private BiomeEnvironment resolveUnsavedBiomeEnvironment(int x, int y, int z) {
try (GenerationHistoryRuntimeRouter.CoordinateScope ignored =
openGenerationHistoryReadScope(x, z, "resolve a biome environment")) {
return Engine.super.getBiomeEnvironment(x, y, z);
}
}
private Optional<BiomeEnvironment> resolveSavedBiomeEnvironment(int x, int y, int z, boolean surface) { private Optional<BiomeEnvironment> resolveSavedBiomeEnvironment(int x, int y, int z, boolean surface) {
if (!usesSavedBiomeEnvironment()) { if (!usesSavedBiomeEnvironment()) {
return Optional.empty(); return Optional.empty();
@@ -38,11 +38,15 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import org.bukkit.Chunk; import org.bukkit.Chunk;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockDropItemEvent;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ScheduledThreadPoolExecutor;
@@ -267,24 +271,38 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
blockDropRouter.onBlockBreak(e); blockDropRouter.onBlockBreak(e);
} }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockDropItems(BlockDropItemEvent event) {
runManagerTask("bukkit_world_manager_block_drops", () -> blockDropRouter.onBlockDropItems(event));
}
@Override @Override
public void onBlockPlace(BlockPlaceEvent e) { public void onBlockPlace(BlockPlaceEvent e) {
blockDropRouter.onBlockPlace(e); blockDropRouter.onBlockPlace(e);
} }
public CompletableFuture<Void> flushPendingBlockDropsAsync() {
return blockDropRouter.flushAsync();
}
@Override @Override
public synchronized void close() { public synchronized void close() {
Throwable failure = null; Throwable failure = null;
try { try {
chunkMaintenance.close(); chunkMaintenance.close();
} catch (Throwable e) { } catch (Throwable e) {
failure = e; failure = appendCloseFailure(failure, e);
} }
try { try {
super.close(); super.close();
} catch (Throwable e) { } catch (Throwable e) {
failure = appendCloseFailure(failure, e); failure = appendCloseFailure(failure, e);
} }
try {
blockDropRouter.close();
} catch (Throwable e) {
failure = appendCloseFailure(failure, e);
}
if (!looperStopped) { if (!looperStopped) {
try { try {
if (looper != null) { if (looper != null) {
@@ -20,13 +20,8 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.tree.BlockDropRouter; import art.arcane.iris.world.tree.BlockDropRouter;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.world.loot.IrisBlockDrops; import art.arcane.iris.world.loot.IrisBlockDrops;
import art.arcane.iris.world.entity.IrisMarker; import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -35,81 +30,140 @@ import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.MatterMarker;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Item;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockDropItemEvent;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer; import java.util.function.Consumer;
/** /**
* Biome, region and dimension block drops for a Bukkit Iris world. The drop list is resolved on the * Biome, region and dimension block drops for a Bukkit Iris world. Native loot is retained while
* event thread, the marker cleanup runs asynchronously and the drops themselves are handed to the * saved biome definitions load, then finalized on the block's owning thread after the break.
* event's own router when it offers one so a deferred break keeps its inventory destination.
*/ */
final class WorldBlockDropRouter { final class WorldBlockDropRouter {
private final IrisWorldManager manager; private final IrisWorldManager manager;
private final Set<DeferredDrops> pending = ConcurrentHashMap.newKeySet();
private boolean closed;
WorldBlockDropRouter(IrisWorldManager manager) { WorldBlockDropRouter(IrisWorldManager manager) {
this.manager = manager; this.manager = manager;
} }
void onBlockBreak(BlockBreakEvent e) { void onBlockBreak(BlockBreakEvent event) {
if (e.isCancelled()) { if (event.isCancelled() || !isManagedWorld(event.getBlock().getWorld())) {
return; return;
} }
if (e.getBlock().getWorld().equals(BukkitWorldBinding.world(manager.getTarget().getWorld()))) { Location location = event.getBlock().getLocation();
int blockX = e.getBlock().getX(); int mantleY = toMantleY(location.getBlockY(), manager.getEngine().getWorld().minHeight());
int mantleY = toMantleY(e.getBlock().getY(), manager.getEngine().getWorld().minHeight()); Runnable finalizedBreak = manager.managedTask("bukkit_world_manager_block_break_finalize", () -> {
int blockZ = e.getBlock().getZ(); if (event.isCancelled()) {
List<IrisBlockDrops> dropProviders;
try {
dropProviders = resolveDropProviders(e);
} catch (SavedBiomeUnavailableException unavailable) {
e.setCancelled(true);
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
return; return;
} }
J.a(manager.managedTask("bukkit_world_manager_block_break_marker", () -> {
KList<ItemStack> d = new KList<>(); MatterMarker marker = manager.getMantle().get(location.getBlockX(), mantleY, location.getBlockZ(), MatterMarker.class);
dropProviders.forEach(provider -> provider.fillDrops(false, d)); if (marker == null || marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) {
if (dropProviders.stream().anyMatch(IrisBlockDrops::isReplaceVanillaDrops)) {
e.setDropItems(false);
}
World w = e.getBlock().getWorld();
Location blockLocation = e.getBlock().getLocation();
Location dropLocation = blockLocation.clone().add(.5, .5, .5);
BlockDropRouter dropRouter = e instanceof BlockDropRouter router ? router : null;
Runnable finalizedBreak = manager.managedTask("bukkit_world_manager_block_break_finalize", () -> {
if (e.isCancelled()) {
return; return;
} }
J.a(manager.managedTask("bukkit_world_manager_block_break_marker", () -> { IrisMarker mark = manager.getData().getMarkerLoader().load(marker.getTag());
MatterMarker marker = manager.getMantle().get(blockX, mantleY, blockZ, MatterMarker.class); if (mark == null || mark.isRemoveOnChange()) {
if (marker == null || marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { manager.getMantle().remove(location.getBlockX(), mantleY, location.getBlockZ(), MatterMarker.class);
return; }
} }));
});
IrisMarker mark = manager.getData().getMarkerLoader().load(marker.getTag()); schedule(location, finalizedBreak);
if (mark == null || mark.isRemoveOnChange()) { if (!(event instanceof BlockDropRouter router) || !event.isDropItems()) {
manager.getMantle().remove(blockX, mantleY, blockZ, MatterMarker.class); return;
} }
})); synchronized (pending) {
routeDrops(d, dropRouter, item -> w.dropItemNaturally(dropLocation, item)); if (closed) {
}); return;
if (!J.runAt(blockLocation, finalizedBreak, 1) && !J.isFolia()) { }
J.s(finalizedBreak, 1); BlockData state = event.getBlock().getBlockData().clone();
if (!mayHaveBlockDropRules(state)) {
return;
}
Location dropLocation = location.clone().add(.5, .5, .5);
List<NativeDrop> originals = new ArrayList<>();
for (Object nativeDrop : router.captureNativeDrops()) {
if (nativeDrop instanceof ItemStack stack) {
originals.add(new NativeDrop(dropLocation, stack.clone()));
}
}
CompletableFuture<BiomeEnvironment> environment = environment(location);
if (defer(new DeferredDrops(location, state, router, () -> !event.isCancelled(), originals, environment, new AtomicBoolean()))) {
event.setDropItems(false);
} }
} }
} }
void onBlockPlace(BlockPlaceEvent e) { void onBlockDropItems(BlockDropItemEvent event) {
if (event.isCancelled() || !isManagedWorld(event.getBlock().getWorld())) {
return;
}
synchronized (pending) {
if (closed) {
return;
}
BlockData state = event.getBlockState().getBlockData().clone();
if (!mayHaveBlockDropRules(state)) {
return;
}
Location location = event.getBlockState().getLocation();
CompletableFuture<BiomeEnvironment> environment = environment(location);
List<NativeDrop> originals = new ArrayList<>(event.getItems().size());
for (Item item : event.getItems()) {
originals.add(new NativeDrop(location.clone().add(.5, .5, .5), item.getItemStack().clone()));
}
if (defer(new DeferredDrops(location, state, null, () -> !event.isCancelled(), originals, environment, new AtomicBoolean()))) {
event.getItems().clear();
}
}
}
void onBlockPlace(BlockPlaceEvent event) {
}
CompletableFuture<Void> flushAsync() {
List<DeferredDrops> outstanding;
synchronized (pending) {
outstanding = List.copyOf(pending);
}
List<CompletableFuture<Void>> restores = new ArrayList<>(outstanding.size());
for (DeferredDrops drops : outstanding) {
Location location = drops.location;
restores.add(J.runRegionFuture(location.getWorld(), location.getBlockX() >> 4, location.getBlockZ() >> 4,
() -> finish(drops, null, null)));
}
return CompletableFuture.allOf(restores.toArray(CompletableFuture<?>[]::new));
}
void close() {
synchronized (pending) {
closed = true;
}
try {
flushAsync().get(5L, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while preserving pending Iris block loot.", interrupted);
} catch (ExecutionException | TimeoutException failure) {
throw new IllegalStateException("Unable to preserve pending Iris block loot before world shutdown.", failure);
}
} }
static int toMantleY(int worldY, int minHeight) { static int toMantleY(int worldY, int minHeight) {
@@ -136,32 +190,137 @@ final class WorldBlockDropRouter {
return mantleY + minHeight; return mantleY + minHeight;
} }
private List<IrisBlockDrops> filterDrops(KList<IrisBlockDrops> drops, BlockBreakEvent e, IrisData data) { private boolean isManagedWorld(World world) {
return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList()); return world.equals(BukkitWorldBinding.world(manager.getTarget().getWorld()));
} }
private List<IrisBlockDrops> resolveDropProviders(BlockBreakEvent event) { private CompletableFuture<BiomeEnvironment> environment(Location location) {
if (!mayHaveBlockDropRules(BukkitBlockState.of(event.getBlock().getBlockData()).materialKey())) { Engine engine = manager.getEngine();
return List.of(); int relativeY = toMantleY(location.getBlockY(), engine.getWorld().minHeight());
if (engine instanceof IrisEngine iris) {
return iris.getBiomeEnvironmentAsync(location.getBlockX(), relativeY, location.getBlockZ());
} }
IrisBiome biome = EngineBukkitOps.getBiome(manager.getEngine(), event.getBlock().getLocation()); try {
List<IrisBlockDrops> providers = filterDrops(biome.getBlockDrops(), event, manager.getData()); return CompletableFuture.completedFuture(engine.getBiomeEnvironment(location.getBlockX(), relativeY, location.getBlockZ()));
} catch (RuntimeException failure) {
return CompletableFuture.failedFuture(failure);
}
}
private boolean defer(DeferredDrops drops) {
pending.add(drops);
if (J.runAt(drops.location, () -> drops.environment.orTimeout(5L, TimeUnit.SECONDS).whenComplete((environment, failure) -> {
if (drops.finished.get()) {
return;
}
J.runRegionFuture(drops.location.getWorld(), drops.location.getBlockX() >> 4, drops.location.getBlockZ() >> 4,
() -> finish(drops, environment, failure)).whenComplete((ignored, schedulingFailure) -> {
if (schedulingFailure != null) {
IrisLogging.reportError("Unable to finalize pending Iris block loot on its owning thread.", schedulingFailure);
}
});
}), 1)) {
return true;
}
pending.remove(drops);
IrisLogging.reportError("Unable to schedule Iris block loot. Preserving normal block loot.",
new IllegalStateException("The owning region did not accept the block-drop task."));
return false;
}
private void schedule(Location location, Runnable task) {
if (!J.runAt(location, task, 1) && !J.isFolia()) {
J.s(task, 1);
}
}
private void finish(DeferredDrops drops, BiomeEnvironment environment, Throwable failure) {
if (!drops.finished.compareAndSet(false, true)) {
return;
}
pending.remove(drops);
if (!drops.allowed.getAsBoolean()) {
return;
}
KList<ItemStack> custom = new KList<>();
boolean replaceVanilla = false;
if (failure == null && environment != null) {
try {
DropResult result = EngineLifecycleTasks.call(manager.getEngine(), "bukkit_world_manager_block_drop_roll",
() -> rollDrops(drops.state, environment), null);
if (result != null) {
custom = result.items();
replaceVanilla = result.replaceVanilla();
}
} catch (Throwable error) {
failure = error;
}
}
if (failure != null) {
Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure;
String context = "Unable to resolve Iris block drops at " + drops.location + ". Preserving normal block loot.";
if (IrisLogging.warnOnce("block-drops:" + drops.location.getWorld().getUID() + ":" + cause.getMessage(), context)) {
IrisLogging.reportError(context, cause);
}
}
if (!replaceVanilla) {
for (NativeDrop original : drops.originals) {
routeDrops(List.of(original.stack()), drops.router, stack -> original.location().getWorld().dropItemNaturally(original.location(), stack));
}
}
Location location = drops.location.clone().add(.5, .5, .5);
routeDrops(custom, drops.router, stack -> location.getWorld().dropItemNaturally(location, stack));
}
private DropResult rollDrops(BlockData state, BiomeEnvironment environment) {
try (BiomeEnvironment.Scope ignored = manager.getEngine().openBiomeEnvironmentScope(environment)) {
KList<ItemStack> items = new KList<>();
boolean replaceVanilla = false;
for (IrisBlockDrops provider : resolveDropProviders(state, environment)) {
provider.fillDrops(false, items);
replaceVanilla |= provider.isReplaceVanillaDrops();
}
return new DropResult(items, replaceVanilla);
}
}
private List<IrisBlockDrops> resolveDropProviders(BlockData state, BiomeEnvironment environment) {
List<IrisBlockDrops> providers = new ArrayList<>();
addMatching(providers, environment.biome().getBlockDrops(), state, environment.data());
if (providers.stream().noneMatch(IrisBlockDrops::isSkipParents)) { if (providers.stream().noneMatch(IrisBlockDrops::isSkipParents)) {
IrisRegion region = EngineBukkitOps.getRegion(manager.getEngine(), event.getBlock().getLocation()); addMatching(providers, environment.region().getBlockDrops(), state, environment.data());
providers.addAll(filterDrops(region.getBlockDrops(), event, manager.getData())); addMatching(providers, environment.dimension().getBlockDrops(), state, environment.data());
providers.addAll(filterDrops(manager.getEngine().getDimension().getBlockDrops(), event, manager.getData()));
} }
return providers; return providers;
} }
private boolean mayHaveBlockDropRules(String material) { private void addMatching(List<IrisBlockDrops> providers, KList<IrisBlockDrops> candidates, BlockData state, IrisData data) {
for (IrisBlockDrops candidate : candidates) {
if (candidate.shouldDropFor(state, data)) {
providers.add(candidate);
}
}
}
private boolean mayHaveBlockDropRules(BlockData state) {
String material = BukkitBlockState.of(state).materialKey();
if (manager.getData().hasBlockDropRules(material)) { if (manager.getData().hasBlockDropRules(material)) {
return true; return true;
} }
if (manager.getEngine() instanceof IrisEngine engine) { if (manager.getEngine() instanceof IrisEngine engine) {
GenerationHistoryRuntimeRouter router = engine.getGenerationHistoryRuntimeRouter().orElse(null); return engine.getGenerationHistoryRuntimeRouter().map(router -> router.biomes().mayHaveBlockDropRules(material)).orElse(false);
return router != null && router.biomes().mayHaveBlockDropRules(material);
} }
return false; return false;
} }
private record DropResult(KList<ItemStack> items, boolean replaceVanilla) {
}
private record NativeDrop(Location location, ItemStack stack) {
}
private record DeferredDrops(Location location, BlockData state, BlockDropRouter router, BooleanSupplier allowed,
List<NativeDrop> originals, CompletableFuture<BiomeEnvironment> environment,
AtomicBoolean finished) {
}
} }
@@ -995,7 +995,7 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
zone.setCeiling(y - 1); zone.setCeiling(y - 1);
if (zone.isValid(resolver.height) if (zone.isValid(resolver.height)
&& (subterrainCell(worldX, floor, worldZ).occupied() && (subterrainCell(worldX, floor, worldZ).occupied()
|| !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ))) { || !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ, context))) {
if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) { if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) {
mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING); mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING);
} }
@@ -1112,6 +1112,17 @@ public class IrisCarveModifier extends EngineAssignedModifier<NativeBlockState>
&& !getEngine().isAdditionalTerrainOwned(x, y, z); && !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. */ /** The dimension's underground river bed palette, or null when nothing paints. */
private IrisRiverMaterialConfig undergroundBedMaterial() { private IrisRiverMaterialConfig undergroundBedMaterial() {
IrisHydrology hydrology = getDimension().getHydrology(); IrisHydrology hydrology = getDimension().getHydrology();
@@ -174,8 +174,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
if (isFallingWaterfallThroat(hydrology)) { if (isFallingWaterfallThroat(hydrology)) {
continue; continue;
} }
int surfaceFluidHeight = (int) Math.round( ChunkContext.TransitionTerrainColumn transitionTerrain = context.getTransitionTerrain(i, j);
getComplex().getRiverWaterSurfaceStream().get(realX, realZ)); int surfaceFluidHeight = transitionTerrain == null ? (int) Math.round(
getComplex().getRiverWaterSurfaceStream().get(realX, realZ)) : transitionTerrain.fluidHeight();
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) { if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
continue; continue;
@@ -231,7 +232,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
Terrain3DColumn terrainColumn = getComplex().terrainColumn(realX, realZ, hydrology); Terrain3DColumn terrainColumn = getComplex().terrainColumn(realX, realZ, hydrology);
if (terrainColumn != null) { if (terrainColumn != null) {
decorateTerrainLedges(output, terrainColumn, biome, i, j, realX, realZ); decorateTerrainLedges(output, terrainColumn, transitionTerrain, biome, i, j, realX, realZ);
} }
@@ -263,10 +264,15 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
} }
private void decorateTerrainLedges(Hunk<NativeBlockState> output, Terrain3DColumn column, private void decorateTerrainLedges(Hunk<NativeBlockState> output, Terrain3DColumn column,
ChunkContext.TransitionTerrainColumn transition,
IrisBiome biome, int localX, int localZ, int worldX, int worldZ) { IrisBiome biome, int localX, int localZ, int worldX, int worldZ) {
for (int span = 0; span + 1 < column.spanCount(); span++) { Terrain3DColumn ledges = transition == null ? column : transition.solids();
int floorY = column.floor(span); for (int span = 0; span + 1 < ledges.spanCount(); span++) {
int ceilingY = column.ceiling(span + 1); 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; int headroom = ceilingY - floorY - 1;
if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) { if (headroom < 1 || !PREDICATE_SOLID.test(output.getRaw(localX, floorY, localZ))) {
continue; continue;
@@ -280,6 +286,37 @@ public class IrisDecorantActuator extends EngineAssignedActuator<NativeBlockStat
} }
} }
static TerrainLedge resolveTerrainLedge(Terrain3DColumn natural, int span,
ChunkContext.TransitionTerrainColumn transition) {
if (transition == null) {
return new TerrainLedge(natural.floor(span), natural.ceiling(span + 1));
}
Terrain3DColumn blended = transition.solids();
int blendedFloor = blended.floor(span);
int blendedCeiling = blended.ceiling(span + 1);
for (int candidate = 0; candidate + 1 < natural.spanCount(); candidate++) {
if (Math.max(natural.floor(candidate), blendedFloor) + 1
< Math.min(natural.ceiling(candidate + 1), blendedCeiling)) {
return new TerrainLedge(blendedFloor, blendedCeiling);
}
}
return null;
}
static boolean isTransitionTerrainOpening(Terrain3DColumn natural,
ChunkContext.TransitionTerrainColumn transition, int y) {
Terrain3DColumn blended = transition.solids();
for (int span = 0; span + 1 < blended.spanCount(); span++) {
if (y > blended.floor(span) && y < blended.ceiling(span + 1)) {
return resolveTerrainLedge(natural, span, transition) != null;
}
}
return false;
}
record TerrainLedge(int floorY, int ceilingY) {
}
enum ShorelineDecorationMode { enum ShorelineDecorationMode {
NONE, NONE,
LEGACY, LEGACY,
@@ -3,6 +3,7 @@ package art.arcane.iris.generation.stage;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineAssignedComponent; import art.arcane.iris.generation.runtime.EngineAssignedComponent;
import art.arcane.iris.generation.runtime.EngineStage; 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.BoundaryColumnGeometry;
import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.BoundaryGeometryInfluence;
import art.arcane.iris.world.history.SavedTerrainChunk; import art.arcane.iris.world.history.SavedTerrainChunk;
@@ -85,6 +86,17 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen
blocks.setRaw(localX, offset, localZ, replacement); blocks.setRaw(localX, offset, localZ, replacement);
} }
context.setTerrainHeight(localX, localZ, floor); 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);
} }
} }
} }
@@ -44,14 +44,15 @@ import art.arcane.iris.generation.biome.IrisBiomeCustom;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.io.AtomicFileIO;
import art.arcane.iris.platform.bootstrap.ServerProperties; import art.arcane.iris.platform.bootstrap.ServerProperties;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import lombok.NonNull; import lombok.NonNull;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
@@ -99,11 +100,17 @@ public class ServerConfigurator {
} }
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
if (s.isConfigureSpigotTimeoutTime()) { if (s.isConfigureSpigotTimeoutTime()) {
J.attempt(ServerConfigurator::increaseKeepAliveSpigot); Throwable failure = J.attemptCatch(ServerConfigurator::increaseKeepAliveSpigot);
if (failure != null) {
IrisLogging.reportError("Unable to persist the Spigot timeout setting.", failure);
}
} }
if (s.isConfigurePaperWatchdogDelay()) { if (s.isConfigurePaperWatchdogDelay() && BukkitPlatform.isPaperServer()) {
J.attempt(ServerConfigurator::increasePaperWatchdog); Throwable failure = J.attemptCatch(ServerConfigurator::increasePaperWatchdog);
if (failure != null) {
IrisLogging.reportError("Unable to persist the Paper watchdog setting.", failure);
}
} }
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) { if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
@@ -230,35 +237,61 @@ public class ServerConfigurator {
} }
} }
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException { private static void increaseKeepAliveSpigot() throws IOException, ReflectiveOperationException {
File spigotConfig = new File("spigot.yml"); increaseKeepAliveSpigot(serverConfigurationOption("spigot-settings"), Bukkit.spigot().getConfig());
FileConfiguration f = new YamlConfiguration(); }
f.load(spigotConfig);
long tt = f.getLong("settings.timeout-time");
long spigotTimeout = TimeUnit.MINUTES.toSeconds(5); static void increaseKeepAliveSpigot(Path file, YamlConfiguration configuration) throws IOException {
long previous = configuration.getLong("settings.timeout-time");
long minimum = TimeUnit.MINUTES.toSeconds(5);
if (previous >= minimum) {
return;
}
persistTimeout(file, configuration, "settings.timeout-time", minimum);
IrisLogging.warn("Updated " + file + " settings.timeout-time: " + previous + " -> " + minimum
+ " seconds. Takes effect after a server restart.");
IrisLogging.warn("Disable autoConfiguration.configureSpigotTimeoutTime in iris.json to manage this setting yourself.");
}
if (tt < spigotTimeout) { private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException, ReflectiveOperationException {
IrisLogging.warn("Updating spigot.yml timeout-time: " + tt + " -> " + spigotTimeout + " (5 minutes)"); increasePaperWatchdog(serverConfigurationOption("paper-settings-directory").resolve("paper-global.yml"));
IrisLogging.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value."); }
f.set("settings.timeout-time", spigotTimeout);
f.save(spigotConfig); static void increasePaperWatchdog(Path file) throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(file.toFile());
long previous = configuration.getLong("watchdog.early-warning-delay");
long minimum = TimeUnit.MINUTES.toMillis(3);
if (previous >= minimum) {
return;
}
persistTimeout(file, configuration, "watchdog.early-warning-delay", minimum);
IrisLogging.warn("Updated " + file + " watchdog.early-warning-delay: " + previous + " -> " + minimum
+ " milliseconds. Takes effect after a server restart.");
IrisLogging.warn("Disable autoConfiguration.configurePaperWatchdogDelay in iris.json to manage this setting yourself.");
}
private static void persistTimeout(Path file, YamlConfiguration configuration, String key, long minimum) throws IOException {
Path target = file.toRealPath();
Object previous = configuration.isSet(key) ? configuration.get(key) : null;
configuration.set(key, minimum);
try {
AtomicFileIO.writeString(target, configuration.saveToString());
} catch (IOException | RuntimeException failure) {
configuration.set(key, previous);
throw failure;
} }
} }
private static void increasePaperWatchdog() throws IOException, InvalidConfigurationException { static Path serverConfigurationOption(String name) throws ReflectiveOperationException, IOException {
File spigotConfig = new File("config/paper-global.yml"); Server server = Bukkit.getServer();
FileConfiguration f = new YamlConfiguration(); Object console = server.getClass().getMethod("getServer").invoke(server);
f.load(spigotConfig); Object options = console.getClass().getField("options").get(console);
long tt = f.getLong("watchdog.early-warning-delay"); Object value = options.getClass().getMethod("valueOf", String.class).invoke(options, name);
if (!(value instanceof File file)) {
long watchdog = TimeUnit.MINUTES.toMillis(3); throw new IOException("Server option " + name + " did not provide a configuration path.");
if (tt < watchdog) {
IrisLogging.warn("Updating paper.yml watchdog early-warning-delay: " + tt + " -> " + watchdog + " (3 minutes)");
IrisLogging.warn("You can disable this change (autoconfigureServer) in Iris settings, then change back the value.");
f.set("watchdog.early-warning-delay", watchdog);
f.save(spigotConfig);
} }
return file.toPath().toAbsolutePath().normalize();
} }
public static KList<File> getDatapacksFolder() { public static KList<File> getDatapacksFolder() {
@@ -42,6 +42,7 @@ import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.IrisWorldManager;
import art.arcane.volmlib.util.cache.AtomicCache; import art.arcane.volmlib.util.cache.AtomicCache;
import art.arcane.iris.generation.chunk.TerrainChunk; import art.arcane.iris.generation.chunk.TerrainChunk;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
@@ -149,6 +150,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>(); private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>();
private volatile Engine engine; private volatile Engine engine;
private volatile Looper hotloader; private volatile Looper hotloader;
private volatile boolean studioUpdatesPaused;
private volatile StudioMode lastMode; private volatile StudioMode lastMode;
private volatile UUID lastJigsawStudioRequestId; private volatile UUID lastJigsawStudioRequestId;
private volatile boolean jigsawStudioActive; private volatile boolean jigsawStudioActive;
@@ -927,29 +929,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
boolean alreadyClosing = closing; boolean alreadyClosing = closing;
closing = true; closing = true;
cancelWatcherHotload(); cancelWatcherHotload();
AtomicBoolean dispatchAccepted = new AtomicBoolean();
CompletableFuture<Void> operation; CompletableFuture<Void> operation;
try { try {
operation = withExclusiveControlFuture(() -> { CompletableFuture<Void> preservedDrops = engine != null && engine.getWorldManager() instanceof IrisWorldManager manager
Looper activeHotloader = hotloader; ? manager.flushPendingBlockDropsAsync() : CompletableFuture.completedFuture(null);
hotloader = null; operation = preservedDrops.thenCompose(ignored -> {
if (isStudio() && activeHotloader != null) { CompletableFuture<Void> dispatched = withExclusiveControlFuture(() -> {
activeHotloader.interrupt(); Looper activeHotloader = hotloader;
try { hotloader = null;
activeHotloader.join(1000L); if (isStudio() && activeHotloader != null) {
} catch (InterruptedException e) { activeHotloader.interrupt();
Thread.currentThread().interrupt(); try {
IrisLogging.reportError(e); activeHotloader.join(1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
IrisLogging.reportError(e);
}
} }
}
Engine currentEngine = engine; Engine currentEngine = engine;
if (currentEngine != null && !currentEngine.isClosed()) { if (currentEngine != null && !currentEngine.isClosed()) {
currentEngine.close(); currentEngine.close();
} }
folder.clear(); folder.clear();
populators.clear(); populators.clear();
closeStartupTarget(); closeStartupTarget();
startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup.")); startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup."));
});
dispatchAccepted.set(true);
return dispatched;
}); });
} catch (Throwable throwable) { } catch (Throwable throwable) {
if (!alreadyClosing) { if (!alreadyClosing) {
@@ -968,9 +977,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
if (throwable == null) { if (throwable == null) {
future.complete(null); future.complete(null);
} else { } else {
// The close body already ran and tore the engine down; unlike the pre-dispatch // An accepted close may have torn the engine down. Reset the gate only when
// failure above, resetting the gate here would advertise a healthy generator // exclusive cleanup was never dispatched, or a CLOSED or FAILED engine would
// over a CLOSED or FAILED engine. Stay latched and surface the failure. // be advertised as a healthy generator.
if (!alreadyClosing && !dispatchAccepted.get()) {
closing = false;
}
closeFuture.compareAndSet(future, null); closeFuture.compareAndSet(future, null);
future.completeExceptionally(throwable); future.completeExceptionally(throwable);
} }
@@ -978,6 +990,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return future; return future;
} }
@Override
public CompletableFuture<Void> quiesceStudioUpdatesAsync() {
studioUpdatesPaused = true;
cancelWatcherHotload();
Looper activeHotloader = hotloader;
hotloader = null;
if (activeHotloader != null) {
activeHotloader.interrupt();
}
return withExclusiveControlFuture(() -> { });
}
@Override @Override
public void quiesceForServerShutdown() { public void quiesceForServerShutdown() {
cancelWatcherHotload(); cancelWatcherHotload();
@@ -1071,26 +1095,33 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
@Override @Override
public void hotload() { public void hotload() {
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
return; return;
} }
withExclusiveControl(() -> getEngine().hotload()); withExclusiveControl(() -> {
if (!studioUpdatesPaused && !closing) {
getEngine().hotload();
}
});
} }
private void hotloadFromWatcher() { private void hotloadFromWatcher() {
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
return; return;
} }
CompletableFuture<Void> pending = new CompletableFuture<>(); CompletableFuture<Void> pending = new CompletableFuture<>();
long queuedAt = System.nanoTime();
watcherHotload.set(pending); watcherHotload.set(pending);
try { try {
if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) if (initialEntryPending || studioUpdatesPaused || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)
|| Thread.currentThread().isInterrupted()) { || Thread.currentThread().isInterrupted()) {
pending.cancel(true); pending.cancel(true);
} else { } else {
J.a(() -> completeExclusiveControlFuture(loadLock, () -> { 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(); getEngine().hotload();
} }
}, pending)); }, pending));
@@ -66,6 +66,8 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
} }
} }
CompletableFuture<Void> quiesceStudioUpdatesAsync();
default void quiesceForServerShutdown() { default void quiesceForServerShutdown() {
} }
@@ -483,6 +483,7 @@ public final class StudioOpenCoordinator {
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
} }
}, },
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.quiesceStudioUpdatesAsync(),
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)), () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
() -> deleteFolder () -> deleteFolder
@@ -516,6 +517,7 @@ public final class StudioOpenCoordinator {
static CompletableFuture<Void> sequenceStudioClose( static CompletableFuture<Void> sequenceStudioClose(
Supplier<CompletableFuture<Void>> evacuate, Supplier<CompletableFuture<Void>> evacuate,
Runnable enterMaintenance, Runnable enterMaintenance,
Supplier<CompletableFuture<Void>> quiesceUpdates,
Supplier<CompletableFuture<Void>> unload, Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator, Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders Supplier<CompletableFuture<Void>> deleteFolders
@@ -523,8 +525,9 @@ public final class StudioOpenCoordinator {
return invokePhase(evacuate) return invokePhase(evacuate)
.thenCompose(ignored -> { .thenCompose(ignored -> {
enterMaintenance.run(); enterMaintenance.run();
return invokePhase(unload); return invokePhase(quiesceUpdates);
}) })
.thenCompose(ignored -> invokePhase(unload))
.thenCompose(ignored -> invokePhase(closeGenerator)) .thenCompose(ignored -> invokePhase(closeGenerator))
.thenCompose(ignored -> invokePhase(deleteFolders)); .thenCompose(ignored -> invokePhase(deleteFolders));
} }
@@ -20,12 +20,10 @@ package art.arcane.iris.studio.workspace;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.object.StudioOpenCoordinator; import art.arcane.iris.studio.object.StudioOpenCoordinator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.volmlib.util.exceptions.IrisException;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.world.task.J;
import lombok.Data; import lombok.Data;
import org.bukkit.World; import org.bukkit.World;
@@ -136,8 +134,6 @@ public class IrisProject {
); );
StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed); StudioOpenProgressReporter.startStudioOpenReporter(sender, stage, progress, complete, failed);
future.whenComplete((result, throwable) -> { future.whenComplete((result, throwable) -> {
World maintenanceWorld = null;
boolean maintenanceActive = false;
try { try {
if (throwable != null) { if (throwable != null) {
failed.set(true); 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())))); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_STUDIO_OPEN_FAILED, MessageArgument.untrusted("error", String.valueOf(error.getMessage()))));
return; return;
} }
maintenanceWorld = result.world();
if (maintenanceWorld != null) {
IrisToolbelt.beginWorldMaintenance(maintenanceWorld, "studio-open");
maintenanceActive = true;
}
} finally { } finally {
if (maintenanceActive && maintenanceWorld != null) {
World worldToRelease = maintenanceWorld;
J.a(() -> IrisToolbelt.endWorldMaintenance(worldToRelease, "studio-open"), 300);
}
complete.set(true); complete.set(true);
} }
}); });
@@ -365,13 +365,12 @@ public class IrisCreator {
worldKey, worldKey,
IrisWorldStorage.levelRoot().getName() IrisWorldStorage.levelRoot().getName()
); );
BukkitWorldConfiguration.register( bukkitRegistered = BukkitWorldConfiguration.register(
BUKKIT_YML, BUKKIT_YML,
registrationName, registrationName,
dimension, dimension,
seed seed
); ) == BukkitWorldConfiguration.Registration.CREATED;
bukkitRegistered = true;
World createdWorld = world; World createdWorld = world;
CompletableFuture<Void> multiverseRegistration = J.sfut( CompletableFuture<Void> multiverseRegistration = J.sfut(
() -> IrisServices.get(MultiverseCoreLink.class) () -> IrisServices.get(MultiverseCoreLink.class)
@@ -22,8 +22,8 @@ public record GenerationTransition(
if (algorithmVersion < 1) { if (algorithmVersion < 1) {
throw new IllegalArgumentException("Transition algorithm version must be positive."); throw new IllegalArgumentException("Transition algorithm version must be positive.");
} }
if (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS) { if (widthBlocks != 0 && (widthBlocks < MINIMUM_WIDTH_BLOCKS || widthBlocks > MAXIMUM_WIDTH_BLOCKS)) {
throw new IllegalArgumentException("Transition width must be between " throw new IllegalArgumentException("Transition width must be zero or between "
+ MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks."); + MINIMUM_WIDTH_BLOCKS + " and " + MAXIMUM_WIDTH_BLOCKS + " blocks.");
} }
if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) { if ((boundaryIdentity == null) != (terrainSignatureIdentity == null)) {
@@ -4,7 +4,11 @@ import art.arcane.iris.generation.runtime.IrisEngine;
import java.io.IOException; import java.io.IOException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; 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.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@@ -47,43 +51,69 @@ public final class IrisBoundarySignatureSampler implements GenerationBoundarySig
private static final class LiveCapture implements TerrainBoundarySignatureStore.SignatureSampler { private static final class LiveCapture implements TerrainBoundarySignatureStore.SignatureSampler {
private final IrisEngine engine; private final IrisEngine engine;
private final LinkedHashMap<Long, SavedTerrainChunk> chunks = new LinkedHashMap<>(32, 0.75F, true); private final LinkedHashMap<Long, CompletableFuture<SavedTerrainChunk>> chunks = new LinkedHashMap<>(32, 0.75F, true);
private LiveCapture(IrisEngine engine) { private LiveCapture(IrisEngine engine) {
this.engine = engine; this.engine = engine;
} }
@Override
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> 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 @Override
public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException { public TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException {
int chunkX = Math.floorDiv(blockX, 16); int chunkX = Math.floorDiv(blockX, 16);
int chunkZ = Math.floorDiv(blockZ, 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<Long, SavedTerrainChunk> oldest = chunks.firstEntry();
chunks.remove(oldest.getKey());
}
}
return terrain.column(blockX, blockZ);
}
private SavedTerrainChunk captureChunk(int chunkX, int chunkZ) throws IOException {
try { try {
return engine.getPlatformHooks().captureSavedTerrainChunk(engine, chunkX, chunkZ) return captureChunk(chunkX, chunkZ).get(30, TimeUnit.SECONDS).column(blockX, blockZ);
.get(30, TimeUnit.SECONDS);
} catch (InterruptedException failure) { } catch (InterruptedException failure) {
cancelPendingCaptures();
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IOException("Interrupted while capturing terrain at " + chunkX + "," + chunkZ, failure); 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); throw new IOException("Unable to capture terrain at " + chunkX + "," + chunkZ, failure);
} }
} }
private CompletableFuture<SavedTerrainChunk> captureChunk(int chunkX, int chunkZ) {
long key = ChunkGenerationOwnership.packChunk(chunkX, chunkZ);
CompletableFuture<SavedTerrainChunk> 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<Long, CompletableFuture<SavedTerrainChunk>> oldest = chunks.firstEntry();
chunks.remove(oldest.getKey());
oldest.getValue().cancel(false);
}
return terrain;
}
private void cancelPendingCaptures() {
for (CompletableFuture<SavedTerrainChunk> capture : chunks.values()) {
capture.cancel(false);
}
}
@Override @Override
public void close() throws IOException { public void close() throws IOException {
cancelPendingCaptures();
try { try {
checkpoint(engine); checkpoint(engine);
} finally { } finally {
@@ -66,6 +66,7 @@ public final class TerrainBoundarySignatureStore {
private static final int SHARD_FIXED_BODY_BYTES = 12; private static final int SHARD_FIXED_BODY_BYTES = 12;
private static final int CHECKSUM_BYTES = Integer.BYTES; private static final int CHECKSUM_BYTES = Integer.BYTES;
private static final int CELL_SIZE = 32; 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 SUPER_CELL_SIZE = 32;
private static final int MAXIMUM_CACHED_SHARDS = 64; private static final int MAXIMUM_CACHED_SHARDS = 64;
private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE; private static final int MAX_CELL_ENTRIES = CELL_SIZE * CELL_SIZE;
@@ -148,24 +149,34 @@ public final class TerrainBoundarySignatureStore {
LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates); LongArrays.quickSort(cellKeys, TerrainBoundarySignatureStore::compareCoordinates);
Long2ObjectOpenHashMap<CellReference> cells = new Long2ObjectOpenHashMap<>(cellKeys.length); Long2ObjectOpenHashMap<CellReference> cells = new Long2ObjectOpenHashMap<>(cellKeys.length);
int count = 0; int count = 0;
for (long cellKey : cellKeys) { for (int offset = 0; offset < cellKeys.length; offset += PREPARED_CELL_WINDOW) {
BitSet mask = columns.remove(cellKey); int end = Math.min(cellKeys.length, offset + PREPARED_CELL_WINDOW);
List<TerrainBoundarySignature> signatures = new ArrayList<>(mask.cardinality()); List<GenerationBoundary.ChunkCoordinate> chunks = new ArrayList<>((end - offset) * 4);
for (int index = mask.nextSetBit(0); index >= 0; index = mask.nextSetBit(index + 1)) { for (int index = offset; index < end; index++) {
int blockX = Math.addExact(Math.multiplyExact(cellX(cellKey), CELL_SIZE), index / CELL_SIZE); long cellKey = cellKeys[index];
int blockZ = Math.addExact(Math.multiplyExact(cellZ(cellKey), CELL_SIZE), index % CELL_SIZE); addSampledChunks(cellKey, columns.get(cellKey), chunks);
TerrainBoundarySignature signature = Objects.requireNonNull( }
sampler.sample(blockX, blockZ), "boundary signature" sampler.prepareChunks(List.copyOf(chunks));
); for (int cellIndex = offset; cellIndex < end; cellIndex++) {
if (signature.blockX() != blockX || signature.blockZ() != blockZ) { long cellKey = cellKeys[cellIndex];
throw new IOException("Boundary signature coordinates changed while sampling " BitSet mask = columns.remove(cellKey);
+ blockX + "," + blockZ + "."); List<TerrainBoundarySignature> 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); byte[] identity = computeIdentity(activationId, count, cellKeys, cells);
return publishCatalog(activationId, new PublishedCatalog( return publishCatalog(activationId, new PublishedCatalog(
@@ -174,6 +185,27 @@ public final class TerrainBoundarySignatureStore {
} }
} }
private static void addSampledChunks(long cellKey, BitSet columns,
List<GenerationBoundary.ChunkCoordinate> 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 { private Snapshot publishCatalog(long activationId, PublishedCatalog catalog) throws IOException {
forceDirectory(directory); forceDirectory(directory);
Path target = snapshotPath(activationId); Path target = snapshotPath(activationId);
@@ -678,6 +710,9 @@ public final class TerrainBoundarySignatureStore {
public interface SignatureSampler extends AutoCloseable { public interface SignatureSampler extends AutoCloseable {
TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException; TerrainBoundarySignature sample(int blockX, int blockZ) throws IOException;
default void prepareChunks(List<GenerationBoundary.ChunkCoordinate> chunks) throws IOException {
}
@Override @Override
default void close() throws IOException { default void close() throws IOException {
} }
@@ -32,11 +32,11 @@ public final class TransitionGenerationPlan {
throw new IllegalArgumentException("Unsupported generation transition algorithm version " throw new IllegalArgumentException("Unsupported generation transition algorithm version "
+ specification.algorithmVersion()); + specification.algorithmVersion());
} }
terrainSampler = new TransitionBoundarySampler( terrainSampler = specification.widthBlocks() == 0 ? null : new TransitionBoundarySampler(
specification.widthBlocks(), specification.widthBlocks(),
terrainSignatures terrainSignatures
); );
terrainBand = new TransitionBandIndex(boundary, terrainSampler); terrainBand = terrainSampler == null ? null : new TransitionBandIndex(boundary, terrainSampler);
newTerrain = TerrainSample.newTerrain(specification.widthBlocks()); newTerrain = TerrainSample.newTerrain(specification.widthBlocks());
} }
@@ -97,11 +97,11 @@ public final class TransitionGenerationPlan {
} }
public boolean hasTransitionAtChunk(int chunkX, int chunkZ) { 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) { public BoundaryGeometryInfluence geometryAt(int blockX, int blockZ) {
if (!terrainBand.contains(blockX >> 4, blockZ >> 4)) { if (!hasTransitionAtChunk(blockX >> 4, blockZ >> 4)) {
return BoundaryGeometryInfluence.none(); return BoundaryGeometryInfluence.none();
} }
return terrainSampler.geometryAt(blockX, blockZ); return terrainSampler.geometryAt(blockX, blockZ);
@@ -111,7 +111,7 @@ public final class TransitionGenerationPlan {
if (boundary.isHistoricalBlock(blockX, blockZ)) { if (boundary.isHistoricalBlock(blockX, blockZ)) {
return TerrainSample.historicalTerrain(); 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<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) { public Optional<String> historicalPhysicalBiomeKeyAt(int blockX, int blockY, int blockZ) {
@@ -137,15 +137,15 @@ public final class TransitionGenerationPlan {
} }
long terrainCatalogProbeCount() { long terrainCatalogProbeCount() {
return terrainSampler.catalogProbeCount(); return terrainSampler == null ? 0L : terrainSampler.catalogProbeCount();
} }
long terrainShardLoadCount() { long terrainShardLoadCount() {
return terrainSampler.shardLoadCount(); return terrainSampler == null ? 0L : terrainSampler.shardLoadCount();
} }
long terrainCandidateBuildCount() { long terrainCandidateBuildCount() {
return terrainSampler.candidateBuildCount(); return terrainSampler == null ? 0L : terrainSampler.candidateBuildCount();
} }
public record Specification( public record Specification(
@@ -171,8 +171,8 @@ public final class TransitionGenerationPlan {
if (oldEpochId.equals(newEpochId)) { if (oldEpochId.equals(newEpochId)) {
throw new IllegalArgumentException("Transition epoch IDs must differ"); throw new IllegalArgumentException("Transition epoch IDs must differ");
} }
if (widthBlocks <= 0) { if (widthBlocks < 0) {
throw new IllegalArgumentException("Transition width must be positive"); throw new IllegalArgumentException("Transition width cannot be negative");
} }
if (algorithmVersion < 1) { if (algorithmVersion < 1) {
throw new IllegalArgumentException("Transition algorithm version must be positive"); throw new IllegalArgumentException("Transition algorithm version must be positive");
@@ -144,7 +144,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
this.chunkAccessMode = chunkAsyncMethodSelection.mode(); this.chunkAccessMode = chunkAsyncMethodSelection.mode();
int detectedWorkerPoolThreads = resolveWorkerPoolThreads(); int detectedWorkerPoolThreads = resolveWorkerPoolThreads();
int detectedCpuThreads = Math.max(1, Runtime.getRuntime().availableProcessors()); 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); !foliaRuntime && !strictSerial && detectedWorkerPoolThreads > 0);
int workerThreadsForCap = foliaRuntime int workerThreadsForCap = foliaRuntime
? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads) ? resolveFoliaConcurrencyWorkerThreads(detectedWorkerPoolThreads, detectedCpuThreads, pregenWorkerThreads)
@@ -166,7 +166,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int configuredThreads = foliaRuntime int configuredThreads = foliaRuntime
? computeFoliaRecommendedCap(workerThreadsForCap) ? computeFoliaRecommendedCap(workerThreadsForCap)
: computePaperLikeRecommendedCap(workerThreadsForCap); : computePaperLikeRecommendedCap(workerThreadsForCap);
configuredThreads = heapLimitedConcurrency(configuredThreads, Runtime.getRuntime().maxMemory()); configuredThreads = heapLimitedConcurrency(configuredThreads, MantleHeapPressure.memoryBudgetBytes());
this.threads = selectConcurrencyCap(configuredThreads, strictSerial); this.threads = selectConcurrencyCap(configuredThreads, strictSerial);
this.workerPoolThreads = detectedWorkerPoolThreads; this.workerPoolThreads = detectedWorkerPoolThreads;
this.runtimeCpuThreads = detectedCpuThreads; this.runtimeCpuThreads = detectedCpuThreads;
@@ -304,7 +304,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
binding.releaseChunk(world, chunkX, chunkZ); 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); minRegionX, minRegionZ, maxRegionX, maxRegionZ);
} catch (Throwable e) { } catch (Throwable e) {
PregenDiagnostics.probeFailed("pregen chunk retention for world " + world.getName(), e); PregenDiagnostics.probeFailed("pregen chunk retention for world " + world.getName(), e);
@@ -996,7 +996,14 @@ public class AsyncPregenMethod implements PregeneratorMethod {
PregenAdmissionGate.Wait waited; PregenAdmissionGate.Wait waited;
try { 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) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return; return;
@@ -445,7 +445,7 @@ public class IrisPregenerator {
try { try {
generator.reclaimMemory(); generator.reclaimMemory();
} catch (Throwable e) { } 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 { try {
@@ -19,6 +19,7 @@
package art.arcane.iris.world.pregen; package art.arcane.iris.world.pregen;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.diagnostics.ContainerMemory;
import javax.management.MBeanServer; import javax.management.MBeanServer;
import javax.management.ObjectName; import javax.management.ObjectName;
@@ -31,6 +32,8 @@ import java.util.function.LongSupplier;
public final class MantleHeapPressure { public final class MantleHeapPressure {
private static final double HIGH_WATER = 0.92D; private static final double HIGH_WATER = 0.92D;
private static final double LOW_WATER = 0.82D; 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 long MAXIMUM_HYSTERESIS_MS = 60_000L;
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName(); private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer( 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 // 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. // anything the operator did. What is actionable is the wait budget being exceeded.
(double fraction) -> IrisLogging.info( (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), fraction * 100.0D),
(String context, Throwable failure) -> IrisLogging.reportError(context, failure))); (String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate( private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate(
@@ -51,11 +54,47 @@ public final class MantleHeapPressure {
MAXIMUM_HYSTERESIS_MS, MAXIMUM_HYSTERESIS_MS,
System::currentTimeMillis, System::currentTimeMillis,
PANIC_RECLAIMER::resetEpisode); 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() { private MantleHeapPressure() {
} }
public static double usedFraction() { 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(); MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
long max = heap.getMax(); long max = heap.getMax();
if (max <= 0L) { if (max <= 0L) {
@@ -71,7 +110,9 @@ public final class MantleHeapPressure {
} }
public static boolean overHighWater() { 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) { public static double reclaimUrgency(double fraction) {
@@ -86,7 +127,7 @@ public final class MantleHeapPressure {
public static void requestPanicReclaim() { public static void requestPanicReclaim() {
double fraction = usedFraction(); double fraction = usedFraction();
PANIC_RECLAIMER.request(fraction, PRESSURE_GATE.update(fraction)); PANIC_RECLAIMER.request(fraction, overHighWater());
} }
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception { static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
@@ -215,16 +215,16 @@ public final class PregenMantleBackpressure {
if (elapsed >= nextWarningElapsed) { if (elapsed >= nextWarningElapsed) {
nextWarningElapsed = elapsed + warningInterval; nextWarningElapsed = elapsed + warningInterval;
lastLog = logNow; lastLog = logNow;
IrisLogging.warn("Pregen heap pressure is still blocking new chunk submissions after " + elapsed + "ms at " IrisLogging.warn("Pregen memory pressure is still blocking new chunk submissions after " + elapsed + "ms at "
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; waiting for headroom. " + MantleHeapPressure.describe() + "; waiting for headroom. "
+ diagnostics.get()); + diagnostics.get());
onBudgetTimeout.run(); onBudgetTimeout.run();
} }
if (logNow - lastLog >= LOG_INTERVAL_MS) { if (logNow - lastLog >= LOG_INTERVAL_MS) {
lastLog = logNow; lastLog = logNow;
IrisLogging.info("Pregen heap pressure: pausing generation at " IrisLogging.info("Pregen memory pressure: pausing generation at "
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom" + MantleHeapPressure.describe() + "; evicting tectonic plates and waiting for headroom"
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + "."); + (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
} }
@@ -289,6 +289,9 @@ public class J {
runnable.run(); runnable.run();
return true; return true;
} }
if (!canSchedule()) {
return false;
}
if (runRegionImmediate(world, chunkX, chunkZ, runnable)) { if (runRegionImmediate(world, chunkX, chunkZ, runnable)) {
return true; return true;
@@ -383,6 +386,9 @@ public class J {
if (delayTicks <= 0) { if (delayTicks <= 0) {
return runRegion(world, chunkX, chunkZ, runnable); return runRegion(world, chunkX, chunkZ, runnable);
} }
if (!canSchedule()) {
return false;
}
if (runRegionDelayed(world, chunkX, chunkZ, runnable, delayTicks)) { if (runRegionDelayed(world, chunkX, chunkZ, runnable, delayTicks)) {
return true; return true;
@@ -1,6 +1,9 @@
package art.arcane.iris.world.tree; package art.arcane.iris.world.tree;
@FunctionalInterface import java.util.Collection;
public interface BlockDropRouter { public interface BlockDropRouter {
Collection<?> captureNativeDrops();
boolean routeDrop(Object drop); boolean routeDrop(Object drop);
} }
@@ -12,6 +12,16 @@ import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public class IrisSettingsDefaultsTest { public class IrisSettingsDefaultsTest {
@Test
public void updateChecksDefaultOnAndPersistAnExplicitOptOut() {
assertTrue(IrisSettings.parseHotloadSnapshot("{}").getGeneral().isUpdateNotifications());
IrisSettings optedOut = IrisSettings.parseHotloadSnapshot(
"{\"general\":{\"updateNotifications\":false}}");
assertFalse(optedOut.getGeneral().isUpdateNotifications());
assertFalse(new Gson().fromJson(new Gson().toJson(optedOut), IrisSettings.class)
.getGeneral().isUpdateNotifications());
}
@Test @Test
public void regionBudgetLeavesHeapForHydrologyAndServerChunks() { public void regionBudgetLeavesHeapForHydrologyAndServerChunks() {
IrisSettings.IrisSettingsPregen settings = new IrisSettings.IrisSettingsPregen(); IrisSettings.IrisSettingsPregen settings = new IrisSettings.IrisSettingsPregen();
@@ -61,6 +71,19 @@ public class IrisSettingsDefaultsTest {
assertEquals(8_192, settings.getGenerationTransitionWidthBlocks()); 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 @Test
public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() { public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() {
IrisSettings previous = IrisSettings.settings; IrisSettings previous = IrisSettings.settings;
@@ -5,9 +5,12 @@ import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.SeedManager; 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.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms; 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.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
public class IrisDecoratorCaveContextTest { public class IrisDecoratorCaveContextTest {
@ClassRule @ClassRule
@@ -105,6 +109,48 @@ public class IrisDecoratorCaveContextTest {
assertFalse(IrisSurfaceDecorator.skipsFluid(null)); 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<NativeBlockState> 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 @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() { public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() {
@@ -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<StudioGenerationUpdate> updates = mockStatic(StudioGenerationUpdate.class);
MockedStatic<IrisBoundarySignatureSampler> signatures = mockStatic(IrisBoundarySignatureSampler.class);
MockedStatic<IrisSettings> 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();
}
}
}
@@ -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<Void> 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<StudioGenerationUpdate> updates = mockStatic(StudioGenerationUpdate.class);
MockedStatic<IrisBoundarySignatureSampler> signatures = mockStatic(IrisBoundarySignatureSampler.class);
MockedStatic<IrisStructureLocator> structures = mockStatic(IrisStructureLocator.class);
MockedStatic<StructureReachability> reachability = mockStatic(StructureReachability.class);
MockedStatic<IrisSettings> 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);
}
}
@@ -0,0 +1,132 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.SavedBiomeRuntime;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
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;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class IrisEngineBiomeEnvironmentAsyncTest {
@Test
public void uncachedBiomeWaitsAsynchronouslyAndUsesPreparedVolumeWithWorldHeight() throws Exception {
Fixture fixture = new Fixture();
CompletableFuture<SavedBiomeRuntime.ReadSession> pending = new CompletableFuture<>();
SavedBiomeRuntime.ReadSession session = mock(SavedBiomeRuntime.ReadSession.class);
BiomeEnvironment environment = environment(7L);
when(fixture.biomes.readChunkAsync(-2, 2)).thenReturn(pending);
when(session.resolve(-17, -59, 33, false)).thenReturn(Optional.of(environment));
CompletableFuture<BiomeEnvironment> reading = fixture.engine.getBiomeEnvironmentAsync(-17, 5, 33);
assertFalse(reading.isDone());
verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt());
pending.complete(session);
assertSame(environment, reading.get(5L, TimeUnit.SECONDS));
verify(session).resolve(-17, -59, 33, false);
verify(fixture.biomes, never()).resolve(anyInt(), anyInt(), anyInt(), anyBoolean());
}
@Test
public void permanentSavedBiomeFailureCompletesTheFutureExceptionally() throws Exception {
Fixture fixture = new Fixture();
SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Saved pack is missing", false);
when(fixture.biomes.readChunkAsync(0, 0)).thenReturn(CompletableFuture.failedFuture(failure));
CompletableFuture<BiomeEnvironment> reading = fixture.engine.getBiomeEnvironmentAsync(0, 64, 0);
assertSame(failure, assertThrows(CompletionException.class, reading::join).getCause());
verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt());
}
@Test
public void preparedEmptyRecordFallsBackWithoutReenteringTheSavedQueryCache() throws Exception {
Fixture fixture = new Fixture();
SavedBiomeRuntime.ReadSession session = mock(SavedBiomeRuntime.ReadSession.class);
GenerationHistoryRuntimeRouter.CoordinateScope scope = mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
BiomeEnvironment expected = environment(0L);
when(fixture.biomes.readChunkAsync(0, 0)).thenReturn(CompletableFuture.completedFuture(session));
when(session.resolve(3, 7, 4, false)).thenReturn(Optional.empty());
when(fixture.router.openReadScope(3, 4)).thenReturn(scope);
when(fixture.engine.getBiome(3, 71, 4)).thenReturn(expected.biome());
when(fixture.engine.getRegion(3, 71, 4)).thenReturn(expected.region());
when(fixture.engine.getDimension()).thenReturn(expected.dimension());
when(fixture.engine.getData()).thenReturn(expected.data());
assertEquals(expected, fixture.engine.getBiomeEnvironmentAsync(3, 71, 4).get(5L, TimeUnit.SECONDS));
verify(scope).close();
verify(fixture.engine, never()).getBiomeEnvironment(anyInt(), anyInt(), anyInt());
verify(fixture.biomes, never()).resolve(anyInt(), anyInt(), anyInt(), anyBoolean());
}
@Test
public void scopedLookupUsesCurrentRuntimeAndDoesNotReadSavedBiomes() throws Exception {
Fixture fixture = new Fixture();
BiomeEnvironment expected = environment(4L);
when(fixture.engine.hasGenerationRuntimeScope()).thenReturn(true);
when(fixture.engine.getBiomeEnvironment(1, 2, 3)).thenReturn(expected);
CompletableFuture<BiomeEnvironment> reading = fixture.engine.getBiomeEnvironmentAsync(1, 2, 3);
assertTrue(reading.isDone());
assertSame(expected, reading.join());
verifyNoInteractions(fixture.biomes);
}
@Test
public void immediateLookupFailureIsReturnedInTheFuture() throws Exception {
Fixture fixture = new Fixture();
IllegalStateException failure = new IllegalStateException("Generation closed");
when(fixture.engine.hasGenerationRuntimeScope()).thenReturn(true);
when(fixture.engine.getBiomeEnvironment(1, 2, 3)).thenThrow(failure);
CompletableFuture<BiomeEnvironment> reading = fixture.engine.getBiomeEnvironmentAsync(1, 2, 3);
assertSame(failure, assertThrows(CompletionException.class, reading::join).getCause());
verifyNoInteractions(fixture.biomes);
}
private static BiomeEnvironment environment(long activationId) {
return new BiomeEnvironment(activationId, mock(IrisBiome.class), mock(IrisRegion.class),
mock(IrisDimension.class), mock(IrisData.class));
}
private static final class Fixture {
private final IrisEngine engine = mock(IrisEngine.class);
private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
private final SavedBiomeRuntime biomes = mock(SavedBiomeRuntime.class);
private Fixture() throws Exception {
Field field = IrisEngine.class.getDeclaredField("generationHistoryRuntimeRouter");
field.setAccessible(true);
field.set(engine, router);
when(router.biomes()).thenReturn(biomes);
when(engine.getWorld()).thenReturn(IrisWorld.builder().name("test").minHeight(-64).maxHeight(320).build());
doCallRealMethod().when(engine).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt());
}
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.runtime; package art.arcane.iris.generation.runtime;
import art.arcane.iris.world.tree.BlockDropRouter;
import org.junit.Test; import org.junit.Test;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
@@ -74,16 +75,12 @@ public class IrisWorldManagerMarkerTest {
public void deferredDropsUseTheRouteAndFallbackOnlyWhenDeclined() { public void deferredDropsUseTheRouteAndFallbackOnlyWhenDeclined() {
List<String> routed = new ArrayList<>(); List<String> routed = new ArrayList<>();
List<String> fallback = new ArrayList<>(); List<String> fallback = new ArrayList<>();
BlockDropRouter router = mock(BlockDropRouter.class);
when(router.routeDrop("routed")).thenAnswer(invocation -> routed.add(invocation.getArgument(0)));
WorldBlockDropRouter.routeDrops( WorldBlockDropRouter.routeDrops(
List.of("routed", "fallback"), List.of("routed", "fallback"),
drop -> { router,
if (drop.equals("routed")) {
routed.add((String) drop);
return true;
}
return false;
},
fallback::add fallback::add
); );
@@ -2,36 +2,45 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.tree.BlockDropRouter;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.world.loot.IrisBlockDrops; import art.arcane.iris.world.loot.IrisBlockDrops;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.world.IrisWorld; import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.generation.EngineBukkitOps;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.GameMode;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockDropItemEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.Collection;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Queue; import java.util.Queue;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
@@ -43,191 +52,241 @@ import static org.mockito.Mockito.when;
public class WorldBlockDropRouterTest { public class WorldBlockDropRouterTest {
@Test @Test
public void packWithoutDropRulesAllowsTheFirstCreativeAndSurvivalBreakWhileBiomesAreCold() { public void coldGrassBreakNeverCancelsOrChangesNativeDropHandling() {
for (GameMode mode : new GameMode[] {GameMode.CREATIVE, GameMode.SURVIVAL}) {
try (Fixture fixture = new Fixture()) {
when(fixture.data.hasBlockDropRules("minecraft:stone")).thenReturn(false);
when(fixture.data.hasBlockDropRules("minecraft:grass_block")).thenReturn(true);
when(fixture.player.getGameMode()).thenReturn(mode);
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location))
.thenThrow(new SavedBiomeUnavailableException("Biome loading", true));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
assertFalse(mode.name(), event.isCancelled());
assertTrue(event.isDropItems());
fixture.queries.verifyNoInteractions();
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
assertEquals(1, fixture.delayed.size());
fixture.delayed.remove().run();
assertEquals(1, fixture.markers.size());
}
}
}
@Test
public void coldBiomeCancelsBeforeChangingDropsOrSchedulingWork() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) fixture.environment = new CompletableFuture<>();
.thenThrow(new SavedBiomeUnavailableException("Biome loading", true)); BlockBreakEvent event = new BlockBreakEvent(fixture.block, fixture.player);
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event); fixture.router.onBlockBreak(event);
assertTrue(event.isCancelled());
assertTrue(event.isDropItems());
fixture.assertNoSideEffects();
fixture.queries.verify(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location), never());
}
}
@Test
public void coldRegionCancelsBeforeFillingAlreadyMatchedBiomeDrops() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location))
.thenThrow(new SavedBiomeUnavailableException("Region loading", true));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
verify(fixture.drops).shouldDropFor(fixture.blockData, fixture.data);
assertTrue(event.isCancelled());
assertTrue(event.isDropItems());
fixture.assertNoSideEffects();
}
}
@Test
public void alreadyCancelledBreakSkipsSavedLookups() {
try (Fixture fixture = new Fixture()) {
BlockBreakEvent event = fixture.event();
event.setCancelled(true);
fixture.router.onBlockBreak(event);
assertTrue(event.isCancelled());
fixture.queries.verifyNoInteractions();
fixture.assertNoSideEffects();
}
}
@Test
public void otherWorldBreakSkipsSavedLookups() {
try (Fixture fixture = new Fixture()) {
when(fixture.block.getWorld()).thenReturn(mock(World.class));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
assertFalse(event.isCancelled()); assertFalse(event.isCancelled());
fixture.queries.verifyNoInteractions(); assertTrue(event.isDropItems());
fixture.assertNoSideEffects(); verify(fixture.engine, never()).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt());
} }
} }
@Test @Test
public void freshAttemptAfterLoadingProducesDropsOnceWithoutReplayingTheCancelledEvent() { public void firstColdBreakReplacesNativeDropsOnceAfterSavedEnvironmentArrives() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)) fixture.environment = new CompletableFuture<>();
.thenThrow(new SavedBiomeUnavailableException("Biome loading", true)) BlockDropItemEvent event = fixture.event();
.thenReturn(fixture.biome); fixture.router.onBlockDropItems(event);
BlockBreakEvent cold = fixture.event(); assertFalse(event.isCancelled());
fixture.router.onBlockBreak(cold); assertTrue(event.getItems().isEmpty());
fixture.assertNoSideEffects(); fixture.flush();
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
BlockBreakEvent retry = fixture.event(); fixture.environment.complete(fixture.biomeEnvironment);
fixture.router.onBlockBreak(retry); fixture.flush();
verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom);
assertTrue(cold.isCancelled()); verify(fixture.world, never()).dropItemNaturally(any(Location.class), eq(fixture.nativeStack));
assertTrue(cold.isDropItems());
assertFalse(retry.isCancelled());
assertFalse(retry.isDropItems());
assertEquals(1, fixture.delayed.size());
verify(fixture.drops).fillDrops(eq(false), any()); verify(fixture.drops).fillDrops(eq(false), any());
fixture.delayed.remove().run(); fixture.router.close();
fixture.flush();
verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom);
}
}
verify(fixture.world).dropItemNaturally(fixture.location.clone().add(.5, .5, .5), fixture.item); @Test
assertEquals(1, fixture.markers.size()); public void additiveRulesPreserveServerCalculatedLootIncludingQuantityAndEnchantments() {
try (Fixture fixture = new Fixture()) {
when(fixture.drops.isReplaceVanillaDrops()).thenReturn(false);
fixture.router.onBlockDropItems(fixture.event());
fixture.flush();
verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack);
verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom);
}
}
@Test
public void rejectedSchedulerLeavesNativeLootInItsOriginalEvent() {
try (Fixture fixture = new Fixture()) {
fixture.scheduling.when(() -> J.runAt(eq(fixture.location), any(Runnable.class), eq(1))).thenReturn(false);
BlockDropItemEvent event = fixture.event();
fixture.router.onBlockDropItems(event);
assertFalse(event.isCancelled());
assertEquals(1, event.getItems().size());
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
}
}
@Test
public void noNativeItemsStillProducesMatchingCustomDrops() {
try (Fixture fixture = new Fixture()) {
BlockDropItemEvent event = fixture.event();
event.getItems().clear();
fixture.router.onBlockDropItems(event);
fixture.flush();
verify(fixture.world).dropItemNaturally(fixture.center, fixture.custom);
}
}
@Test
public void noMatchingRulesLeaveNativeEventUntouchedWithoutSavedLookups() {
try (Fixture fixture = new Fixture()) {
when(fixture.data.hasBlockDropRules(anyString())).thenReturn(false);
BlockDropItemEvent event = fixture.event();
fixture.router.onBlockDropItems(event);
assertEquals(1, event.getItems().size());
verify(fixture.engine, never()).getBiomeEnvironmentAsync(anyInt(), anyInt(), anyInt());
assertTrue(fixture.delayed.isEmpty()); assertTrue(fixture.delayed.isEmpty());
} }
} }
@Test @Test
public void permanentSavedBiomeFailureCancelsAndPropagates() { public void cancelledDropEventRemainsCancelledAndDoesNotRollOrEmitLoot() {
verifyReportedFailure(new SavedBiomeUnavailableException("Saved biome missing", false));
}
@Test
public void loadingWithSuppressedFailureCancelsAndPropagates() {
SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Biome loading", true);
failure.addSuppressed(new IllegalStateException("Saved storage cleanup failed"));
verifyReportedFailure(failure);
}
@Test
public void protectionProbeCancelledAfterDispatchProducesNoFinalDropsOrMarkerCleanup() {
try (Fixture fixture = new Fixture()) { try (Fixture fixture = new Fixture()) {
BlockBreakEvent probe = new ProtectionProbe(fixture.block, fixture.player); fixture.environment = new CompletableFuture<>();
probe.setDropItems(false); BlockDropItemEvent event = fixture.event();
fixture.router.onBlockDropItems(event);
fixture.router.onBlockBreak(probe); event.setCancelled(true);
assertFalse(probe.isCancelled()); fixture.environment.complete(fixture.biomeEnvironment);
assertFalse(probe.isDropItems()); fixture.flush();
assertEquals(1, fixture.delayed.size());
probe.setCancelled(true);
fixture.delayed.remove().run();
verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
assertTrue(fixture.markers.isEmpty());
verify(fixture.manager, never()).getMantle();
}
}
private void verifyReportedFailure(SavedBiomeUnavailableException failure) {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)).thenThrow(failure);
BlockBreakEvent event = fixture.event();
assertSame(failure, assertThrows(SavedBiomeUnavailableException.class,
() -> fixture.router.onBlockBreak(event)));
assertTrue(event.isCancelled()); assertTrue(event.isCancelled());
assertTrue(event.isDropItems()); verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
fixture.assertNoSideEffects(); verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
} }
} }
private static final class ProtectionProbe extends BlockBreakEvent { @Test
private ProtectionProbe(Block block, Player player) { public void permanentFailurePreservesNormalLootWithoutBlockingOrDiscardingIt() {
try (Fixture fixture = new Fixture()) {
fixture.environment = CompletableFuture.failedFuture(new SavedBiomeUnavailableException("Saved biome missing", false));
BlockDropItemEvent event = fixture.event();
fixture.router.onBlockDropItems(event);
fixture.flush();
assertFalse(event.isCancelled());
verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack);
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
fixture.logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)));
}
}
@Test
public void managerCloseRestoresPendingNativeLootOnlyOnce() {
try (Fixture fixture = new Fixture()) {
fixture.environment = new CompletableFuture<>();
fixture.router.onBlockDropItems(fixture.event());
fixture.router.close();
fixture.environment.complete(fixture.biomeEnvironment);
fixture.flush();
verify(fixture.world).dropItemNaturally(fixture.center, fixture.nativeStack);
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
}
}
@Test
public void historicalContextOwnsMatchingAndAllParentRules() {
try (Fixture fixture = new Fixture()) {
IrisData historicData = mock(IrisData.class);
IrisRegion historicRegion = mock(IrisRegion.class);
IrisDimension historicDimension = mock(IrisDimension.class);
IrisBlockDrops regionDrops = mock(IrisBlockDrops.class);
IrisBlockDrops dimensionDrops = mock(IrisBlockDrops.class);
when(historicRegion.getBlockDrops()).thenReturn(new KList<>(regionDrops));
when(historicDimension.getBlockDrops()).thenReturn(new KList<>(dimensionDrops));
when(fixture.drops.shouldDropFor(fixture.state, historicData)).thenReturn(true);
when(regionDrops.shouldDropFor(fixture.state, historicData)).thenReturn(true);
when(dimensionDrops.shouldDropFor(fixture.state, historicData)).thenReturn(true);
BiomeEnvironment historic = new BiomeEnvironment(2L, fixture.biome, historicRegion, historicDimension, historicData);
fixture.environment = CompletableFuture.completedFuture(historic);
fixture.router.onBlockDropItems(fixture.event());
fixture.flush();
verify(fixture.engine).openBiomeEnvironmentScope(historic);
verify(regionDrops).fillDrops(eq(false), any());
verify(dimensionDrops).fillDrops(eq(false), any());
verify(fixture.engine, never()).getDimension();
verify(fixture.drops, never()).shouldDropFor(fixture.state, fixture.data);
}
}
@Test
public void skipParentsStillPreventsRegionAndDimensionLoot() {
try (Fixture fixture = new Fixture()) {
when(fixture.drops.isSkipParents()).thenReturn(true);
fixture.router.onBlockDropItems(fixture.event());
fixture.flush();
verify(fixture.region, never()).getBlockDrops();
verify(fixture.dimension, never()).getBlockDrops();
}
}
@Test
public void routedTreeBreakPreservesInventoryDestinationWithColdMetadata() {
try (Fixture fixture = new Fixture()) {
fixture.environment = new CompletableFuture<>();
RoutedBreak event = new RoutedBreak(fixture.block, fixture.player);
fixture.router.onBlockBreak(event);
assertFalse(event.isCancelled());
assertFalse(event.isDropItems());
fixture.environment.complete(fixture.biomeEnvironment);
fixture.flush();
assertEquals(List.of(fixture.custom), event.routed);
verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
}
}
@Test
public void cancelledRoutedProbeNeverEmitsDeferredLoot() {
try (Fixture fixture = new Fixture()) {
fixture.environment = new CompletableFuture<>();
RoutedBreak event = new RoutedBreak(fixture.block, fixture.player);
fixture.router.onBlockBreak(event);
event.setCancelled(true);
fixture.environment.complete(fixture.biomeEnvironment);
fixture.flush();
assertTrue(event.routed.isEmpty());
verify(fixture.drops, never()).fillDrops(anyBoolean(), any());
assertTrue(fixture.markers.isEmpty());
}
}
private static final class RoutedBreak extends BlockBreakEvent implements BlockDropRouter {
private final List<Object> routed = new ArrayList<>();
private RoutedBreak(Block block, Player player) {
super(block, player); super(block, player);
} }
@Override
public Collection<ItemStack> captureNativeDrops() {
return List.of();
}
@Override
public boolean routeDrop(Object drop) {
return routed.add(drop);
}
} }
private static final class Fixture implements AutoCloseable { private static final class Fixture implements AutoCloseable {
private final IrisWorldManager manager = mock(IrisWorldManager.class); private final IrisWorldManager manager = mock(IrisWorldManager.class);
private final Engine engine = mock(Engine.class); private final IrisEngine engine = mock(IrisEngine.class);
private final IrisData data = mock(IrisData.class); private final IrisData data = mock(IrisData.class);
private final World world = mock(World.class); private final World world = mock(World.class);
private final Block block = mock(Block.class); private final Block block = mock(Block.class);
private final BlockData blockData = mock(BlockData.class); private final BlockState blockState = mock(BlockState.class);
private final BlockData state = mock(BlockData.class);
private final Player player = mock(Player.class); private final Player player = mock(Player.class);
private final Location location = new Location(world, 16, 72, 32); private final Location location = new Location(world, 16, 72, 32);
private final Location center = location.clone().add(.5, .5, .5);
private final Location nativeLocation = location.clone().add(.2, .3, .4);
private final IrisBiome biome = mock(IrisBiome.class); private final IrisBiome biome = mock(IrisBiome.class);
private final IrisRegion region = mock(IrisRegion.class);
private final IrisDimension dimension = mock(IrisDimension.class);
private final IrisBlockDrops drops = mock(IrisBlockDrops.class); private final IrisBlockDrops drops = mock(IrisBlockDrops.class);
private final ItemStack item = mock(ItemStack.class); private final ItemStack custom = mock(ItemStack.class);
private final ItemStack nativeStack = mock(ItemStack.class);
private final BiomeEnvironment biomeEnvironment = new BiomeEnvironment(1L, biome, region, dimension, data);
private CompletableFuture<BiomeEnvironment> environment = CompletableFuture.completedFuture(biomeEnvironment);
private final WorldBlockDropRouter router = new WorldBlockDropRouter(manager); private final WorldBlockDropRouter router = new WorldBlockDropRouter(manager);
private final Queue<Runnable> delayed = new ArrayDeque<>(); private final Queue<Runnable> delayed = new ArrayDeque<>();
private final Queue<Runnable> markers = new ArrayDeque<>(); private final Queue<Runnable> markers = new ArrayDeque<>();
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class); private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final MockedStatic<EngineBukkitOps> queries = mockStatic(EngineBukkitOps.class);
private final MockedStatic<J> scheduling = mockStatic(J.class); private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class);
private Fixture() { private Fixture() {
IrisWorld irisWorld = mock(IrisWorld.class); IrisWorld irisWorld = mock(IrisWorld.class);
EngineTarget target = mock(EngineTarget.class); EngineTarget target = mock(EngineTarget.class);
IrisRegion region = mock(IrisRegion.class);
IrisDimension dimension = mock(IrisDimension.class);
when(manager.getEngine()).thenReturn(engine); when(manager.getEngine()).thenReturn(engine);
when(manager.getTarget()).thenReturn(target); when(manager.getTarget()).thenReturn(target);
when(manager.getData()).thenReturn(data); when(manager.getData()).thenReturn(data);
@@ -235,56 +294,67 @@ public class WorldBlockDropRouterTest {
when(target.getWorld()).thenReturn(irisWorld); when(target.getWorld()).thenReturn(irisWorld);
when(engine.getWorld()).thenReturn(irisWorld); when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.minHeight()).thenReturn(-64); when(irisWorld.minHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension); when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.empty());
when(engine.getBiomeEnvironmentAsync(16, 136, 32)).thenAnswer(invocation -> environment);
when(engine.openBiomeEnvironmentScope(any(BiomeEnvironment.class))).thenReturn(() -> {});
binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
when(world.getUID()).thenReturn(new UUID(0L, 1L));
when(block.getWorld()).thenReturn(world); when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(16);
when(block.getY()).thenReturn(72);
when(block.getZ()).thenReturn(32);
when(block.getLocation()).thenReturn(location); when(block.getLocation()).thenReturn(location);
when(block.getBlockData()).thenReturn(blockData); when(block.getBlockData()).thenReturn(state);
when(blockData.getAsString()).thenReturn("minecraft:stone"); when(state.clone()).thenReturn(state);
when(state.getAsString()).thenReturn("minecraft:grass_block");
when(blockState.getBlockData()).thenReturn(state);
when(blockState.getLocation()).thenReturn(location);
when(nativeStack.clone()).thenReturn(nativeStack);
when(biome.getBlockDrops()).thenReturn(new KList<>(drops)); when(biome.getBlockDrops()).thenReturn(new KList<>(drops));
when(region.getBlockDrops()).thenReturn(new KList<>()); when(region.getBlockDrops()).thenReturn(new KList<>());
when(dimension.getBlockDrops()).thenReturn(new KList<>()); when(dimension.getBlockDrops()).thenReturn(new KList<>());
when(drops.shouldDropFor(blockData, data)).thenReturn(true); when(drops.shouldDropFor(state, data)).thenReturn(true);
when(drops.isReplaceVanillaDrops()).thenReturn(true); when(drops.isReplaceVanillaDrops()).thenReturn(true);
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(block.getDrops(inventory.getItemInMainHand(), player)).thenReturn(List.of(nativeStack));
doAnswer(invocation -> { doAnswer(invocation -> {
KList<ItemStack> results = invocation.getArgument(1); KList<ItemStack> results = invocation.getArgument(1);
results.add(item); results.add(custom);
return null; return null;
}).when(drops).fillDrops(eq(false), any()); }).when(drops).fillDrops(eq(false), any());
queries.when(() -> EngineBukkitOps.getBiome(engine, location)).thenReturn(biome); when(manager.managedTask(anyString(), any(Runnable.class))).thenAnswer(invocation -> invocation.getArgument(1));
queries.when(() -> EngineBukkitOps.getRegion(engine, location)).thenReturn(region);
when(manager.managedTask(anyString(), any(Runnable.class)))
.thenAnswer(invocation -> invocation.getArgument(1));
scheduling.when(() -> J.runAt(eq(location), any(Runnable.class), eq(1))).thenAnswer(invocation -> { scheduling.when(() -> J.runAt(eq(location), any(Runnable.class), eq(1))).thenAnswer(invocation -> {
delayed.add(invocation.getArgument(1)); delayed.add(invocation.getArgument(1));
return true; return true;
}); });
scheduling.when(() -> J.isOwnedByCurrentRegion(world, 1, 2)).thenReturn(true);
scheduling.when(() -> J.runRegionFuture(eq(world), eq(1), eq(2), any(Runnable.class))).thenAnswer(invocation -> {
Runnable task = invocation.getArgument(3);
task.run();
return CompletableFuture.completedFuture(null);
});
scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
markers.add(invocation.getArgument(0)); markers.add(invocation.getArgument(0));
return null; return null;
}); });
logging.when(() -> IrisLogging.warnOnce(anyString(), anyString())).thenReturn(true);
} }
private BlockBreakEvent event() { private BlockDropItemEvent event() {
return new BlockBreakEvent(block, player); Item item = mock(Item.class);
when(item.getItemStack()).thenReturn(nativeStack);
when(item.getLocation()).thenReturn(nativeLocation);
return new BlockDropItemEvent(block, blockState, player, new ArrayList<>(List.of(item)));
} }
private void assertNoSideEffects() { private void flush() {
verify(drops, never()).fillDrops(anyBoolean(), any()); while (!delayed.isEmpty()) {
verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class)); delayed.remove().run();
verify(manager, never()).getMantle(); }
scheduling.verifyNoInteractions();
assertTrue(delayed.isEmpty());
assertTrue(markers.isEmpty());
} }
@Override @Override
public void close() { public void close() {
scheduling.close(); scheduling.close();
queries.close(); logging.close();
binding.close(); binding.close();
} }
} }
@@ -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());
}
}
@@ -246,6 +246,37 @@ public class IrisNaturalCaveScanTest {
assertTrue(fixture.markers.isEmpty()); 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 { private static void verifyScan(int width, int depth) throws Exception {
Fixture fixture = new Fixture(); Fixture fixture = new Fixture();
Hunk<NativeBlockState> output = fixture.output(width, depth); Hunk<NativeBlockState> output = fixture.output(width, depth);
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.stage;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.BoundaryColumnGeometry;
import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.BoundaryGeometryInfluence;
import art.arcane.iris.world.history.NativeTerrainReceipt; import art.arcane.iris.world.history.NativeTerrainReceipt;
@@ -106,6 +107,8 @@ public class IrisTransitionGeometryActuatorTest {
assertSame(stone, blocks.getRaw(0, 3, 0)); assertSame(stone, blocks.getRaw(0, 3, 0));
assertSame(air, blocks.getRaw(0, 4, 0)); assertSame(air, blocks.getRaw(0, 4, 0));
verify(context).setTerrainHeight(0, 0, 3); verify(context).setTerrainHeight(0, 0, 3);
verify(context).setTransitionTerrain(0, 0,
Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), -1);
} }
@Test @Test
@@ -121,12 +124,40 @@ public class IrisTransitionGeometryActuatorTest {
assertSame(air, blocks.getRaw(0, 2, 0)); 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<NativeBlockState> 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) { private ChunkContext context(String material) {
return context(material, 3);
}
private ChunkContext context(String material, int fluidHeight) {
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(16); ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(16);
for (int y = 0; y < 16; y++) { for (int y = 0; y < 16; y++) {
boolean solid = y == 0 || y == 3; boolean solid = y == 0 || y == 3;
voxels.add(new BoundaryColumnGeometry.Voxel(solid ? material : "minecraft:air", boolean fluid = y > 3 && y <= fluidHeight;
solid ? BoundaryColumnGeometry.Phase.SOLID : BoundaryColumnGeometry.Phase.AIR, "", false)); 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, BoundaryGeometryInfluence influence = new BoundaryGeometryInfluence(0D, 0D,
List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D))); List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D)));
@@ -0,0 +1,207 @@
package art.arcane.iris.pack.datapack;
import art.arcane.iris.spi.IrisLogging;
import org.bukkit.Bukkit;
import org.bukkit.Server;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
public class ServerConfiguratorTimeoutTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void spigotLiveConfigurationKeepsTheRaisedTimeoutWhenTheServerSavesItAgain() throws Exception {
Path file = temporaryFolder.newFile("custom-spigot.yml").toPath();
Files.writeString(file, "settings:\n timeout-time: 60\n sample-setting: retained\n");
YamlConfiguration live = load(file);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
logging.when(() -> IrisLogging.warn(anyString())).thenAnswer(invocation -> {
assertEquals(300L, load(file).getLong("settings.timeout-time"));
return null;
});
ServerConfigurator.increaseKeepAliveSpigot(file, live);
assertEquals(300L, live.getLong("settings.timeout-time"));
live.set("world-settings.default.sample-world-setting", true);
live.save(file.toFile());
YamlConfiguration persisted = load(file);
assertEquals(300L, persisted.getLong("settings.timeout-time"));
assertEquals("retained", persisted.getString("settings.sample-setting"));
logging.verify(() -> IrisLogging.warn("Updated " + file + " settings.timeout-time: 60 -> 300"
+ " seconds. Takes effect after a server restart."));
}
}
@Test
public void higherSpigotTimeoutIsLeftUntouched() throws Exception {
Path file = temporaryFolder.newFile("larger-spigot.yml").toPath();
String original = "# Keep this configuration\nsettings:\n timeout-time: 600\n";
Files.writeString(file, original);
YamlConfiguration live = load(file);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(file, live);
assertEquals(600L, live.getLong("settings.timeout-time"));
assertEquals(original, Files.readString(file));
logging.verifyNoInteractions();
}
}
@Test
public void sharedSpigotConfigurationSymlinkIsPreservedWhileItsTargetIsUpdated() throws Exception {
Path directory = temporaryFolder.newFolder("shared-settings").toPath();
Path target = directory.resolve("shared-spigot.yml");
Path link = directory.resolve("spigot.yml");
Files.writeString(target, "settings:\n timeout-time: 60\n sample-setting: retained\n");
Path relativeTarget = target.getFileName();
Files.createSymbolicLink(link, relativeTarget);
YamlConfiguration live = load(link);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increaseKeepAliveSpigot(link, live);
assertTrue(Files.isSymbolicLink(link));
assertEquals(relativeTarget, Files.readSymbolicLink(link));
assertEquals(300L, load(target).getLong("settings.timeout-time"));
assertEquals("retained", load(target).getString("settings.sample-setting"));
assertEquals(300L, live.getLong("settings.timeout-time"));
}
}
@Test
public void paperWatchdogPersistsToTheSpecifiedFileAndPreservesOtherSettings() throws Exception {
Path directory = temporaryFolder.newFolder("custom-paper-directory").toPath();
Path file = directory.resolve("paper-global.yml");
Files.writeString(file, "watchdog:\n early-warning-delay: 10000\n early-warning-every: 5000\n"
+ "misc:\n sample-setting: retained\n");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increasePaperWatchdog(file);
YamlConfiguration persisted = load(file);
assertEquals(180000L, persisted.getLong("watchdog.early-warning-delay"));
assertEquals(5000L, persisted.getLong("watchdog.early-warning-every"));
assertEquals("retained", persisted.getString("misc.sample-setting"));
logging.verify(() -> IrisLogging.warn("Updated " + file + " watchdog.early-warning-delay: 10000 -> 180000"
+ " milliseconds. Takes effect after a server restart."));
}
}
@Test
public void higherPaperWatchdogDelayIsLeftUntouched() throws Exception {
Path file = temporaryFolder.newFile("larger-paper-global.yml").toPath();
String original = "# Keep this configuration\nwatchdog:\n early-warning-delay: 240000\n";
Files.writeString(file, original);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
ServerConfigurator.increasePaperWatchdog(file);
assertEquals(original, Files.readString(file));
logging.verifyNoInteractions();
}
}
@Test
public void malformedPaperYamlIsPreservedAndDoesNotReportAnUpdate() throws Exception {
Path file = temporaryFolder.newFile("invalid-paper-global.yml").toPath();
String malformed = "watchdog:\n early-warning-delay: [invalid\n";
Files.writeString(file, malformed);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
assertThrows(InvalidConfigurationException.class, () -> ServerConfigurator.increasePaperWatchdog(file));
assertEquals(malformed, Files.readString(file));
logging.verifyNoInteractions();
}
}
@Test
public void failedSpigotWriteRestoresTheLiveValueAndDoesNotReportSuccess() throws Exception {
Path blockedParent = temporaryFolder.newFile("blocked-parent").toPath();
Files.writeString(blockedParent, "retained");
YamlConfiguration live = new YamlConfiguration();
live.set("settings.timeout-time", 60L);
live.set("settings.sample-setting", "retained");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
assertThrows(IOException.class, () -> ServerConfigurator.increaseKeepAliveSpigot(
blockedParent.resolve("spigot.yml"), live));
assertEquals(60L, live.getLong("settings.timeout-time"));
assertEquals("retained", live.getString("settings.sample-setting"));
assertEquals("retained", Files.readString(blockedParent));
logging.verifyNoInteractions();
}
}
@Test
public void actualServerOptionsResolveCustomConfigurationPaths() throws Exception {
Path directory = temporaryFolder.newFolder("custom-settings").toPath();
File spigot = directory.resolve("nested/../server-spigot.yml").toFile();
File paper = directory.resolve("nested/../paper").toFile();
Server server = mock(Server.class, withSettings().extraInterfaces(ConsoleAccess.class));
when(((ConsoleAccess) server).getServer()).thenReturn(new ConfigurationConsole(
new ConfigurationOptions(Map.of("spigot-settings", spigot, "paper-settings-directory", paper))));
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getServer).thenReturn(server);
assertEquals(spigot.toPath().toAbsolutePath().normalize(), ServerConfigurator.serverConfigurationOption("spigot-settings"));
assertEquals(paper.toPath().toAbsolutePath().normalize(), ServerConfigurator.serverConfigurationOption("paper-settings-directory"));
assertThrows(IOException.class, () -> ServerConfigurator.serverConfigurationOption("missing-option"));
}
}
private static YamlConfiguration load(Path file) throws IOException, InvalidConfigurationException {
YamlConfiguration configuration = new YamlConfiguration();
configuration.load(file.toFile());
return configuration;
}
public interface ConsoleAccess {
ConfigurationConsole getServer();
}
public static final class ConfigurationConsole {
public final ConfigurationOptions options;
public ConfigurationConsole(ConfigurationOptions options) {
this.options = options;
}
}
public static final class ConfigurationOptions {
private final Map<String, File> paths;
public ConfigurationOptions(Map<String, File> paths) {
this.paths = paths;
}
public File valueOf(String name) {
return paths.get(name);
}
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.IrisWorldManager;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.io.ReactiveFolder;
@@ -25,11 +26,60 @@ import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorCloseTest { public class BukkitChunkGeneratorCloseTest {
@Test
public void nativeDropFlushCompletesBeforeExclusiveCloseIsDispatched() throws Exception {
CloseFixture fixture = new CloseFixture();
IrisWorldManager manager = mock(IrisWorldManager.class);
CompletableFuture<Void> flushed = new CompletableFuture<>();
when(fixture.engine.getWorldManager()).thenReturn(manager);
when(manager.flushPendingBlockDropsAsync()).thenReturn(flushed);
CompletableFuture<Void> operation = fixture.queueOperation();
CompletableFuture<Void> closed = fixture.generator.closeAsync();
assertFalse(closed.isDone());
verify(fixture.generator, never()).withExclusiveControlFuture(any(Runnable.class));
verify(fixture.engine, never()).close();
flushed.complete(null);
verify(fixture.generator).withExclusiveControlFuture(any(Runnable.class));
verify(fixture.engine, never()).close();
fixture.operation.get().run();
operation.complete(null);
closed.join();
verify(fixture.engine).close();
}
@Test
public void failedNativeDropFlushLeavesTheInitialCloseRetryable() throws Exception {
CloseFixture fixture = new CloseFixture();
IrisWorldManager manager = mock(IrisWorldManager.class);
when(fixture.engine.getWorldManager()).thenReturn(manager);
when(manager.flushPendingBlockDropsAsync()).thenReturn(
CompletableFuture.failedFuture(new IllegalStateException("Drop scheduling unavailable")),
CompletableFuture.completedFuture(null));
CompletableFuture<Void> failed = fixture.generator.closeAsync();
assertTrue(failed.isCompletedExceptionally());
assertFalse(fixture.generator.isClosing());
verify(fixture.generator, never()).withExclusiveControlFuture(any(Runnable.class));
verify(fixture.engine, never()).close();
CompletableFuture<Void> operation = fixture.queueOperation();
CompletableFuture<Void> retried = fixture.generator.closeAsync();
assertNotSame(failed, retried);
fixture.operation.get().run();
operation.complete(null);
retried.join();
assertTrue(fixture.generator.isClosing());
verify(fixture.engine).close();
}
@Test @Test
public void failedCloseReleasesInitialEntryDeferral() throws Exception { public void failedCloseReleasesInitialEntryDeferral() throws Exception {
CloseFixture fixture = new CloseFixture(); CloseFixture fixture = new CloseFixture();
@@ -130,6 +180,32 @@ public class BukkitChunkGeneratorCloseTest {
closed.join(); closed.join();
} }
@Test
public void studioQuiesceCancelsWatcherAndDrainsExclusiveControlWithoutClosingEngine() throws Exception {
CloseFixture fixture = new CloseFixture();
CompletableFuture<Void> pending = new CompletableFuture<>();
fixture.watcherHotload.set(pending);
Looper watcher = mock(Looper.class);
setField(fixture.generator, "hotloader", watcher);
CompletableFuture<Void> drained = fixture.queueOperation();
CompletableFuture<Void> 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 @Test
public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception { public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception {
CloseFixture fixture = new CloseFixture(); CloseFixture fixture = new CloseFixture();
@@ -19,18 +19,65 @@ import static org.mockito.Mockito.mockStatic;
public class StudioOpenCoordinatorCloseSequenceTest { public class StudioOpenCoordinatorCloseSequenceTest {
@Test @Test
public void unloadCompletesBeforeGeneratorCloseAndFolderDeletion() { public void updatesDrainBeforeUnloadAndGeneratorClose() {
ArrayList<String> phases = new ArrayList<>(); ArrayList<String> phases = new ArrayList<>();
StudioOpenCoordinator.sequenceStudioClose( StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"), () -> phase(phases, "evacuate"),
() -> phases.add("maintenance"), () -> phases.add("maintenance"),
() -> phase(phases, "quiesce-updates"),
() -> phase(phases, "unload"), () -> phase(phases, "unload"),
() -> phase(phases, "close-generator"), () -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders") () -> phase(phases, "delete-folders")
).join(); ).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<String> phases = new ArrayList<>();
CompletableFuture<Void> update = new CompletableFuture<>();
CompletableFuture<Void> 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<String> phases = new ArrayList<>();
IllegalStateException failure = new IllegalStateException("update remains active");
CompletableFuture<Void> 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 @Test
@@ -42,6 +89,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
StudioOpenCoordinator.sequenceStudioClose( StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"), () -> phase(phases, "evacuate"),
() -> phases.add("maintenance"), () -> phases.add("maintenance"),
() -> phase(phases, "quiesce-updates"),
() -> { () -> {
phases.add("unload"); phases.add("unload");
return CompletableFuture.failedFuture(failure); return CompletableFuture.failedFuture(failure);
@@ -54,7 +102,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
assertSame(failure, exception.getCause()); assertSame(failure, exception.getCause());
} }
assertEquals(List.of("evacuate", "maintenance", "unload"), phases); assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases);
} }
@Test @Test
@@ -69,6 +117,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
return CompletableFuture.failedFuture(failure); return CompletableFuture.failedFuture(failure);
}, },
() -> phases.add("maintenance"), () -> phases.add("maintenance"),
() -> phase(phases, "quiesce-updates"),
() -> phase(phases, "unload"), () -> phase(phases, "unload"),
() -> phase(phases, "close-generator"), () -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders") () -> phase(phases, "delete-folders")
@@ -90,6 +139,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
StudioOpenCoordinator.sequenceStudioClose( StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"), () -> phase(phases, "evacuate"),
() -> phases.add("maintenance"), () -> phases.add("maintenance"),
() -> phase(phases, "quiesce-updates"),
() -> phase(phases, "unload"), () -> phase(phases, "unload"),
() -> { () -> {
phases.add("close-generator"); phases.add("close-generator");
@@ -102,7 +152,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
assertSame(failure, exception.getCause()); 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 @Test
@@ -115,6 +165,7 @@ public class StudioOpenCoordinatorCloseSequenceTest {
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose( CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"), () -> phase(phases, "evacuate"),
() -> phases.add("maintenance"), () -> phases.add("maintenance"),
() -> phase(phases, "quiesce-updates"),
() -> { () -> {
phases.add("unload"); phases.add("unload");
return unload; return unload;
@@ -126,13 +177,13 @@ public class StudioOpenCoordinatorCloseSequenceTest {
warning.get().run(); warning.get().run();
assertFalse(close.isDone()); assertFalse(close.isDone());
assertFalse(unload.isDone()); assertFalse(unload.isDone());
assertEquals(List.of("evacuate", "maintenance", "unload"), phases); assertEquals(List.of("evacuate", "maintenance", "quiesce-updates", "unload"), phases);
configurator.verifyNoInteractions(); configurator.verifyNoInteractions();
unload.complete(null); unload.complete(null);
close.join(); 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(); configurator.verifyNoInteractions();
} }
} }
@@ -17,6 +17,32 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
public class GenerationHistoryTest extends GenerationHistorySupport { 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 @Test
public void freshPublicationReusesOnlyItsInternalVerification() throws Exception { public void freshPublicationReusesOnlyItsInternalVerification() throws Exception {
Path world = temporaryFolder.newFolder("verified-publication-world").toPath(); Path world = temporaryFolder.newFolder("verified-publication-world").toPath();
@@ -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 @Test
public void activationRoundTripRetainsItsTransition() { public void activationRoundTripRetainsItsTransition() {
GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256) GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256)
@@ -5,11 +5,22 @@ import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import org.junit.Test; import org.junit.Test;
import java.io.IOException; 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 java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals; 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.assertSame;
import static org.junit.Assert.assertThrows; 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.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -60,8 +71,186 @@ public class IrisBoundarySignatureSamplerTest {
verify(hooks, never()).flushSavedTerrainCapture(engine); 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<SavedTerrainChunk> first = new CompletableFuture<>();
CompletableFuture<SavedTerrainChunk> 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<TerrainBoundarySignature> 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<CompletableFuture<SavedTerrainChunk>> requests = new ArrayList<>();
when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> {
CompletableFuture<SavedTerrainChunk> 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<GenerationBoundary.ChunkCoordinate> 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<SavedTerrainChunk> 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<SavedTerrainChunk> 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<SavedTerrainChunk> 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<CompletableFuture<SavedTerrainChunk>> requests = new ArrayList<>();
when(hooks.captureSavedTerrainChunk(eq(engine), anyInt(), eq(0))).thenAnswer(invocation -> {
CompletableFuture<SavedTerrainChunk> future = new CompletableFuture<>();
requests.add(future);
return future;
});
when(hooks.flushSavedTerrainCapture(engine)).thenAnswer(invocation -> {
for (CompletableFuture<SavedTerrainChunk> 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<SavedTerrainChunk> 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<SavedTerrainChunk> 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 { 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 @Override
public BoundaryColumnGeometry.Voxel voxel(int localX, int y, int localZ) { public BoundaryColumnGeometry.Voxel voxel(int localX, int y, int localZ) {
return new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); return new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
@@ -14,6 +14,8 @@ import java.nio.file.Path;
import java.util.Arrays; import java.util.Arrays;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.HashSet;
import java.util.Set;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.concurrent.CyclicBarrier; import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService; 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.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -31,6 +34,160 @@ public class TerrainBoundarySignatureStoreTest {
@Rule @Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder(); public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void preparesOnlyDistinctSampledChunksInImmutableBoundedWindows() throws Exception {
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
temporaryFolder.newFolder("prepared-cells").toPath());
List<GenerationBoundary.ChunkCoordinate> 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<List<GenerationBoundary.ChunkCoordinate>> batches = new ArrayList<>();
Set<GenerationBoundary.ChunkCoordinate> sampled = new HashSet<>();
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
private List<GenerationBoundary.ChunkCoordinate> prepared = List.of();
@Override
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> 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<GenerationBoundary.ChunkCoordinate> 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<List<GenerationBoundary.ChunkCoordinate>> batches = new ArrayList<>();
Set<GenerationBoundary.ChunkCoordinate> sampled = new HashSet<>();
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
private List<GenerationBoundary.ChunkCoordinate> prepared = List.of();
@Override
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> 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<GenerationBoundary.ChunkCoordinate> prepared = new ArrayList<>();
TerrainBoundarySignatureStore.SignatureSampler sampler = new TerrainBoundarySignatureStore.SignatureSampler() {
@Override
public void prepareChunks(List<GenerationBoundary.ChunkCoordinate> 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<GenerationBoundary.ChunkCoordinate> 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 @Test
public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception { public void loadsOnlyRequestedShardsAndValidatesTheirBytesBeforeUse() throws Exception {
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore( TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
@@ -77,6 +77,32 @@ public class TransitionGenerationPlanTest {
assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D); 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 @Test
public void computesTransitionForEnclosedNewTerritory() throws Exception { public void computesTransitionForEnclosedNewTerritory() throws Exception {
GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of( GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of(
@@ -336,7 +362,7 @@ public class TransitionGenerationPlanTest {
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> specification(1L, "same", "same", 16, "actual", snapshot.identity())); () -> specification(1L, "same", "same", 16, "actual", snapshot.identity()));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> specification(1L, "old", "new", 0, "actual", snapshot.identity())); () -> specification(1L, "old", "new", -1, "actual", snapshot.identity()));
assertThrows(IllegalArgumentException.class, assertThrows(IllegalArgumentException.class,
() -> new TransitionGenerationPlan( () -> new TransitionGenerationPlan(
specification(1L, "old", "new", 16, "different", snapshot.identity()), specification(1L, "old", "new", 16, "different", snapshot.identity()),
@@ -206,7 +206,7 @@ public class IrisPregeneratorInitTest {
pressure.verify(MantleHeapPressure::requestPanicReclaim); pressure.verify(MantleHeapPressure::requestPanicReclaim);
if (failReclaim) { if (failReclaim) {
logging.verify(() -> IrisLogging.reportError( 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));
} }
} }
} }
@@ -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));
}
}
@@ -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<BukkitPlatform> bukkit = mockStatic(BukkitPlatform.class);
MockedStatic<Bukkit> 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<Void> 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<J> 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<Void> 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());
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ org.gradle.jvmargs=-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF
org.gradle.caching=true org.gradle.caching=true
org.gradle.configureondemand=false org.gradle.configureondemand=false
irisVersion=4.3.2-26.2 irisVersion=4.3.3-26.2
minecraftVersion=26.3 minecraftVersion=26.3
# Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 through 26.3 # Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 through 26.3
apiVersion=26.1 apiVersion=26.1