diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java
index 8f8ae6bad..58f521cde 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java
@@ -31,12 +31,15 @@ import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisStartupAdmissionListener;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.core.IrisWorldGeneratorResolver;
+import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.PendingWorldDeleteQueue;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
+import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
+import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
@@ -65,6 +68,7 @@ import art.arcane.iris.core.service.IrisProtocolService;
import art.arcane.iris.core.service.IrisTerrainSVC;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.core.service.LogFilterSVC;
+import art.arcane.iris.core.service.MultiverseSVC;
import art.arcane.iris.core.service.ObjectSVC;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.core.service.PreservationSVC;
@@ -100,6 +104,7 @@ import art.arcane.volmlib.util.io.InstanceState;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.misc.Bindings;
+import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.misc.SlimJar;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.common.plugin.IrisService;
@@ -142,6 +147,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -261,12 +267,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
+ /**
+ * A warning raised here is the same kind of thing as one raised in core, so it takes the same route: the
+ * plugin logger at WARNING, where a log scan of logs/latest.log finds it.
+ */
public static void warn(String format, Object... objs) {
- msg(C.YELLOW + safeFormat(format, objs));
+ diagnostic(Level.WARNING, safeFormat(format, objs));
}
public static void error(String format, Object... objs) {
- msg(C.RED + safeFormat(format, objs));
+ diagnostic(Level.SEVERE, safeFormat(format, objs));
}
public static void debug(String string) {
@@ -512,12 +522,52 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static void bridgeLog(LogLevel level, String message) {
LogLevel target = level == null ? LogLevel.INFO : level;
- switch (target) {
- case DEBUG -> Iris.debug(message);
- case INFO -> Iris.info(message);
- case WARN -> Iris.warn(message);
- case ERROR -> Iris.error(message);
+ Level diagnostic = diagnosticLevel(target);
+ if (diagnostic != null) {
+ diagnostic(diagnostic, message);
+ return;
}
+ if (target == LogLevel.DEBUG) {
+ Iris.debug(message);
+ return;
+ }
+ Iris.info(message);
+ }
+
+ /**
+ * The java.util.logging level a message keeps, or null when it belongs on the console sender path.
+ *
+ * Core states a severity and every other adapter honours it; on Bukkit a coloured line through the
+ * console sender reaches the terminal but not the instance's logs/latest.log, so a WARN-level scan of
+ * that file never saw a single core warning. Diagnostics go to the plugin logger at their own level
+ * instead, and NOTICE carries the handful of lifecycle lines that have to land there too. Player-facing
+ * text still goes through {@code IrisLogging.msg}.
+ */
+ static Level diagnosticLevel(LogLevel level) {
+ return switch (level) {
+ case NOTICE -> Level.INFO;
+ case WARN -> Level.WARNING;
+ case ERROR -> Level.SEVERE;
+ case DEBUG, INFO -> null;
+ };
+ }
+
+ private static void diagnostic(Level level, String message) {
+ String line = IrisLogging.clean(message);
+ Iris plugin = instance;
+ if (plugin != null) {
+ try {
+ plugin.getLogger().log(level, line);
+ return;
+ } catch (Throwable unavailable) {
+ // Paper runs the bootstrap before a plugin logger exists; the streams below are all there is.
+ }
+ }
+ if (level.intValue() >= Level.WARNING.intValue()) {
+ System.err.println("[Iris] " + line);
+ return;
+ }
+ System.out.println("[Iris] " + line);
}
/**
@@ -573,6 +623,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
new WandSVC(),
new BoardSVC(),
new IrisIntegrationService(),
+ new MultiverseSVC(),
new IrisProtocolService(),
new IrisApiEventSVC(),
new CommandSVC()
@@ -597,6 +648,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.attempt(this::splash);
IrisSafeguard.printReports();
IrisSafeguard.printFooter();
+ // Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there
+ // never reach logs/latest.log. Replay them once now that the platform log is up.
+ MissingWorldStorageLog.replayToPlatformLog();
tickets = new ChunkTickets();
linkMultiverseCore = new MultiverseCoreLink();
IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore);
@@ -606,6 +660,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(EngineWorldManagerProvider.class,
(EngineWorldManagerProvider) IrisWorldManager::new);
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
+ IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld);
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
settingsHotloadWatch = watch;
configHotloadEngine = new ConfigHotloadEngine(
@@ -710,6 +765,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return worldReconciler;
}
+ /**
+ * The load every integration goes through. {@code /iris load} and the Multiverse guard both land here,
+ * so a world loaded from outside Iris still gets the keyed creator, the pack's environment and the
+ * bukkit.yml reconciliation that make it an Iris world rather than a vanilla one under the same name.
+ */
+ private CompletableFuture loadManagedWorld(String configuredWorldName) {
+ return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
+ .thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
+ }
+
public PendingWorldReplacementManager pendingWorldReplacements() {
return pendingWorldReplacements;
}
@@ -757,8 +822,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
- boolean enabled = enable();
+ boolean enabled;
+ try {
+ enabled = enable();
+ } catch (Throwable failure) {
+ refuseVanillaFallback(failure);
+ throw failure;
+ }
if (!enabled) {
+ refuseVanillaFallback(null);
return;
}
BukkitGuiHost.install();
@@ -766,6 +838,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
super.onEnable();
}
+ /**
+ * Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
+ *
+ * A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
+ * vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
+ * There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
+ * damage control, not prevention: level creation runs in the same startup step that enables plugins, so
+ * spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
+ * lives in IrisBootstrap, which refuses startup before any level is created.
+ */
+ private static void refuseVanillaFallback(Throwable failure) {
+ File levelRoot;
+ try {
+ levelRoot = IrisWorldStorage.levelRoot();
+ } catch (Throwable unavailable) {
+ return;
+ }
+ if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
+ return;
+ }
+ Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
+ if (failure != null) {
+ Iris.reportError("Iris enable failed", failure);
+ }
+ try {
+ Bukkit.shutdown();
+ } catch (Throwable unavailable) {
+ Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
+ }
+ }
+
public void onDisable() {
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java
index 135675c0e..039b3a5f5 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java
@@ -1,6 +1,10 @@
package art.arcane.iris;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
+import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
+import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisWorldStorageEntry;
+import art.arcane.iris.core.lifecycle.HuskWorldQuarantine;
+import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
@@ -13,6 +17,8 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
+import java.util.List;
+import java.util.function.Consumer;
@SuppressWarnings("UnstableApiUsage")
public final class IrisBootstrap implements PluginBootstrap {
@@ -22,6 +28,8 @@ public final class IrisBootstrap implements PluginBootstrap {
try {
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
reconcilePendingWorldReplacements(context, startupPaths);
+ quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message));
+ requireUsableWorldStorage(startupPaths);
ProvisionResult provisioned = provision(context, startupPaths);
Path datapackRoot = provisioned.datapackRoot();
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
@@ -58,6 +66,76 @@ public final class IrisBootstrap implements PluginBootstrap {
}
}
+ /**
+ * Moves a hot-deleted world's husk out of {@code /dimensions/iris} before anything enumerates
+ * it.
+ *
+ * Deleting a loaded world's folder and letting the server save the level back leaves a directory holding
+ * only the server's own {@code data/} skeleton. Left there it stops the boot whichever way it is
+ * classified: as unusable storage it trips the guard below, and as an empty folder it is excluded from
+ * the dimension registry, which trips Paper's interactive world-migration gate - a prompt that consumes
+ * no console input and hangs startup forever. The folder is moved rather than deleted, so it stays
+ * recoverable; {@link HuskWorldQuarantine} refuses anything that is not provably worthless.
+ */
+ static void quarantineWorthlessHusks(BukkitStartupPaths startupPaths, Consumer warn) {
+ HuskWorldQuarantine.quarantineWorthlessHusks(startupPaths.levelRoot(), warn);
+ }
+
+ /**
+ * Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
+ *
+ * The server enumerates levels from {@code /dimensions//} on disk, so a world
+ * whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
+ * {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
+ * terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
+ * to be found here, before any level is created, exactly as a corrupt {@code dimensions/.json} is.
+ *
+ * A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
+ * its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
+ * is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
+ * already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
+ * or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
+ */
+ static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
+ Path levelRoot = startupPaths.levelRoot();
+ Path levelName = levelRoot.getFileName();
+ if (levelName == null || levelName.toString().isBlank()) {
+ throw new IOException("Configured level root has no startup level id: " + levelRoot);
+ }
+ List entries = BukkitWorldConfiguration.auditIrisWorldStorage(
+ startupPaths.bukkitConfiguration().toFile(),
+ levelName.toString(),
+ levelRoot
+ );
+ StringBuilder unusable = new StringBuilder();
+ for (IrisWorldStorageEntry entry : entries) {
+ switch (entry.state()) {
+ case PRESENT -> {
+ }
+ case MISSING -> MissingWorldStorageLog.warnOnce(
+ entry.configuredWorldName(),
+ entry.storagePath());
+ case EMPTY -> MissingWorldStorageLog.warnEmptyOnce(
+ entry.configuredWorldName(),
+ entry.storagePath());
+ case UNUSABLE -> unusable.append(unusable.isEmpty() ? "" : "; ")
+ .append(entry.configuredWorldName())
+ .append(" (")
+ .append(entry.storagePath())
+ .append(": ")
+ .append(entry.detail() == null ? "storage is unusable" : entry.detail())
+ .append(')');
+ }
+ }
+ if (unusable.isEmpty()) {
+ return;
+ }
+ throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
+ + " terrain over it: " + unusable
+ + ". Restore each world folder from a backup, or move it aside; a world with no folder is"
+ + " reported and skipped, and the server starts.");
+ }
+
static void armStartupFailure(BootstrapContext context, Throwable failure) {
armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY);
}
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java
index 222ec94bc..cbb8dd2db 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java
@@ -28,6 +28,7 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
+import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisWorld;
@@ -237,7 +238,7 @@ public final class IrisWorldGeneratorResolver {
@Nullable
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
File levelRoot = IrisWorldStorage.levelRoot();
- NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
+ NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName());
File pack = IrisWorldStorage.frozenDimensionRoot(
Bukkit.getWorldContainer(),
@@ -263,8 +264,65 @@ public final class IrisWorldGeneratorResolver {
return dimension;
}
- static NamespacedKey configuredWorldKey(String worldName, String levelName) {
- return IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
+ /**
+ * Resolves the Iris key behind a Bukkit world name, accepting the runtime keyed name alongside the
+ * startup name.
+ *
+ * Paper names a world built from {@code WorldCreator.ofKey} {@code _}, and Multiverse
+ * loads Iris worlds that way because its own keyed creator refuses a non-{@code minecraft} namespace.
+ * That is not the startup name, so the configured-name parser reads {@code iris_moon} as
+ * {@code iris:iris_moon} and loses the world.
+ *
+ * Only a name whose decoded key already has Iris storage is remapped, and only when the literal
+ * reading has none, so a world genuinely created as {@code /iris create "iris moon"} keeps its own
+ * identity and a server without Multiverse resolves exactly the names it resolved before.
+ */
+ static NamespacedKey configuredWorldKey(String worldName, String levelName, File levelRoot) {
+ NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
+ String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
+ if (!worldName.startsWith(runtimePrefix)
+ || worldName.startsWith(levelName + "_" + runtimePrefix)
+ || IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, literal)) {
+ return literal;
+ }
+ NamespacedKey runtime;
+ try {
+ runtime = IrisWorldStorage.managedKeyFromName(
+ IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
+ levelName);
+ } catch (RuntimeException notAManagedKey) {
+ return literal;
+ }
+ if (!IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, runtime)) {
+ return literal;
+ }
+ IrisLogging.debug("Resolved runtime keyed world name " + worldName + " as " + runtime);
+ return runtime;
+ }
+
+ /**
+ * The key a refusal message should name for a world name, without the storage-existence guard.
+ *
+ * {@link #configuredWorldKey} only remaps the runtime keyed name when the decoded key has storage, so an
+ * orphan keeps the literal reading and a message built from it names {@code iris:iris_moon} and tells
+ * the admin to create a world that never existed under that name. Messaging does not have to be safe
+ * against mis-mapping a live world, only correct about what the admin typed, so it drops that guard;
+ * generation keeps it.
+ */
+ static NamespacedKey messageWorldKey(String worldName, String levelName) {
+ NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
+ String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
+ if (!worldName.startsWith(runtimePrefix)
+ || worldName.startsWith(levelName + "_" + runtimePrefix)) {
+ return literal;
+ }
+ try {
+ return IrisWorldStorage.managedKeyFromName(
+ IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
+ levelName);
+ } catch (RuntimeException notAManagedKey) {
+ return literal;
+ }
}
/**
@@ -298,7 +356,7 @@ public final class IrisWorldGeneratorResolver {
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
- NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
+ NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
@@ -361,10 +419,11 @@ public final class IrisWorldGeneratorResolver {
&& (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) {
return;
}
- throw new IllegalStateException("'" + worldName + "' (" + worldKey
+ NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
+ throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
- + " Create Iris worlds with /iris create " + worldName + " type=;"
- + " Iris registers them with Multiverse itself.");
+ + " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
+ + " type=; Iris registers them with Multiverse itself.");
}
private static boolean hasFrozenPack(File dimensionRoot) {
@@ -374,7 +433,7 @@ public final class IrisWorldGeneratorResolver {
private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) {
File levelRoot = IrisWorldStorage.levelRoot();
- NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
+ NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot(
Bukkit.getWorldContainer(),
levelRoot,
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java
index 87e7515eb..3f10b882d 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java
@@ -162,7 +162,37 @@ public final class IrisEngineSVC implements IrisService {
World world = event.getWorld();
CompletionStage unloadBoundary = WorldUnloadBoundaryRegistry.claim(
WorldIdentity.serialize(world));
- remove(world, unloadBoundary);
+ if (remove(world, unloadBoundary) || unloadBoundary != null) {
+ return;
+ }
+ closeUntrackedGenerator(world);
+ }
+
+ /**
+ * Closes the engine of a world this service never registered.
+ *
+ * Registration is skipped while a previous generator for the same identity is still closing and while
+ * the world is not yet the server's world for its UID, and an unload that lands in that window would
+ * otherwise leave a live engine bound to a dead world. Only an unload Iris did not drive is covered:
+ * an Iris-driven unload carries a boundary and closes the generator in its own phase. The close is
+ * CAS-idempotent, so a redundant call returns the in-flight completion.
+ */
+ private void closeUntrackedGenerator(World world) {
+ PlatformChunkGenerator generator = IrisToolbelt.access(world);
+ if (generator == null || generator.isClosing()) {
+ return;
+ }
+ IrisLogging.debug("EngineSVC: closing untracked generator for unloaded world " + world.getName());
+ CompletableFuture close = generator.closeAsync();
+ if (close == null) {
+ return;
+ }
+ close.whenComplete((ignored, failure) -> {
+ if (failure != null) {
+ Throwable cause = failure.getCause() == null ? failure : failure.getCause();
+ reportFailure("Failed to close untracked generator for " + world.getName(), cause);
+ }
+ });
}
@EventHandler
@@ -235,9 +265,12 @@ public final class IrisEngineSVC implements IrisService {
}
}
- private void remove(World world, CompletionStage unloadBoundary) {
+ /**
+ * @return true when a registration was found and its close was reserved
+ */
+ private boolean remove(World world, CompletionStage unloadBoundary) {
if (world == null) {
- return;
+ return false;
}
Registered registered;
@@ -250,10 +283,12 @@ public final class IrisEngineSVC implements IrisService {
closing = reserveClose(registered);
}
}
- if (closing != null) {
- phases.closing(world);
- deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
+ if (closing == null) {
+ return false;
}
+ phases.closing(world);
+ deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
+ return true;
}
private void deferCloseUntilWorldUnload(
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java
new file mode 100644
index 000000000..8e9216bec
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java
@@ -0,0 +1,194 @@
+package art.arcane.iris;
+
+import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
+import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The server enumerates levels from disk and CraftBukkit falls back to the vanilla generator whenever the
+ * plugin cannot supply one, so a world with storage Iris cannot use has to stop startup before any level is
+ * created. A world with no storage at all is only reported.
+ */
+public class IrisBootstrapWorldStorageContractTest {
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Before
+ public void clearOrphanReports() {
+ MissingWorldStorageLog.reset();
+ }
+
+ @Test
+ public void startupIsRefusedWhenAConfiguredWorldLostItsPackSnapshot() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("broken-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/orphan2/region"));
+ Files.writeString(serverRoot.resolve("world/dimensions/iris/orphan2/region/r.0.0.mca"), "terrain");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_orphan2");
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_orphan2"));
+ assertTrue(failure.getMessage(),
+ failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/iris/orphan2")
+ .toString()));
+ }
+
+ /**
+ * Deleting a live world's folder and letting the server save it back leaves a data/ skeleton with no
+ * regions and no iris/ directory. It owns nothing, so refusing to boot over it only strands the server.
+ */
+ @Test
+ public void startupContinuesForAServerRewrittenHuskAndReportsItOnce() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("husk-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper"));
+ Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_husk");
+
+ IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
+
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
+ }
+
+ /**
+ * Both classifications of a hot-deleted world's husk stop the boot: UNUSABLE trips Iris' fail-closed
+ * guard, and EMPTY excludes it from the dimension registry, which trips Paper's interactive world
+ * migration gate. The husk has to leave the dimensions tree before either can happen.
+ */
+ @Test
+ public void aHotDeletedWorldsHuskIsMovedOutOfTheDimensionsTree() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("husk-quarantine").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper"));
+ Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_husk");
+ List warnings = new ArrayList<>();
+
+ IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
+ IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
+
+ assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk")));
+ assertEquals(1, warnings.size());
+ assertTrue(warnings.getFirst(),
+ warnings.getFirst().contains("/iris remove world=world_iris_husk delete=true"));
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
+ }
+
+ /**
+ * Paper enumerates the dimensions tree from disk, so a husk whose bukkit.yml entry is already gone stops
+ * the boot exactly the same way. The sweep is over the tree, not over the configuration.
+ */
+ @Test
+ public void aHuskWithNoBukkitConfigurationEntryIsStillMovedOut() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("husk-unconfigured").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft"));
+ Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft/raids.dat"), "x");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot);
+ List warnings = new ArrayList<>();
+
+ IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
+
+ assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk")));
+ assertEquals(1, warnings.size());
+ }
+
+ @Test
+ public void aWorldWithRealDataIsNeverQuarantinedAndStillStopsStartup() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("husk-with-data").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/kept/region"));
+ Files.writeString(serverRoot.resolve("world/dimensions/iris/kept/region/r.0.0.mca"), "terrain");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_kept");
+ List warnings = new ArrayList<>();
+
+ IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
+
+ assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/kept")));
+ assertTrue(warnings.isEmpty());
+ assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+ }
+
+ /**
+ * The engine rebuilds iris/engine-data under a world folder the server itself recreated during save-all,
+ * so an iris/ directory with no pack snapshot is the ambiguous state, not a worthless one.
+ */
+ @Test
+ public void anIrisMarkerWithNoPackSnapshotStillFailsClosed() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("husk-marker").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data"));
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_marked");
+ List warnings = new ArrayList<>();
+
+ IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add);
+
+ assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data")));
+ assertTrue(warnings.isEmpty());
+ assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+ }
+
+ @Test
+ public void startupContinuesForAWorldWhoseFolderIsGoneAndReportsItOnce() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("orphan-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris"));
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_orphan1");
+
+ IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
+
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_orphan1"));
+ }
+
+ @Test
+ public void startupContinuesForHealthyWorlds() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("healthy-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/iris/moon/iris/pack"));
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world_iris_moon");
+
+ IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
+
+ assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
+ }
+
+ private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
+ return BukkitStartupPaths.resolve(serverRoot, new String[0]);
+ }
+
+ private static void writeServerProperties(Path serverRoot) throws Exception {
+ Files.writeString(serverRoot.resolve("server.properties"), "level-name=world\n", StandardCharsets.UTF_8);
+ }
+
+ private static void writeBukkitWorlds(Path serverRoot, String... configuredWorldNames) throws Exception {
+ File configuration = serverRoot.resolve("bukkit.yml").toFile();
+ YamlConfiguration yaml = new YamlConfiguration();
+ for (String configuredWorldName : configuredWorldNames) {
+ yaml.set("worlds." + configuredWorldName + ".generator", "Iris:overworld");
+ }
+ yaml.save(configuration);
+ }
+}
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java
new file mode 100644
index 000000000..362297596
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java
@@ -0,0 +1,89 @@
+package art.arcane.iris;
+
+import art.arcane.iris.spi.LogLevel;
+import org.junit.Test;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.logging.Level;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Core states a severity on every message it logs and the modded adapters honour it. On Bukkit the same
+ * message used to become a coloured console line, which the server logs at INFO, so no core warning ever
+ * appeared in a WARN-level scan of logs/latest.log - including the orphaned-world-storage reports the
+ * bootstrap replays there specifically for operators to find.
+ */
+public class IrisDiagnosticLogLevelTest {
+ @Test
+ public void coreDiagnosticsKeepTheirSeverity() {
+ assertEquals(Level.WARNING, Iris.diagnosticLevel(LogLevel.WARN));
+ assertEquals(Level.SEVERE, Iris.diagnosticLevel(LogLevel.ERROR));
+ }
+
+ @Test
+ public void informationalAndDebugMessagesStayOnTheConsolePath() {
+ assertNull(Iris.diagnosticLevel(LogLevel.INFO));
+ assertNull(Iris.diagnosticLevel(LogLevel.DEBUG));
+ }
+
+ /**
+ * Console sender output reaches the terminal but not the instance's logs/latest.log, which is the only
+ * log most operators read after the fact. A handful of lifecycle lines go to the plugin logger instead.
+ */
+ @Test
+ public void lifecycleNoticesReachThePluginLoggerAtInfo() {
+ assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE));
+ }
+
+ /**
+ * A warning raised by the adapter is the same kind of thing as a warning raised by core. Routing one
+ * through the plugin logger and the other through the console sender makes the level depend on which
+ * side of the SPI the call happened to be written on.
+ */
+ @Test
+ public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception {
+ String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
+
+ String warn = method(source, "public static void warn(String format, Object... objs)");
+ assertTrue(warn, warn.contains("diagnostic(Level.WARNING"));
+ assertFalse(warn, warn.contains("msg("));
+
+ String error = method(source, "public static void error(String format, Object... objs)");
+ assertTrue(error, error.contains("diagnostic(Level.SEVERE"));
+ assertFalse(error, error.contains("msg("));
+ }
+
+ @Test
+ public void theCoreLogBridgeRoutesDiagnosticsToThePluginLogger() throws Exception {
+ String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
+ String bridge = method(source, "private static void bridgeLog(LogLevel level, String message)");
+
+ assertTrue(bridge, bridge.contains("diagnosticLevel(target)"));
+ assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)"));
+ assertTrue(source.contains("plugin.getLogger().log(level, line)"));
+ }
+
+ private static String method(String source, String signature) {
+ int start = source.indexOf(signature);
+ assertTrue("method not found: " + signature, start >= 0);
+ int open = source.indexOf('{', start);
+ int depth = 0;
+ for (int i = open; i < source.length(); i++) {
+ char c = source.charAt(i);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) {
+ return source.substring(start, i + 1);
+ }
+ }
+ }
+ throw new AssertionError("unterminated method: " + signature);
+ }
+}
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java
index 7496dc732..a98eb68b1 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java
@@ -94,15 +94,97 @@ public class IrisWorldGeneratorResolverTest {
}
@Test
- public void paperStartupAliasResolvesToCanonicalRuntimeKey() {
+ public void paperStartupAliasResolvesToCanonicalRuntimeKey() throws Exception {
+ File levelRoot = ownedLevelRoot("startup-alias", "moon");
+
assertEquals(
new NamespacedKey("iris", "moon"),
- IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world")
+ IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world", levelRoot)
);
assertEquals(
new NamespacedKey("iris", "moon"),
- IrisWorldGeneratorResolver.configuredWorldKey("moon", "world")
+ IrisWorldGeneratorResolver.configuredWorldKey("moon", "world", levelRoot)
);
+ assertEquals(
+ NamespacedKey.minecraft("overworld"),
+ IrisWorldGeneratorResolver.configuredWorldKey("world", "world", levelRoot)
+ );
+ }
+
+ @Test
+ public void multiverseRuntimeKeyedNameResolvesToTheStoredIrisWorld() throws Exception {
+ File levelRoot = ownedLevelRoot("runtime-keyed", "moon");
+
+ assertEquals(
+ "mv load names an Iris world iris_ because its keyed creator refuses the namespace",
+ new NamespacedKey("iris", "moon"),
+ IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
+ );
+ }
+
+ @Test
+ public void runtimeKeyedNameWithoutStorageKeepsItsLiteralKey() throws Exception {
+ File levelRoot = ownedLevelRoot("runtime-keyed-missing", "elsewhere");
+
+ assertEquals(
+ new NamespacedKey("iris", "iris_moon"),
+ IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
+ );
+ }
+
+ @Test
+ public void literalWorldStorageWinsOverTheRuntimeKeyedReading() throws Exception {
+ File levelRoot = ownedLevelRoot("runtime-keyed-collision", "moon");
+ assertTrue(new File(levelRoot, "dimensions/iris/iris_moon").mkdirs());
+
+ assertEquals(
+ "a world genuinely created as iris_moon keeps its own identity",
+ new NamespacedKey("iris", "iris_moon"),
+ IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot)
+ );
+ }
+
+ @Test
+ public void levelNamedIrisStillResolvesItsOwnStartupNames() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("level-named-iris", "iris");
+ assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
+
+ assertEquals(
+ new NamespacedKey("iris", "moon"),
+ IrisWorldGeneratorResolver.configuredWorldKey("iris_iris_moon", "iris", levelRoot)
+ );
+ }
+
+ /**
+ * A refusal has to name the world the admin typed. The generation guard keeps the storage-existence
+ * check that stops a genuine iris_moon world being mis-mapped; the message must not, or an orphan is
+ * described as iris:iris_orphan1 and the remedy names a world that never existed.
+ */
+ @Test
+ public void refusalMessagesNameTheRuntimeKeyedWorldWithoutItsStorage() {
+ assertEquals(
+ new NamespacedKey("iris", "orphan1"),
+ IrisWorldGeneratorResolver.messageWorldKey("iris_orphan1", "world")
+ );
+ assertEquals(
+ new NamespacedKey("iris", "moon"),
+ IrisWorldGeneratorResolver.messageWorldKey("world_iris_moon", "world")
+ );
+ assertEquals(
+ "a world genuinely created as iris_moon keeps its own identity",
+ new NamespacedKey("iris", "iris_moon"),
+ IrisWorldGeneratorResolver.messageWorldKey("world_iris_iris_moon", "world")
+ );
+ assertEquals(
+ NamespacedKey.minecraft("overworld"),
+ IrisWorldGeneratorResolver.messageWorldKey("world", "world")
+ );
+ }
+
+ private File ownedLevelRoot(String scope, String worldKey) throws Exception {
+ File levelRoot = temporaryFolder.newFolder(scope, "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/" + worldKey).mkdirs());
+ return levelRoot;
}
@Test
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java
index 03adff440..cfadd6843 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java
@@ -76,7 +76,7 @@ public class IrisApiWiringContractTest {
assertBefore(add, "registered = true;", "phases.ready(world)");
String remove = method(source,
- "private void remove(World world, CompletionStage unloadBoundary)");
+ "private boolean remove(World world, CompletionStage unloadBoundary)");
assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)");
assertBefore(remove, "phases.closing(world)",
"deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)");
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java
index 2496a3f1a..f8b0dfe0b 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java
@@ -49,7 +49,7 @@ public class IrisEngineLifecycleContractTest {
public void registrationRetryWaitsAsynchronouslyForClose() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String add = method(source, "private void add(World world)");
- String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)");
+ String remove = method(source, "private boolean remove(World world, CompletionStage unloadBoundary)");
String completeClose = method(source, "private void completeClose(");
String retry = method(source, "private void retryRegistrationAfterClose(");
String invokeClose = method(source, "private CompletableFuture invokeGeneratorClose()");
@@ -73,7 +73,7 @@ public class IrisEngineLifecycleContractTest {
public void worldUnloadDefersGeneratorCloseUntilTheRawBoundaryCompletesTrue() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String handler = method(source, "public void onWorldUnload(WorldUnloadEvent event)");
- String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)");
+ String remove = method(source, "private boolean remove(World world, CompletionStage unloadBoundary)");
String defer = method(source, "private void deferCloseUntilWorldUnload(");
assertBefore(handler, "WorldUnloadBoundaryRegistry.claim(", "remove(world, unloadBoundary)");
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java
new file mode 100644
index 000000000..e014d930a
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java
@@ -0,0 +1,59 @@
+package art.arcane.iris.core.service;
+
+import art.arcane.iris.engine.platform.BukkitChunkGenerator;
+import org.bukkit.NamespacedKey;
+import org.bukkit.World;
+import org.bukkit.event.world.WorldUnloadEvent;
+import org.junit.Test;
+
+import java.util.concurrent.CompletableFuture;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Multiverse unloads a world through {@code Bukkit.unloadWorld}, which carries no Iris unload boundary.
+ * {@code IrisEngineSVC} closes the engine of every world it registered, but registration is skipped while
+ * a previous generator for the same identity is still closing, and an unload landing in that window used
+ * to leave a live engine bound to a dead world.
+ */
+public class IrisEngineSVCUntrackedUnloadTest {
+ @Test
+ public void externalUnloadClosesAnIrisGeneratorTheServiceNeverRegistered() {
+ BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class);
+ when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null));
+
+ new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("mvtest", generator)));
+
+ verify(generator).closeAsync();
+ }
+
+ @Test
+ public void externalUnloadOfANonIrisWorldClosesNothing() {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn("plots");
+ when(world.getKey()).thenReturn(new NamespacedKey("minecraft", "plots"));
+
+ new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(world));
+ }
+
+ @Test
+ public void externalUnloadDoesNotReenterAGeneratorThatIsAlreadyClosing() {
+ BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class);
+ when(generator.isClosing()).thenReturn(true);
+
+ new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("closing", generator)));
+
+ verify(generator, never()).closeAsync();
+ }
+
+ private static World irisWorld(String key, BukkitChunkGenerator generator) {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn("world_iris_" + key);
+ when(world.getKey()).thenReturn(new NamespacedKey("iris", key));
+ when(world.getGenerator()).thenReturn(generator);
+ return world;
+ }
+}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java
index 475d7137d..31b2ef6d0 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java
@@ -35,9 +35,13 @@ public final class ModdedIrisLog {
LogLevel target = level == null ? LogLevel.INFO : level;
switch (target) {
case DEBUG -> debug(message);
- case INFO -> info(message);
case WARN -> warn(message);
case ERROR -> error(message);
+ // INFO, NOTICE, and any level added later. This is a switch statement, so the compiler does not
+ // check it for exhaustiveness and a missing arm would drop the message silently. The modded
+ // loaders have one logger, so a lifecycle notice is already in the file the server writes; the
+ // level only differs on Bukkit, where INFO goes to a console sender instead.
+ default -> info(message);
}
}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java
new file mode 100644
index 000000000..19a317138
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java
@@ -0,0 +1,27 @@
+package art.arcane.iris.modded;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The level router is a switch statement, which the compiler does not check for exhaustiveness. A level added
+ * to {@link art.arcane.iris.spi.LogLevel} with no arm here would compile and then drop every message at that
+ * level without a trace, so the fallback arm is what keeps a new level visible on the modded loaders.
+ */
+public class ModdedIrisLogLevelCoverageTest {
+ @Test
+ public void everyLogLevelReachesTheLoggerIncludingOnesAddedLater() throws IOException {
+ String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
+ "art/arcane/iris/modded/ModdedIrisLog.java"));
+ int router = source.indexOf("public static void log(LogLevel level, String message)");
+ assertTrue("log(LogLevel, String) not found", router >= 0);
+
+ String body = source.substring(router, source.indexOf("public static void debug(", router));
+ assertTrue(body, body.contains("default -> info(message);"));
+ }
+}
diff --git a/core/build.gradle b/core/build.gradle
index 38ae052c7..afc991214 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -117,7 +117,7 @@ dependencies {
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
testRuntimeOnly(libs.paper.api)
- testRuntimeOnly(libs.multiverseCore)
+ testImplementation(libs.multiverseCore)
}
tasks.named('test').configure {
diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt
index b49452462..73b462fc2 100644
--- a/core/purity-allowlist.txt
+++ b/core/purity-allowlist.txt
@@ -24,6 +24,7 @@ art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java
art/arcane/iris/core/lifecycle/WorldsProviderBackend.java
art/arcane/iris/core/link/ExternalDataProvider.java
art/arcane/iris/core/link/MultiverseCoreLink.java
+art/arcane/iris/core/link/MultiverseGuardListener.java
art/arcane/iris/core/link/WorldEditLink.java
art/arcane/iris/core/link/data/CraftEngineDataProvider.java
art/arcane/iris/core/link/data/EcoItemsDataProvider.java
@@ -66,6 +67,7 @@ art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
+art/arcane/iris/core/service/MultiverseSVC.java
art/arcane/iris/core/service/ObjectSVC.java
art/arcane/iris/core/service/ObjectStudioSaveService.java
art/arcane/iris/core/service/PackDownloadProgressReporter.java
diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java
index 090510394..b6afd8c50 100644
--- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java
+++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java
@@ -17,6 +17,7 @@ import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
+import java.util.stream.Stream;
public final class IrisWorldStorage {
private static final String IRIS_NAMESPACE = "iris";
@@ -325,6 +326,29 @@ public final class IrisWorldStorage {
return target.toFile();
}
+ /**
+ * True when the level root holds at least one Iris-managed world directory. Used by the failure paths
+ * that have to decide whether letting the server continue would put vanilla terrain into Iris storage.
+ */
+ public static boolean hasManagedWorldStorage(File levelRoot) {
+ if (levelRoot == null) {
+ return false;
+ }
+ Path namespace = levelRoot.toPath()
+ .toAbsolutePath()
+ .normalize()
+ .resolve("dimensions")
+ .resolve(IRIS_NAMESPACE);
+ if (!Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ try (Stream entries = Files.list(namespace)) {
+ return entries.anyMatch(entry -> Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS));
+ } catch (IOException unreadable) {
+ return false;
+ }
+ }
+
public static boolean isExistingManagedDimensionRoot(File levelRoot, NamespacedKey key) {
try {
Path target = requireSafeManagedDimensionRoot(levelRoot, key).toPath();
diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java
index 0b7dcbd4d..35815be4d 100644
--- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java
+++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java
@@ -1,5 +1,6 @@
package art.arcane.iris.core;
+import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.service.StudioSVC;
@@ -32,12 +33,16 @@ import java.nio.file.StandardOpenOption;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
public class IrisWorlds {
private static final String IRIS_GENERATOR_NAME = "iris";
private static final String IRIS_GENERATOR_PREFIX = "iris:";
+ private static final String IRIS_NAMESPACE = "iris";
private static final AtomicCache cache = new AtomicCache<>();
+ private static final Set UNUSABLE_STORAGE_REPORTED = ConcurrentHashMap.newKeySet();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Type TYPE = TypeToken.getParameterized(KMap.class, String.class, String.class).getType();
private final Path levelRoot;
@@ -49,15 +54,33 @@ public class IrisWorlds {
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
registryFile = registryFile(this.levelRoot);
this.worlds = new KMap<>();
- worlds.forEach((identity, type) -> this.worlds.put(WorldIdentity.parse(identity).toString(), type));
+ // One unreadable entry drops itself. This runs behind a static cache whose callers all dereference
+ // the result immediately, so anything thrown here stops Iris from enabling for every world.
+ worlds.forEach((identity, type) -> {
+ try {
+ this.worlds.put(WorldIdentity.parse(identity).toString(), type);
+ } catch (IllegalArgumentException unreadable) {
+ IrisLogging.warn("Dropping unreadable worlds.json entry %s: %s", identity, unreadable.getMessage());
+ dirty = true;
+ }
+ });
readBukkitWorlds(this.levelRoot).forEach((name, type) -> put0(
IrisWorldStorage.keyFromConfiguredWorldName(name, this.levelRoot.getFileName().toString()).toString(),
type));
save();
}
+ /**
+ * The world registry, never null.
+ *
+ * A failure here used to be swallowed into a null return, and every caller dereferences the result
+ * immediately, so a single unusable world folder turned into an NPE that stopped Iris from enabling -
+ * and a server whose Iris worlds then generated vanilla terrain over their own region files. Per-world
+ * storage problems are isolated in {@link #clean()} and {@link #loadDimension(String, String)}; anything
+ * that gets past those is a real failure and is raised, not hidden.
+ */
public static IrisWorlds get() {
- return cache.aquire(() -> {
+ return cache.aquireOnceOrThrow(() -> {
Path levelRoot = IrisWorldStorage.levelRoot().toPath().toAbsolutePath().normalize();
File file = registryFile(levelRoot).toFile();
if (!file.exists()) {
@@ -69,8 +92,8 @@ public class IrisWorlds {
KMap worlds = GSON.fromJson(json, TYPE);
return new IrisWorlds(levelRoot, Objects.requireNonNullElseGet(worlds, KMap::new));
} catch (Throwable e) {
- IrisLogging.error("Failed to load worlds.json for level root " + levelRoot + "!");
- e.printStackTrace();
+ IrisLogging.error("Failed to read worlds.json for level root " + levelRoot
+ + "; rebuilding it from bukkit.yml.");
IrisLogging.reportError(e);
}
@@ -138,6 +161,14 @@ public class IrisWorlds {
.filter(Objects::nonNull);
}
+ /**
+ * The dimension one registered world generates from, or null when its pack snapshot cannot supply it.
+ * Same resolution {@link #getDimensions()} uses, for callers that hold a single world identity.
+ */
+ public IrisDimension getDimension(String worldIdentity, String id) {
+ return loadDimension(worldIdentity, id);
+ }
+
public Stream getDimensions() {
return getWorlds()
.entrySet()
@@ -146,6 +177,14 @@ public class IrisWorlds {
.filter(Objects::nonNull);
}
+ /**
+ * Drops registry entries whose pack snapshot no longer backs them.
+ *
+ * A world whose storage is present but unusable is kept and reported instead of dropped: the entry is
+ * what lets {@code /iris remove} find the world, and dropping it would not stop the server from loading
+ * the folder anyway. It is excluded from {@link #getDimensions()} so one broken world cannot fail the
+ * whole registry - that failure used to propagate out of the constructor and null out {@link #get()}.
+ */
public synchronized void clean() {
boolean removed = worlds.entrySet().removeIf(entry -> {
try {
@@ -154,6 +193,9 @@ public class IrisWorlds {
|| !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists();
} catch (IllegalArgumentException e) {
return true;
+ } catch (IllegalStateException e) {
+ warnUnusableStorage(entry.getKey(), e);
+ return false;
}
});
dirty = dirty || removed;
@@ -232,27 +274,45 @@ public class IrisWorlds {
KMap result = new KMap<>();
for (Map.Entry entry : Objects.requireNonNull(configuredWorlds, "configuredWorlds").entrySet()) {
String configuredWorldName = entry.getKey();
- NamespacedKey worldKey = IrisWorldStorage.keyFromConfiguredWorldName(
- configuredWorldName,
- levelNamePath.toString());
- if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString())
- .equals(configuredWorldName)) {
+ NamespacedKey worldKey;
+ try {
+ worldKey = IrisWorldStorage.keyFromConfiguredWorldName(
+ configuredWorldName,
+ levelNamePath.toString());
+ if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString())
+ .equals(configuredWorldName)) {
+ continue;
+ }
+ } catch (IllegalArgumentException notAnIrisWorldName) {
+ // A bukkit.yml name that has no Iris identity is somebody else's world, never this
+ // registry's, and must not stop the whole registry from being built.
continue;
}
Path worldContainer = root.getParent();
if (worldContainer == null) {
throw new IllegalArgumentException("Selected level root has no world container: " + root);
}
+ boolean storagePresent;
try {
- if (IrisWorldStorage.frozenDimensionRoot(
+ storagePresent = IrisWorldStorage.frozenDimensionRoot(
worldContainer.toFile(),
root.toFile(),
configuredWorldName,
worldKey
- ).isPresent()) {
- result.put(configuredWorldName, entry.getValue());
- }
- } catch (IllegalStateException ignored) {
+ ).isPresent();
+ } catch (IllegalStateException unusableStorage) {
+ storagePresent = false;
+ }
+ if (storagePresent) {
+ result.put(configuredWorldName, entry.getValue());
+ continue;
+ }
+ // Vanilla slots are absent on every server that never created them; only an Iris world that
+ // bukkit.yml still points at is an orphan worth reporting.
+ if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
+ MissingWorldStorageLog.warnOnce(
+ configuredWorldName,
+ root.resolve("dimensions").resolve(IRIS_NAMESPACE).resolve(worldKey.getKey()));
}
}
return result;
@@ -310,8 +370,24 @@ public class IrisWorlds {
return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot);
}
+ private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) {
+ if (!UNUSABLE_STORAGE_REPORTED.add(worldIdentity)) {
+ return;
+ }
+ IrisLogging.error("Iris world %s has unusable world storage and is excluded: %s",
+ worldIdentity, failure.getMessage());
+ IrisLogging.error("Restore its iris/pack snapshot, or drop the world with /iris remove world=%s delete=true.",
+ worldIdentity);
+ }
+
private IrisDimension loadDimension(String worldIdentity, String id) {
- File pack = packRoot(worldIdentity).orElse(null);
+ File pack;
+ try {
+ pack = packRoot(worldIdentity).orElse(null);
+ } catch (IllegalStateException unusableStorage) {
+ warnUnusableStorage(worldIdentity, unusableStorage);
+ return null;
+ }
IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id);
if (dimension == null) {
dimension = IrisData.loadAnyDimension(id, null);
diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java
index 05de7503b..b6e4ef496 100644
--- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java
+++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java
@@ -986,12 +986,11 @@ public class ServerConfigurator {
if (INMS.get().supportsDataPacks()) {
- IrisLogging.error("============================================================================");
+ // Three sentences, no rules: a separator carries the record's severity too, so a box drawn
+ // out of equals signs became three more [SEVERE] lines saying nothing.
IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
- IrisLogging.error("----------------------------------------------------------------------------");
- IrisLogging.error(C.UNDERLINE + "IT IS HIGHLY RECOMMENDED YOU RESTART THE SERVER BEFORE GENERATING!");
- IrisLogging.error("============================================================================");
+ IrisLogging.error(C.UNDERLINE + "Restart the server before generating.");
for (Player i : Bukkit.getOnlinePlayers()) {
if (i.isOp() || i.hasPermission("iris.all")) {
diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java
index 45b6c4962..ec4517cc4 100644
--- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java
+++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java
@@ -99,7 +99,7 @@ public final class GuiHost {
desktop.setQuitHandler((event, response) -> cancelDesktopQuit(response));
} catch (Throwable error) {
IrisLogging.reportError(error);
- IrisLogging.warn("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
+ IrisLogging.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit");
}
}
diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java
index f2d9b307c..573c08f83 100644
--- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java
+++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java
@@ -24,6 +24,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
+import java.util.stream.Stream;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
@@ -113,58 +114,12 @@ public final class BukkitWorldConfiguration {
return List.of();
}
synchronized (MUTATION_LOCK) {
- YamlConfiguration configuration = load(requiredConfigurationFile);
- Object rawWorlds = configuration.get("worlds");
- ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
- if (rawWorlds != null && worlds == null) {
- throw new IOException("bukkit.yml worlds entry is not a section.");
- }
- if (worlds == null) {
- return List.of();
- }
-
- List configuredNames = new ArrayList<>(worlds.getKeys(false));
- configuredNames.sort(Comparator.naturalOrder());
Map bindings = new LinkedHashMap<>();
- for (String configuredName : configuredNames) {
- Object rawWorld = worlds.get(configuredName);
- ConfigurationSection world = worlds.getConfigurationSection(configuredName);
- if (rawWorld != null && world == null) {
- throw new IOException("bukkit.yml world entry \"" + configuredName + "\" is not a section.");
- }
- if (world == null || !world.getKeys(false).contains("generator")) {
- continue;
- }
- Object rawGenerator = world.get("generator");
- if (!(rawGenerator instanceof String generator)) {
- throw new IOException("bukkit.yml generator for world \"" + configuredName
- + "\" is not a string.");
- }
- String configuredGenerator = generator.trim();
- if (!configuredGenerator.equalsIgnoreCase("Iris")
- && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
- continue;
- }
-
- NamespacedKey namespacedKey;
- try {
- namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(
- configuredName,
- requiredLevelName
- );
- } catch (IllegalArgumentException failure) {
- throw new IOException("bukkit.yml contains an invalid Iris world name \""
- + configuredName + "\".", failure);
- }
- if (!"iris".equals(namespacedKey.getNamespace())) {
- continue;
- }
- if (!configuredName.equals(IrisWorldStorage.configuredWorldName(
- namespacedKey,
- requiredLevelName
- ))) {
- continue;
- }
+ for (IrisWorldCandidate candidate : readIrisWorldCandidates(
+ load(requiredConfigurationFile),
+ requiredLevelName
+ )) {
+ String configuredName = candidate.configuredWorldName();
Path worldContainer = requiredLevelRoot.getParent();
if (worldContainer == null) {
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
@@ -175,18 +130,21 @@ public final class BukkitWorldConfiguration {
worldContainer.toFile(),
requiredLevelRoot.toFile(),
configuredName,
- namespacedKey
+ candidate.worldKey()
).isPresent();
} catch (IllegalStateException failure) {
storagePresent = false;
}
if (!storagePresent) {
+ MissingWorldStorageLog.warnOnce(
+ configuredName,
+ expectedDimensionRoot(requiredLevelRoot, candidate.worldKey()));
continue;
}
- String dimension = selectedIrisDimension(configuredGenerator, configuredName);
+ String dimension = selectedIrisDimension(candidate.configuredGenerator(), configuredName);
WorldSlotKey worldKey = new WorldSlotKey(
- namespacedKey.getNamespace(),
- namespacedKey.getKey()
+ candidate.worldKey().getNamespace(),
+ candidate.worldKey().getKey()
);
IrisGeneratorBinding binding = new IrisGeneratorBinding(configuredName, worldKey, dimension);
IrisGeneratorBinding previous = bindings.putIfAbsent(worldKey, binding);
@@ -199,6 +157,228 @@ public final class BukkitWorldConfiguration {
}
}
+ /**
+ * Classifies the world storage behind every Iris entry in bukkit.yml.
+ *
+ * {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
+ * anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose
+ * frozen pack snapshot is gone still gets loaded by the server, and if Iris cannot supply a generator
+ * for it the server writes vanilla terrain into Iris region files. That state has to be found before
+ * any level loads, so it is reported here rather than discovered at world-generation time.
+ */
+ public static List auditIrisWorldStorage(
+ File configurationFile,
+ String levelName,
+ Path levelRoot
+ ) throws IOException {
+ File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
+ String requiredLevelName = requireName(levelName, "Level name");
+ Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
+ .toAbsolutePath()
+ .normalize();
+ Path configurationPath = requiredConfigurationFile.toPath();
+ if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
+ return List.of();
+ }
+ Path worldContainer = requiredLevelRoot.getParent();
+ if (worldContainer == null) {
+ throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
+ }
+ synchronized (MUTATION_LOCK) {
+ List entries = new ArrayList<>();
+ for (IrisWorldCandidate candidate : readIrisWorldCandidates(
+ load(requiredConfigurationFile),
+ requiredLevelName
+ )) {
+ entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
+ }
+ return List.copyOf(entries);
+ }
+ }
+
+ private static IrisWorldStorageEntry classifyStorage(
+ Path worldContainer,
+ Path levelRoot,
+ IrisWorldCandidate candidate
+ ) {
+ String configuredName = candidate.configuredWorldName();
+ NamespacedKey worldKey = candidate.worldKey();
+ WorldSlotKey slotKey = new WorldSlotKey(worldKey.getNamespace(), worldKey.getKey());
+ Path expectedRoot = expectedDimensionRoot(levelRoot, worldKey);
+ File dimensionRoot;
+ try {
+ dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
+ worldContainer.toFile(),
+ levelRoot.toFile(),
+ configuredName,
+ worldKey
+ ).orElse(null);
+ } catch (IllegalStateException unusable) {
+ // The server only loads a level it can see as a directory, so an entry with no directory at
+ // either candidate path is an orphan, not a world that is about to generate.
+ return hasDimensionDirectory(worldContainer, levelRoot, configuredName, worldKey)
+ ? new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
+ IrisWorldStorageState.UNUSABLE, unusable.getMessage())
+ : new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
+ IrisWorldStorageState.MISSING, null);
+ }
+ if (dimensionRoot == null) {
+ return new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot,
+ IrisWorldStorageState.MISSING, null);
+ }
+ Path resolvedRoot = dimensionRoot.toPath().toAbsolutePath().normalize();
+ try {
+ IrisWorldStorage.requireFrozenPackRoot(dimensionRoot);
+ } catch (IllegalStateException unusablePack) {
+ // Only a folder that owns something can be corrupted by a vanilla generator writing into it.
+ return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
+ hasWorldData(resolvedRoot)
+ ? IrisWorldStorageState.UNUSABLE
+ : IrisWorldStorageState.EMPTY,
+ unusablePack.getMessage());
+ }
+ return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
+ IrisWorldStorageState.PRESENT, null);
+ }
+
+ /**
+ * True when the world folder holds something a vanilla generator could overwrite: an {@code iris/}
+ * directory marking it as an Iris world whose pack snapshot broke, or stored chunk data.
+ *
+ * Deleting a loaded world's folder and letting the server save the level back recreates a small
+ * {@code data/} skeleton with no regions and no {@code iris/} directory. That husk owns nothing, so
+ * refusing to boot over it only strands the server behind a manual delete.
+ */
+ private static boolean hasWorldData(Path worldRoot) {
+ if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
+ return true;
+ }
+ for (String directory : new String[]{"region", "entities", "poi", "mantle"}) {
+ if (hasEntries(worldRoot.resolve(directory))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean hasEntries(Path directory) {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ try (Stream entries = Files.list(directory)) {
+ return entries.findAny().isPresent();
+ } catch (IOException unreadable) {
+ // An unreadable directory is not proof that there is nothing to lose.
+ return true;
+ }
+ }
+
+ private static boolean hasDimensionDirectory(
+ Path worldContainer,
+ Path levelRoot,
+ String configuredWorldName,
+ NamespacedKey worldKey
+ ) {
+ if (occupiesDimensionPath(levelRoot, expectedDimensionRoot(levelRoot, worldKey))) {
+ return true;
+ }
+ Path configuredLevelRoot = worldContainer.resolve(configuredWorldName).normalize();
+ return occupiesDimensionPath(configuredLevelRoot, expectedDimensionRoot(configuredLevelRoot, worldKey));
+ }
+
+ /**
+ * True when something the server could load sits at the dimension path: a real directory, or a symbolic
+ * link on any segment below the level root.
+ *
+ * {@link IrisWorldStorage} refuses to resolve through a link, so a linked path is storage Iris cannot use,
+ * not storage that is not there. Calling it missing tells the operator to restore a folder that is already
+ * present and leaves whatever the link points at open to a vanilla generator.
+ */
+ private static boolean occupiesDimensionPath(Path levelRoot, Path dimensionRoot) {
+ Path root = levelRoot.toAbsolutePath().normalize();
+ Path target = dimensionRoot.toAbsolutePath().normalize();
+ if (!target.startsWith(root) || target.equals(root)) {
+ return Files.isSymbolicLink(target) || Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
+ }
+
+ Path current = root;
+ for (Path segment : root.relativize(target)) {
+ current = current.resolve(segment);
+ if (Files.isSymbolicLink(current)) {
+ return true;
+ }
+ }
+ return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
+ }
+
+ private static Path expectedDimensionRoot(Path levelRoot, NamespacedKey worldKey) {
+ return levelRoot.toAbsolutePath()
+ .normalize()
+ .resolve("dimensions")
+ .resolve(worldKey.getNamespace())
+ .resolve(worldKey.getKey())
+ .normalize();
+ }
+
+ /**
+ * Every canonical Iris entry in bukkit.yml, in configured-name order, before any storage is inspected.
+ * Malformed entries throw exactly as they always did; entries that are not Iris', or whose name is not
+ * the canonical startup name for their key, are dropped.
+ */
+ private static List readIrisWorldCandidates(
+ YamlConfiguration configuration,
+ String levelName
+ ) throws IOException {
+ Object rawWorlds = configuration.get("worlds");
+ ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
+ if (rawWorlds != null && worlds == null) {
+ throw new IOException("bukkit.yml worlds entry is not a section.");
+ }
+ if (worlds == null) {
+ return List.of();
+ }
+
+ List configuredNames = new ArrayList<>(worlds.getKeys(false));
+ configuredNames.sort(Comparator.naturalOrder());
+ List candidates = new ArrayList<>(configuredNames.size());
+ for (String configuredName : configuredNames) {
+ Object rawWorld = worlds.get(configuredName);
+ ConfigurationSection world = worlds.getConfigurationSection(configuredName);
+ if (rawWorld != null && world == null) {
+ throw new IOException("bukkit.yml world entry \"" + configuredName + "\" is not a section.");
+ }
+ if (world == null || !world.getKeys(false).contains("generator")) {
+ continue;
+ }
+ Object rawGenerator = world.get("generator");
+ if (!(rawGenerator instanceof String generator)) {
+ throw new IOException("bukkit.yml generator for world \"" + configuredName
+ + "\" is not a string.");
+ }
+ String configuredGenerator = generator.trim();
+ if (!configuredGenerator.equalsIgnoreCase("Iris")
+ && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
+ continue;
+ }
+
+ NamespacedKey namespacedKey;
+ try {
+ namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(configuredName, levelName);
+ } catch (IllegalArgumentException failure) {
+ throw new IOException("bukkit.yml contains an invalid Iris world name \""
+ + configuredName + "\".", failure);
+ }
+ if (!"iris".equals(namespacedKey.getNamespace())) {
+ continue;
+ }
+ if (!configuredName.equals(IrisWorldStorage.configuredWorldName(namespacedKey, levelName))) {
+ continue;
+ }
+ candidates.add(new IrisWorldCandidate(configuredName, namespacedKey, configuredGenerator));
+ }
+ return candidates;
+ }
+
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -522,6 +702,51 @@ public final class BukkitWorldConfiguration {
UNCHANGED
}
+ public enum IrisWorldStorageState {
+ /**
+ * Storage resolves and carries a usable frozen pack snapshot.
+ */
+ PRESENT,
+ /**
+ * No world directory at all. The server never enumerates the level, so nothing generates; the
+ * bukkit.yml and Multiverse entries are left alone so putting the folder back restores the world.
+ */
+ MISSING,
+ /**
+ * A world directory the server will load that holds no pack snapshot and no world data - the
+ * skeleton the server writes back when it saves a level whose folder was deleted underneath it.
+ * There is nothing to overwrite, so it is reported and the server starts.
+ */
+ EMPTY,
+ /**
+ * A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
+ * generate it and nothing else may.
+ */
+ UNUSABLE
+ }
+
+ public record IrisWorldStorageEntry(
+ String configuredWorldName,
+ WorldSlotKey worldKey,
+ Path storagePath,
+ IrisWorldStorageState state,
+ String detail
+ ) {
+ public IrisWorldStorageEntry {
+ configuredWorldName = requireName(configuredWorldName, "Configured world name");
+ Objects.requireNonNull(worldKey, "worldKey");
+ Objects.requireNonNull(storagePath, "storagePath");
+ Objects.requireNonNull(state, "state");
+ }
+ }
+
+ private record IrisWorldCandidate(
+ String configuredWorldName,
+ NamespacedKey worldKey,
+ String configuredGenerator
+ ) {
+ }
+
public record IrisGeneratorBinding(
String configuredWorldName,
WorldSlotKey worldKey,
diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java
new file mode 100644
index 000000000..cec987525
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java
@@ -0,0 +1,252 @@
+package art.arcane.iris.core.lifecycle;
+
+import art.arcane.iris.core.IrisWorldStorage;
+import art.arcane.iris.core.WorldSlotKey;
+
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/**
+ * Moves a hot-deleted Iris world's husk out of the dimensions tree before any level is created.
+ *
+ * Deleting a loaded world's folder and letting the server save the level back leaves a directory holding
+ * nothing but the server's own {@code data/} skeleton: no frozen pack snapshot, no regions, no mantle. The
+ * server still enumerates it, and both outcomes stop startup - Iris refuses to boot over a directory it
+ * cannot generate, and excluding it from the dimension registry instead trips Paper's interactive
+ * world-migration gate, which consumes no console input and hangs the boot forever.
+ *
+ * The husk is moved, never deleted: it is recoverable, and the move alone is enough to let the server start.
+ * The test is deliberately narrow. Anything with chunk data, an {@code iris/} directory holding anything of
+ * Iris' own, a symbolic link anywhere on the path, or a directory that cannot be read is left exactly where it
+ * is, for {@link BukkitWorldConfiguration#auditIrisWorldStorage} to fail closed on.
+ */
+public final class HuskWorldQuarantine {
+ private static final String IRIS_NAMESPACE = "iris";
+ private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+");
+ private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"};
+ private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
+ private static final String APPLE_DOUBLE_PREFIX = "._";
+ private static final DateTimeFormatter STAMP = DateTimeFormatter
+ .ofPattern("yyyyMMdd'T'HHmmss")
+ .withZone(ZoneOffset.UTC);
+ private static final int MAX_DESTINATION_ATTEMPTS = 64;
+
+ private HuskWorldQuarantine() {
+ }
+
+ public static List quarantineWorthlessHusks(Path levelRoot, Consumer warn) {
+ return quarantineWorthlessHusks(levelRoot, warn, Instant.now());
+ }
+
+ static List quarantineWorthlessHusks(Path levelRoot, Consumer warn, Instant at) {
+ Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
+ Consumer log = Objects.requireNonNull(warn, "warn");
+ Instant stamp = Objects.requireNonNull(at, "at");
+ String levelName = root.getFileName() == null ? null : root.getFileName().toString();
+ if (levelName == null || levelName.isBlank()) {
+ return List.of();
+ }
+ Path namespace = root.resolve("dimensions").resolve(IRIS_NAMESPACE);
+ if (Files.isSymbolicLink(namespace) || !Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) {
+ return List.of();
+ }
+
+ List candidates;
+ try (Stream entries = Files.list(namespace)) {
+ candidates = entries.sorted().toList();
+ } catch (IOException unreadable) {
+ // An unreadable namespace is not proof that there is nothing in it to lose.
+ return List.of();
+ }
+
+ List quarantined = new ArrayList<>();
+ for (Path candidate : candidates) {
+ String key = candidate.getFileName().toString();
+ if (!MANAGED_KEY.matcher(key).matches() || !isWorthlessHusk(candidate)) {
+ continue;
+ }
+ String worldName;
+ try {
+ worldName = IrisWorldStorage.configuredWorldName(
+ new WorldSlotKey(IRIS_NAMESPACE, key),
+ levelName);
+ } catch (RuntimeException notAManagedWorld) {
+ continue;
+ }
+ Path destination;
+ try {
+ destination = move(root, key, candidate, stamp);
+ } catch (IOException failure) {
+ log.accept("Iris husk world " + worldName + " at " + candidate
+ + " could not be moved aside (" + describe(failure)
+ + "); startup will stop until it is moved or deleted by hand.");
+ continue;
+ }
+ quarantined.add(new Quarantine(key, candidate, destination));
+ log.accept("Iris husk world " + worldName + ": no pack snapshot and no world data; moved "
+ + candidate + " -> " + destination
+ + ". Drop the entry with /iris remove world=" + worldName + " delete=true.");
+ }
+ return List.copyOf(quarantined);
+ }
+
+ /**
+ * True when nothing in this directory can be lost: no frozen pack snapshot, nothing of Iris' own under
+ * {@code iris/}, and no stored chunk data. Every uncertain answer - a symbolic link, an unreadable
+ * directory, an entry that is not the type it should be - is false, because uncertainty is not proof of
+ * worthlessness.
+ */
+ private static boolean isWorthlessHusk(Path dimensionRoot) {
+ if (!isReadableDirectory(dimensionRoot)) {
+ return false;
+ }
+ if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
+ return false;
+ }
+ for (String directory : CHUNK_DATA_DIRECTORIES) {
+ if (hasEntries(dimensionRoot.resolve(directory))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * True when the {@code iris/} entry holds anything of Iris' own.
+ *
+ * The desktop file managers recreate a directory to hold their own metadata while a delete is still in
+ * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
+ * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
+ * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
+ * the list all count as content.
+ */
+ private static boolean holdsIrisContent(Path irisRoot) {
+ if (Files.isSymbolicLink(irisRoot)) {
+ return true;
+ }
+ if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
+ return true;
+ }
+ try (Stream entries = Files.list(irisRoot)) {
+ return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
+ } catch (IOException unreadable) {
+ return true;
+ }
+ }
+
+ private static boolean isOperatingSystemMetadata(Path entry) {
+ if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ Path name = entry.getFileName();
+ if (name == null) {
+ return false;
+ }
+ String fileName = name.toString();
+ return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
+ }
+
+ /**
+ * True when the path is a real directory that can actually be listed. The permission bits alone are not
+ * the answer: what matters is whether Iris can see everything in it before deciding it holds nothing.
+ */
+ private static boolean isReadableDirectory(Path directory) {
+ if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ try (Stream entries = Files.list(directory)) {
+ entries.findAny();
+ return true;
+ } catch (IOException unreadable) {
+ return false;
+ }
+ }
+
+ private static boolean hasEntries(Path directory) {
+ if (Files.isSymbolicLink(directory)) {
+ return true;
+ }
+ if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)
+ && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
+ return true;
+ }
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ try (Stream entries = Files.list(directory)) {
+ return entries.findAny().isPresent();
+ } catch (IOException unreadable) {
+ return true;
+ }
+ }
+
+ private static Path move(Path levelRoot, String key, Path husk, Instant at) throws IOException {
+ Path huskRoot = requireDirectoryPath(levelRoot, levelRoot.resolve(IRIS_NAMESPACE).resolve("husks"));
+ String base = key + "-" + STAMP.format(at);
+ for (int attempt = 1; attempt <= MAX_DESTINATION_ATTEMPTS; attempt++) {
+ Path destination = huskRoot.resolve(attempt == 1 ? base : base + "-" + attempt);
+ if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
+ continue;
+ }
+ try {
+ Files.move(husk, destination, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException notAtomic) {
+ Files.move(husk, destination);
+ }
+ DirectoryDurability.forceDirectoryAfterCommit(huskRoot, "An Iris husk world quarantine");
+ return destination;
+ }
+ throw new IOException("No free husk destination under " + huskRoot + " for " + key);
+ }
+
+ /**
+ * Creates the quarantine directory, refusing when any segment below the level root is a symbolic link so
+ * a hostile or accidental link cannot redirect a world folder out of the level root. Segments above the
+ * level root are the server's own installation path and are not Iris' to judge.
+ */
+ private static Path requireDirectoryPath(Path levelRoot, Path directory) throws IOException {
+ Path current = levelRoot;
+ for (Path segment : levelRoot.relativize(directory)) {
+ current = current.resolve(segment);
+ if (Files.isSymbolicLink(current)) {
+ throw new IOException("Husk quarantine path contains a symbolic link: " + current);
+ }
+ if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
+ && !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Husk quarantine path is not a directory: " + current);
+ }
+ }
+ Files.createDirectories(directory);
+ return directory;
+ }
+
+ private static String describe(Throwable failure) {
+ String message = failure.getMessage();
+ return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
+ }
+
+ public record Quarantine(String worldKey, Path husk, Path destination) {
+ public Quarantine {
+ Objects.requireNonNull(worldKey, "worldKey");
+ Objects.requireNonNull(husk, "husk");
+ Objects.requireNonNull(destination, "destination");
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java b/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java
new file mode 100644
index 000000000..c559ef23e
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java
@@ -0,0 +1,38 @@
+package art.arcane.iris.core.lifecycle;
+
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Loads a persistent Iris world back into a running server.
+ *
+ * Iris creates its worlds from a keyed WorldCreator so the level lands in
+ * {@code /dimensions/iris/} and carries the environment the pack's dimension declares.
+ * Nothing else can: a plain name-keyed creator builds a fresh vanilla world beside the level root, and a
+ * custom-dimension world reports {@code CUSTOM} after its first restart, which CraftServer refuses outright.
+ * Any integration that wants an Iris world loaded has to come through here.
+ *
+ * The implementation is installed by the platform adapter; core resolves it through
+ * {@link art.arcane.iris.spi.IrisServices} and degrades when nothing is registered.
+ */
+public interface ManagedWorldLoader {
+ /**
+ * Loads the world registered under {@code configuredWorldName} - the Bukkit startup name, not an alias.
+ * Never throws: a refusal is reported through the returned outcome.
+ */
+ CompletableFuture load(String configuredWorldName);
+
+ record ManagedWorldLoad(boolean loaded, String message) {
+ public ManagedWorldLoad {
+ Objects.requireNonNull(message, "message");
+ }
+
+ public static ManagedWorldLoad loaded(String message) {
+ return new ManagedWorldLoad(true, message);
+ }
+
+ public static ManagedWorldLoad failed(String message) {
+ return new ManagedWorldLoad(false, message);
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java b/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java
new file mode 100644
index 000000000..ffe5a4688
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java
@@ -0,0 +1,118 @@
+package art.arcane.iris.core.lifecycle;
+
+import art.arcane.iris.spi.IrisLogging;
+import art.arcane.iris.spi.IrisPlatforms;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * One warning per orphaned Iris world per boot.
+ *
+ * The bootstrap storage audit, the bukkit.yml binding read and the worlds.json registry read all notice the
+ * same orphan, and the registry read runs on every {@code getWorlds()}, so the warning is deduplicated by
+ * configured world name instead of being emitted per code path.
+ *
+ * Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the
+ * console and the runtime log but never the instance's {@code logs/latest.log}, which is the only log most
+ * operators read. Warnings raised while no platform is bound are held and re-emitted once by
+ * {@link #replayToPlatformLog()}.
+ *
+ * Nothing here prunes configuration. A world folder can be missing because a drive is unmounted or a backup
+ * restore is in flight, and the surviving bukkit.yml and Multiverse entries are exactly what makes putting
+ * the folder back a complete recovery.
+ */
+public final class MissingWorldStorageLog {
+ private static final Set WARNED = ConcurrentHashMap.newKeySet();
+ private static final List PENDING = new ArrayList<>();
+ private static final Object PENDING_LOCK = new Object();
+
+ private MissingWorldStorageLog() {
+ }
+
+ public static void warnOnce(String configuredWorldName, Path expectedStorage) {
+ String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName");
+ Path storage = Objects.requireNonNull(expectedStorage, "expectedStorage");
+ if (!WARNED.add(worldName)) {
+ return;
+ }
+ emit(new String[]{
+ IrisLogging.format("Iris world %s is configured but has no world storage at %s; it will not load.",
+ worldName, storage),
+ IrisLogging.format("Restore that folder, or drop the world with /iris remove world=%s delete=true.",
+ worldName)
+ });
+ }
+
+ /**
+ * Reports a world folder that survives with no pack snapshot and no world data - the skeleton the server
+ * writes back when it saves a level whose folder was deleted underneath it. There is nothing in it to
+ * lose, so it is reported instead of stopping startup, and the remedy is a delete rather than a restore.
+ */
+ public static void warnEmptyOnce(String configuredWorldName, Path storage) {
+ String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName");
+ Path worldStorage = Objects.requireNonNull(storage, "storage");
+ if (!WARNED.add(worldName)) {
+ return;
+ }
+ emit(new String[]{
+ IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region"
+ + " data; it will generate as vanilla terrain.", worldName, worldStorage),
+ IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
+ worldName)
+ });
+ }
+
+ public static boolean hasWarned(String configuredWorldName) {
+ return WARNED.contains(Objects.requireNonNull(configuredWorldName, "configuredWorldName"));
+ }
+
+ /**
+ * Re-emits every warning raised before a platform was bound, exactly once. Safe to call more than once
+ * and safe to call when nothing was held.
+ */
+ public static void replayToPlatformLog() {
+ List held;
+ synchronized (PENDING_LOCK) {
+ if (PENDING.isEmpty()) {
+ return;
+ }
+ held = List.copyOf(PENDING);
+ PENDING.clear();
+ }
+ for (String[] lines : held) {
+ write(lines);
+ }
+ }
+
+ /**
+ * Visible for tests: the deduplication set is per JVM, not per server boot.
+ */
+ public static void reset() {
+ WARNED.clear();
+ synchronized (PENDING_LOCK) {
+ PENDING.clear();
+ }
+ }
+
+ private static void emit(String[] lines) {
+ write(lines);
+ if (IrisPlatforms.isBound()) {
+ return;
+ }
+ synchronized (PENDING_LOCK) {
+ PENDING.add(lines);
+ }
+ }
+
+ private static void write(String[] lines) {
+ for (String line : lines) {
+ // Pre-formatted: passing no arguments keeps a path containing '%' out of the format engine.
+ IrisLogging.warn(line);
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java b/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java
new file mode 100644
index 000000000..2f048e9fe
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java
@@ -0,0 +1,81 @@
+package art.arcane.iris.core.lifecycle;
+
+import art.arcane.iris.spi.IrisLogging;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Detects an Iris world folder that was deleted out from under a live engine.
+ *
+ * Every persistence path resolves its target from a world folder captured when the engine bound, and the
+ * writes create their own parents. After an external delete that turns a save into a rebuild: a directory
+ * tree with engine data and server {@code .dat} files but no pack snapshot and no regions, which the next
+ * boot reads as an owned-but-broken world. Persistence stops instead, once the folder is gone.
+ *
+ * The verdict latches. A {@code save-all} after the delete writes the level's own {@code data/*.dat} files
+ * back, which recreates the world folder, and a plain "is it a directory" check would then let Iris resume
+ * writing into a tree that no longer holds the world. It also accepts a directory Iris established under the
+ * folder: once Iris has written {@code iris/engine-data} there, that directory disappearing is a delete
+ * whether or not the server has already put the folder back, which is what makes the next boot's storage
+ * classification the same on every run instead of depending on save ordering.
+ */
+public final class VanishedWorldStorage {
+ private static final Set VANISHED = ConcurrentHashMap.newKeySet();
+
+ private VanishedWorldStorage() {
+ }
+
+ /**
+ * True when the world folder is no longer a directory, or was already found gone earlier in this JVM.
+ */
+ public static boolean vanished(File worldFolder) {
+ return vanished(worldFolder, null);
+ }
+
+ /**
+ * True when the world folder is gone, or when {@code establishedTree} - a directory Iris is known to have
+ * created under it already - is gone. Reports the first time it sees each folder.
+ *
+ * @param establishedTree a directory Iris has already written, or null when it has written none yet
+ */
+ public static boolean vanished(File worldFolder, File establishedTree) {
+ if (worldFolder == null) {
+ return false;
+ }
+ Path path = worldFolder.toPath().toAbsolutePath().normalize();
+ if (VANISHED.contains(path.toString())) {
+ return true;
+ }
+ if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
+ return report(path);
+ }
+ if (establishedTree == null) {
+ return false;
+ }
+ Path tree = establishedTree.toPath().toAbsolutePath().normalize();
+ if (Files.isDirectory(tree, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ return report(path);
+ }
+
+ /**
+ * Visible for tests: the report set is per JVM, not per server boot.
+ */
+ public static void reset() {
+ VANISHED.clear();
+ }
+
+ private static boolean report(Path path) {
+ if (VANISHED.add(path.toString())) {
+ IrisLogging.error("Iris world storage is gone at %s; that world is no longer being written.", path);
+ IrisLogging.error("Unload or remove the world, or restore the folder and restart the server.");
+ }
+ return true;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java
index e2ed0b771..dab62c6d6 100644
--- a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java
+++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java
@@ -19,23 +19,33 @@
package art.arcane.iris.core.link;
import art.arcane.iris.core.IrisWorldStorage;
+import art.arcane.iris.core.IrisWorlds;
+import art.arcane.iris.engine.object.IrisDimension;
+import art.arcane.iris.platform.bukkit.BukkitEnvironment;
import art.arcane.iris.spi.IrisLogging;
-import lombok.SneakyThrows;
+import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.mvplugins.multiverse.core.MultiverseCoreApi;
+import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.utils.result.Attempt;
+import org.mvplugins.multiverse.core.utils.result.FailureReason;
+import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.options.ImportWorldOptions;
+import org.mvplugins.multiverse.core.world.options.LoadWorldOptions;
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
+import org.mvplugins.multiverse.core.world.reasons.LoadFailureReason;
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
+import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
/**
@@ -44,37 +54,63 @@ import java.util.Objects;
* its worlds under the Paper startup name ({@code __}), so the name Multiverse
* sees at import is already the name it will see again after every restart, and it is the name every
* Iris unregistration uses.
+ *
+ * Every entry point here degrades instead of throwing when Multiverse is absent, not yet loaded, or
+ * has moved the internals this link reflects into. A server without Multiverse must behave exactly as
+ * it did before this link existed.
*/
public class MultiverseCoreLink {
+ public static final String MULTIVERSE_PLUGIN = "Multiverse-Core";
+ private static final String GENERATOR_PREFIX = "Iris:";
+ /**
+ * Reflection into Multiverse internals is warned about once. A server that keeps logging the same
+ * broken accessor for every world tells the admin nothing the first line did not.
+ */
+ private static volatile boolean worldConfigUnwritable;
+
/**
* Best-effort scrub for worlds Iris never registered (studio worlds). Multiverse not knowing the
* world is the normal case here, so a miss is not worth a warning.
*/
public boolean removeIfPresent(World world) {
String worldName = Objects.requireNonNull(world, "world").getName();
- if (!isActive()) {
- return false;
- }
WorldManager manager = worldManager();
- MultiverseWorld multiverseWorld = resolve(manager, worldName);
- if (multiverseWorld == null) {
+ if (manager == null) {
+ return false;
+ }
+ try {
+ MultiverseWorld multiverseWorld = resolve(manager, worldName);
+ return multiverseWorld != null && remove(manager, multiverseWorld, worldName);
+ } catch (Throwable failure) {
+ // Studio teardown is not allowed to fail over a Multiverse bookkeeping entry.
+ IrisLogging.warn("Multiverse refused to drop \"%s\": %s", worldName, describe(failure));
return false;
}
- return remove(manager, multiverseWorld, worldName);
}
/**
* Authoritative unregistration for a world Iris registered. A miss leaves a ghost entry behind in
- * Multiverse's worlds.yml, so it is reported.
+ * Multiverse's worlds.yml, so it is reported. A refusal from Multiverse itself is raised, because
+ * the caller is a removal the admin asked for and a silent success would delete the world files
+ * while Multiverse kept pointing at them.
*/
public boolean removeFromConfig(String configuredWorldName) {
- String worldName = requireWorldName(configuredWorldName);
- if (!isActive()) {
- IrisLogging.debug("Multiverse is not enabled; skipped unregistering \"" + worldName + "\".");
+ WorldManager manager = worldManager();
+ if (manager == null) {
+ IrisLogging.debug("Multiverse is not available; skipped unregistering \""
+ + configuredWorldName + "\".");
+ return false;
+ }
+ String worldName = requireWorldName(configuredWorldName);
+ MultiverseWorld multiverseWorld;
+ try {
+ multiverseWorld = resolve(manager, worldName);
+ } catch (Throwable failure) {
+ // Only a refusal Multiverse states is worth stopping a removal for; an unusable link is not.
+ IrisLogging.warn("Could not look %s up in Multiverse: %s; its worlds.yml entry was left as-is.",
+ worldName, describe(failure));
return false;
}
- WorldManager manager = worldManager();
- MultiverseWorld multiverseWorld = resolve(manager, worldName);
if (multiverseWorld == null) {
IrisLogging.warn("Multiverse has no world registered as %s; its worlds.yml entry was left as-is.",
worldName);
@@ -83,55 +119,394 @@ public class MultiverseCoreLink {
return remove(manager, multiverseWorld, worldName);
}
- @SneakyThrows
+ /**
+ * Registers a freshly created world with Multiverse. Never throws: a Multiverse failure here used
+ * to unwind {@code IrisCreator}, whose rollback deletes the world storage that was just built.
+ */
public void updateWorld(World bukkitWorld, String configuredWorldName, String pack) {
- World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld");
- String worldName = requireWorldName(configuredWorldName);
- if (!isActive()) {
+ try {
+ World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld");
+ String worldName = requireWorldName(configuredWorldName);
+ WorldManager manager = worldManager();
+ if (manager == null) {
+ return;
+ }
+ if (!worldName.equals(world.getName())) {
+ // Multiverse records the live name as legacy-world-name. A live name that is not the
+ // startup name makes it re-import the world next boot and collide with its own config key.
+ IrisLogging.info("World %s is live as %s; Multiverse will record the live name.",
+ worldName, world.getName());
+ }
+ String generator = GENERATOR_PREFIX + Objects.requireNonNull(pack, "pack");
+ MultiverseWorld multiverseWorld = manager.getWorld(world)
+ .orElse(() -> manager.getWorld(worldName))
+ .getOrElse(() -> {
+ // Import through the live world so Multiverse binds its own config key to the
+ // key Paper gave the world and records the startup name it was created under.
+ ImportWorldOptions options = ImportWorldOptions.worldName(world.getName())
+ .generator(generator)
+ .environment(world.getEnvironment())
+ .useSpawnAdjust(false);
+ return manager.importWorld(options).get();
+ });
+
+ applyIrisWorldSettings(multiverseWorld, generator);
+ manager.saveWorldsConfig().get();
+ } catch (Throwable failure) {
+ IrisLogging.warn("Multiverse registration failed for %s: %s; the world is unaffected.",
+ String.valueOf(configuredWorldName), describe(failure));
+ }
+ }
+
+ /**
+ * Re-imposes Iris' intended Multiverse state on every world Iris owns.
+ *
+ * Iris registers worlds it creates, but worlds reconciled out of bukkit.yml at boot are registered
+ * by Multiverse's own auto-import instead, which defaults {@code auto-load} and {@code adjust-spawn}
+ * on. Auto-load makes Multiverse try to load an Iris world every boot and log a SEVERE when Paper
+ * has already loaded it, and adjust-spawn hands Multiverse the authority to relocate and persist a
+ * spawn Iris chose.
+ *
+ * The stored environment is corrected here too. Multiverse refuses to adopt a world whose live
+ * environment differs from the one it stored, and an Iris world's live environment is not stable across a
+ * restart: it is whatever the pack declares in the session the world was created or loaded in, and
+ * {@code CUSTOM} once the server enumerates the level from {@code /dimensions/iris/} on
+ * the next boot. A runtime {@code /mv load} therefore leaves Multiverse holding the pack's environment,
+ * and this pass is what moves it back to the live one before adoption is attempted.
+ *
+ * This is also where a world Iris already loaded is offered to Multiverse, which is what makes the
+ * running world visible to every Multiverse command that needs a loaded one.
+ *
+ * @return the number of worlds whose Multiverse entry was corrected; adoption is reported separately
+ * because it changes Multiverse's registry rather than the world's entry
+ */
+ public int reconcileOwnedWorlds() {
+ WorldManager manager = worldManager();
+ if (manager == null) {
+ return 0;
+ }
+ Map owned;
+ String levelName;
+ try {
+ levelName = IrisWorldStorage.levelRoot().getName();
+ owned = IrisWorlds.get().getWorlds();
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not read the Iris world registry for Multiverse reconciliation: %s",
+ describe(failure));
+ return 0;
+ }
+
+ int corrected = 0;
+ int adopted = 0;
+ for (Map.Entry entry : owned.entrySet()) {
+ NamespacedKey worldKey;
+ String worldName;
+ try {
+ worldKey = WorldIdentity.parse(entry.getKey());
+ worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName);
+ } catch (Throwable ignored) {
+ // Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it.
+ continue;
+ }
+ try {
+ MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
+ if (multiverseWorld == null) {
+ continue;
+ }
+ warnOnStaleRecordedName(multiverseWorld, worldName);
+ boolean changed = applyIrisWorldEnvironment(
+ multiverseWorld,
+ ownedWorldEnvironment(worldKey, entry.getValue()));
+ changed = applyIrisWorldSettings(multiverseWorld, GENERATOR_PREFIX + entry.getValue()) || changed;
+ if (changed) {
+ corrected++;
+ }
+ if (adoptLoadedWorld(manager, multiverseWorld, worldKey)) {
+ adopted++;
+ }
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not correct the Multiverse entry for %s: %s",
+ worldName, describe(failure));
+ }
+ }
+ if (corrected > 0) {
+ try {
+ manager.saveWorldsConfig().get();
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not save worlds.yml after Multiverse reconciliation: %s",
+ describe(failure));
+ }
+ IrisLogging.info("Corrected %d Multiverse world entr%s.", corrected, corrected == 1 ? "y" : "ies");
+ }
+ if (adopted > 0) {
+ IrisLogging.notice("Adopted %d live Iris world%s into Multiverse.", adopted, adopted == 1 ? "" : "s");
+ }
+ return corrected;
+ }
+
+ /**
+ * Points Multiverse's stored environment at the one Iris is about to create the world with, so the
+ * adoption Multiverse runs from its own {@code WorldLoadEvent} handler does not fail on the mismatch.
+ *
+ * Multiverse compares the stored environment to the live world's before it will take a world, and it
+ * rewrites the stored value from the live world every time it binds one. An Iris world reports the
+ * environment its pack declares in the session it was created and {@code CUSTOM} on every later boot, so
+ * the stored value is only ever right for one of the two - and it is this call, plus the boot-time pass
+ * in {@link #reconcileOwnedWorlds()}, that moves it between them.
+ */
+ public void prepareOwnedWorldLoad(String configuredWorldName) {
+ try {
+ WorldManager manager = worldManager();
+ if (manager == null) {
+ return;
+ }
+ String worldName = requireWorldName(configuredWorldName);
+ String levelName = IrisWorldStorage.levelRoot().getName();
+ NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName);
+ MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
+ if (multiverseWorld == null) {
+ return;
+ }
+ if (applyIrisWorldEnvironment(
+ multiverseWorld,
+ ownedWorldEnvironment(worldKey, ownedWorldPack(worldKey)))) {
+ manager.saveWorldsConfig().get();
+ }
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not prepare the Multiverse entry for %s: %s",
+ String.valueOf(configuredWorldName), describe(failure));
+ }
+ }
+
+ /**
+ * Offers a world Iris has just loaded to Multiverse, importing it when Multiverse has no entry at all.
+ *
+ * @return true when Multiverse now tracks the world as loaded
+ */
+ public boolean adoptOwnedWorld(String configuredWorldName) {
+ try {
+ WorldManager manager = worldManager();
+ if (manager == null) {
+ return false;
+ }
+ String worldName = requireWorldName(configuredWorldName);
+ String levelName = IrisWorldStorage.levelRoot().getName();
+ NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName);
+ World live = WorldIdentity.resolve(worldKey).orElse(null);
+ if (live == null) {
+ return false;
+ }
+ String pack = ownedWorldPack(worldKey);
+ MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName);
+ if (multiverseWorld == null) {
+ if (pack == null) {
+ return false;
+ }
+ updateWorld(live, worldName, pack);
+ return true;
+ }
+ boolean changed = applyIrisWorldEnvironment(multiverseWorld, live.getEnvironment());
+ changed = applyIrisWorldSettings(multiverseWorld, pack == null ? null : GENERATOR_PREFIX + pack)
+ || changed;
+ boolean adopted = adoptLoadedWorld(manager, multiverseWorld, worldKey);
+ if (changed || adopted) {
+ manager.saveWorldsConfig().get();
+ }
+ return adopted;
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not hand %s to Multiverse: %s",
+ String.valueOf(configuredWorldName), describe(failure));
+ return false;
+ }
+ }
+
+ private static String ownedWorldPack(NamespacedKey worldKey) {
+ try {
+ return IrisWorlds.get().getWorlds().get(worldKey.toString());
+ } catch (Throwable unreadable) {
+ IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": "
+ + describe(unreadable));
+ return null;
+ }
+ }
+
+ /**
+ * Binds a live Iris world into Multiverse's loaded registry.
+ *
+ * Iris turns {@code auto-load} off and loads its worlds out of bukkit.yml before Multiverse enables, so
+ * Multiverse never adopts them: {@code mv list} calls a running world UNLOADED and every command that
+ * needs a loaded world - {@code mv info}, {@code mv gamerule} - refuses it.
+ *
+ * Multiverse's {@code loadWorld} short-circuits to {@code Bukkit.getWorld()} when that
+ * world already exists and binds it without going near a WorldCreator. That short circuit is the whole
+ * safety property here: without a live world under the name Multiverse recorded, the same call would
+ * build one over Iris storage instead, so the world is looked up and identified first and adoption is
+ * skipped whenever it is not the target.
+ *
+ * @return true when Multiverse took the world
+ */
+ boolean adoptLoadedWorld(WorldManager manager, MultiverseWorld multiverseWorld, NamespacedKey worldKey) {
+ if (manager.isLoadedWorld(multiverseWorld)) {
+ return false;
+ }
+ World live = Bukkit.getWorld(multiverseWorld.getName());
+ if (live == null || !worldKey.equals(WorldIdentity.key(live))) {
+ return false;
+ }
+ Attempt adoption =
+ manager.loadWorld(LoadWorldOptions.world(multiverseWorld));
+ if (adoption.isFailure()) {
+ IrisLogging.warn("Multiverse would not adopt live world %s: %s",
+ multiverseWorld.getName(), describeFailure(adoption));
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * The Bukkit environment an Iris-owned world is or will be live with, or null when it cannot be
+ * determined.
+ *
+ * A live world already carries it, and it is the value Multiverse compares its own against before it will
+ * adopt the world, so it wins whenever there is one. With no live world the pack's dimension answers it -
+ * the same {@code BukkitEnvironment.from(dimension.getEnvironment())} Iris' own load path passes to the
+ * WorldCreator - so a world about to be loaded gets the environment it is about to have, not a guess at
+ * what Multiverse wants to hear.
+ */
+ World.Environment ownedWorldEnvironment(NamespacedKey worldKey, String pack) {
+ World live = WorldIdentity.resolve(worldKey).orElse(null);
+ if (live != null) {
+ return live.getEnvironment();
+ }
+ try {
+ IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack);
+ return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment());
+ } catch (Throwable unreadable) {
+ IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable));
+ return null;
+ }
+ }
+
+ /**
+ * Corrects the environment Multiverse stored for an Iris world.
+ *
+ * Multiverse compares the stored value to the live world's and refuses to adopt the world when they
+ * differ, and it rewrites the stored value from the live world every time it binds one. An Iris world's
+ * live environment flips between the pack's own and {@code CUSTOM} across a restart, so this is the only
+ * thing that keeps the two in step; without it {@code mv list} reports a running world as unloaded and
+ * every command that needs a loaded one refuses it.
+ *
+ * @return true when something was changed
+ */
+ boolean applyIrisWorldEnvironment(MultiverseWorld multiverseWorld, World.Environment environment) {
+ if (environment == null || environment == multiverseWorld.getEnvironment()) {
+ return false;
+ }
+ return setWorldConfigValue(multiverseWorld, "setEnvironment", World.Environment.class, environment);
+ }
+
+ /**
+ * Re-imposes Iris ownership on one Multiverse entry. Iris drives loading through bukkit.yml and owns
+ * the spawn, so Multiverse must neither load the world nor move its spawn.
+ *
+ * @return true when something was changed
+ */
+ boolean applyIrisWorldSettings(MultiverseWorld multiverseWorld, String generator) {
+ boolean changed = false;
+ if (multiverseWorld.isAutoLoad()) {
+ multiverseWorld.setAutoLoad(false);
+ changed = true;
+ }
+ if (multiverseWorld.getAdjustSpawn()) {
+ multiverseWorld.setAdjustSpawn(false);
+ changed = true;
+ }
+ if (generator != null && !generator.equals(multiverseWorld.getGenerator())) {
+ changed = setWorldConfigValue(multiverseWorld, "setGenerator", String.class, generator) || changed;
+ }
+ return changed;
+ }
+
+ /**
+ * Multiverse tracks the world name it saw at import and indexes its store by it. Iris cannot rewrite
+ * that name without desynchronising Multiverse's own name index, so a mismatch is reported instead.
+ */
+ void warnOnStaleRecordedName(MultiverseWorld multiverseWorld, String configuredWorldName) {
+ String recorded = multiverseWorld.getName();
+ if (configuredWorldName.equals(recorded)) {
return;
}
- if (!worldName.equals(world.getName())) {
- // Multiverse records the live name as legacy-world-name. A live name that is not the
- // startup name makes it re-import the world next boot and collide with its own config key.
- IrisLogging.warn("World %s is live as %s; Multiverse will record the live name.",
- worldName, world.getName());
- }
- String generator = "Iris:" + pack;
- WorldManager manager = worldManager();
- MultiverseWorld multiverseWorld = manager.getWorld(world)
- .orElse(() -> manager.getWorld(worldName))
- .getOrElse(() -> {
- // Import through the live world so Multiverse binds its own config key to the key
- // Paper gave the world and records the startup name the world was created under.
- ImportWorldOptions options = ImportWorldOptions.worldName(world.getName())
- .generator(generator)
- .environment(world.getEnvironment())
- .useSpawnAdjust(false);
- return manager.importWorld(options).get();
- });
+ IrisLogging.warn("Multiverse records %s as %s; remove that worlds.yml entry so Iris can re-register it.",
+ configuredWorldName, recorded);
+ }
- multiverseWorld.setAutoLoad(false);
- if (!generator.equals(multiverseWorld.getGenerator())) {
- setWorldConfigString(multiverseWorld, "setGenerator", generator);
+ /**
+ * Reports whether a Bukkit world name names a persistent world Iris owns on disk. Never throws, so a
+ * name Iris has no opinion about simply answers false.
+ */
+ public static boolean isIrisOwnedWorldName(String worldName, File levelRoot) {
+ if (worldName == null || levelRoot == null) {
+ return false;
}
-
- manager.saveWorldsConfig().get();
+ String levelName = levelRoot.getName();
+ NamespacedKey key;
+ try {
+ key = IrisWorldStorage.managedKeyFromName(worldName.trim(), levelName);
+ } catch (RuntimeException notAnIrisName) {
+ return false;
+ }
+ return IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, key);
}
private boolean remove(WorldManager manager, MultiverseWorld multiverseWorld, String worldName) {
- Attempt removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
+ Attempt removal = manager.removeWorld(removalOptions(multiverseWorld));
if (removal.isFailure()) {
throw new IllegalStateException("Multiverse refused to remove world \"" + worldName + "\": "
- + removal.getFailureMessage());
+ + describeFailure(removal));
}
manager.saveWorldsConfig().get();
return true;
}
- private MultiverseWorld resolve(WorldManager manager, String worldName) {
- for (String candidate : lookupNames(worldName, IrisWorldStorage.levelRoot().getName())) {
+ /**
+ * Removal options for one Multiverse world.
+ *
+ * Iris evacuates, unloads and closes a world before it unregisters it, but Multiverse still holds it in
+ * its loaded registry and its unload-before-remove dereferences the Bukkit world that is already gone,
+ * failing the whole removal. Multiverse only owns the unload while the Bukkit world is genuinely live.
+ */
+ static RemoveWorldOptions removalOptions(MultiverseWorld multiverseWorld) {
+ RemoveWorldOptions options = RemoveWorldOptions.world(multiverseWorld);
+ if (hasLiveBukkitWorld(multiverseWorld)) {
+ return options;
+ }
+ return options.unloadBukkitWorld(false).saveBukkitWorld(false);
+ }
+
+ private static boolean hasLiveBukkitWorld(MultiverseWorld multiverseWorld) {
+ LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
+ return loaded != null && loaded.getBukkitWorld().getOrNull() != null;
+ }
+
+ MultiverseWorld resolve(WorldManager manager, String worldName) {
+ return resolve(manager, worldName, IrisWorldStorage.levelRoot().getName());
+ }
+
+ /**
+ * The Multiverse entry for one Iris world, under whichever of its names Multiverse currently holds.
+ *
+ * A name other than the one asked for only identifies the same world when Multiverse keys its entry by
+ * the Iris world key, so an entry sitting on the runtime name under some other key is never returned:
+ * a removal must not delete a world that is not its target.
+ */
+ MultiverseWorld resolve(WorldManager manager, String worldName, String levelName) {
+ NamespacedKey expectedKey = managedKeyOrNull(worldName, levelName);
+ for (String candidate : lookupNames(worldName, levelName)) {
MultiverseWorld multiverseWorld = manager.getWorld(candidate).getOrElse((MultiverseWorld) null);
- if (multiverseWorld != null) {
+ if (multiverseWorld == null) {
+ continue;
+ }
+ if (candidate.equals(worldName) || expectedKey != null && expectedKey.equals(multiverseWorld.getKey())) {
return multiverseWorld;
}
}
@@ -139,32 +514,83 @@ public class MultiverseCoreLink {
}
/**
- * worlds.yml files written by Iris builds that created keyed worlds under {@code _}
- * still carry that name, so a startup name must also resolve through the Multiverse config key it
- * encodes or those entries can never be removed.
+ * Every name one Iris world can be registered under in Multiverse: the Bukkit startup name Iris
+ * registers, the Multiverse config key, and the {@code _} runtime name a world gets
+ * when Multiverse re-creates it from a keyed WorldCreator. Without all three an entry Multiverse
+ * reloaded under a different form can never be removed.
*/
static List lookupNames(String worldName, String levelName) {
- List candidates = new ArrayList<>(2);
+ List candidates = new ArrayList<>(3);
candidates.add(worldName);
- try {
- NamespacedKey key = IrisWorldStorage.managedKeyFromName(worldName, levelName);
- if (IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) {
- candidates.add(key.toString());
- }
- } catch (IllegalArgumentException ignored) {
- // Not an Iris startup name; the plain name is the only identity Multiverse can have.
+ NamespacedKey key = managedKeyOrNull(worldName, levelName);
+ if (key != null && IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) {
+ candidates.add(key.toString());
+ candidates.add(key.getNamespace() + "_" + key.getKey());
}
return List.copyOf(candidates);
}
- private static void setWorldConfigString(MultiverseWorld world, String setter, String value) throws Exception {
- Field field = MultiverseWorld.class.getDeclaredField("worldConfig");
- field.setAccessible(true);
+ private static NamespacedKey managedKeyOrNull(String worldName, String levelName) {
+ try {
+ return IrisWorldStorage.managedKeyFromName(worldName, levelName);
+ } catch (IllegalArgumentException notAnIrisName) {
+ // Not an Iris startup name; the plain name is the only identity Multiverse can have.
+ return null;
+ }
+ }
- Object config = field.get(world);
- Method method = config.getClass().getDeclaredMethod(setter, String.class);
- method.setAccessible(true);
- method.invoke(config, value);
+ /**
+ * {@code WorldConfig} and its setters are package-private in Multiverse, so its stored values can only
+ * be corrected reflectively. A Multiverse release that renames either must degrade to leaving the value
+ * alone rather than taking down whatever asked for the write.
+ */
+ private static boolean setWorldConfigValue(
+ MultiverseWorld world,
+ String setter,
+ Class> parameterType,
+ Object value
+ ) {
+ if (worldConfigUnwritable) {
+ return false;
+ }
+ try {
+ Field field = MultiverseWorld.class.getDeclaredField("worldConfig");
+ field.setAccessible(true);
+
+ Object config = field.get(world);
+ Method method = config.getClass().getDeclaredMethod(setter, parameterType);
+ method.setAccessible(true);
+ method.invoke(config, value);
+ return true;
+ } catch (ReflectiveOperationException | RuntimeException | Error failure) {
+ worldConfigUnwritable = true;
+ // A probe on a private Multiverse field. It is latched, the fallback is Multiverse's own
+ // handling of the setting, and nothing an operator can do changes the answer.
+ IrisLogging.info("Multiverse WorldConfig.%s is not reachable; Iris world settings are left to Multiverse.",
+ setter);
+ return false;
+ }
+ }
+
+ /**
+ * A Multiverse failure reason as text. {@code getFailureMessage} is a Multiverse {@code Message}, whose
+ * rendering goes through a locale manager that is not installed in every teardown order, so the reason
+ * name is the fallback rather than an object identity string.
+ */
+ private static String describeFailure(Attempt, ? extends FailureReason> attempt) {
+ Message message = attempt.getFailureMessage();
+ if (message != null) {
+ try {
+ String formatted = message.formatted();
+ if (formatted != null && !formatted.isBlank()) {
+ return formatted;
+ }
+ } catch (RuntimeException | Error unrenderable) {
+ // Fall through to the reason name.
+ }
+ }
+ FailureReason reason = attempt.getFailureReason();
+ return reason == null ? "no reason given" : reason.toString();
}
private static String requireWorldName(String worldName) {
@@ -175,12 +601,38 @@ public class MultiverseCoreLink {
return name;
}
- private WorldManager worldManager() {
- MultiverseCoreApi api = MultiverseCoreApi.get();
- return api.getWorldManager();
+ private static String describe(Throwable failure) {
+ String message = failure.getMessage();
+ return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
}
- private boolean isActive() {
- return Bukkit.getPluginManager().isPluginEnabled("Multiverse-Core");
+ /**
+ * The world manager, or null when Multiverse cannot serve one. Plugin enablement alone is not enough:
+ * the API singleton is installed during Multiverse's own enable, and Iris is asked for generators
+ * before that finishes.
+ */
+ WorldManager worldManager() {
+ if (!isActive()) {
+ return null;
+ }
+ try {
+ MultiverseCoreApi api = MultiverseCoreApi.get();
+ return api.getWorldManager();
+ } catch (RuntimeException | Error failure) {
+ IrisLogging.debug("Multiverse world manager is unavailable: " + describe(failure));
+ return null;
+ }
+ }
+
+ public boolean isActive() {
+ if (!Bukkit.getPluginManager().isPluginEnabled(MULTIVERSE_PLUGIN)) {
+ return false;
+ }
+ try {
+ return MultiverseCoreApi.isLoaded();
+ } catch (Error missingApi) {
+ // Multiverse older than 5.1 has no readiness probe; enablement is the only signal there is.
+ return true;
+ }
}
}
diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java
new file mode 100644
index 000000000..a77a1851f
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java
@@ -0,0 +1,487 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.core.link;
+
+import art.arcane.iris.core.IrisWorldStorage;
+import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
+import art.arcane.iris.core.tools.IrisToolbelt;
+import art.arcane.iris.spi.IrisLogging;
+import art.arcane.iris.spi.IrisServices;
+import art.arcane.iris.util.common.plugin.VolmitSender;
+import art.arcane.iris.util.common.scheduling.J;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerCommandPreprocessEvent;
+import org.bukkit.event.server.ServerCommandEvent;
+import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent;
+import org.mvplugins.multiverse.core.event.world.MVWorldImportedEvent;
+import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
+import org.mvplugins.multiverse.core.world.MultiverseWorld;
+import org.mvplugins.multiverse.core.world.WorldManager;
+
+import java.lang.ref.WeakReference;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.CompletionException;
+
+/**
+ * Keeps Multiverse's destructive world commands away from Iris storage.
+ *
+ * An Iris world folder is not a vanilla world folder: {@code /dimensions/iris/} also holds
+ * {@code iris/pack}, the frozen pack snapshot the world is generated from, and {@code iris/engine-data}.
+ * Multiverse deletes a world by recursively deleting its Bukkit world folder, and its {@code --keep-files}
+ * only matches file basenames, so a pack directory can never survive. The delete leaves the world, the
+ * pack and the Multiverse entry gone while bukkit.yml and Iris' worlds.json still point at them, and a
+ * half-deleted tree reads back as an owned-but-broken world.
+ *
+ * This listener is only registered while Multiverse is enabled. Every handler here names a Multiverse
+ * type, so registering it without Multiverse on the classpath would fail at handler resolution.
+ */
+public final class MultiverseGuardListener implements Listener {
+ private static final String IRIS_REMOVE_HINT = "Use /iris remove world=%s delete=true instead.";
+ private static final String MULTIVERSE_LOAD_PERMISSION = "multiverse.core.load";
+ private final MultiverseCoreLink link;
+ /**
+ * Multiverse world events carry no command sender, and its own cancellation message is the generic
+ * failure string, so the sender who typed the command has to be remembered across the dispatch. The
+ * capture is consumed on first read so a later API-driven delete cannot message an unrelated player.
+ */
+ private volatile WeakReference multiverseCommandSender;
+
+ public MultiverseGuardListener(MultiverseCoreLink link) {
+ this.link = Objects.requireNonNull(link, "link");
+ }
+
+ /**
+ * Cancels {@code mv delete} and {@code mv regen} for Iris worlds.
+ *
+ * Both commands funnel through {@code WorldManager#doDeleteWorld}, which fires this event after
+ * resolving the world folder but before it unloads the world or touches disk, so a cancel here is
+ * free of side effects. Regen is refused with delete because the event cannot tell them apart, and
+ * because a regen Iris did not drive would have Multiverse re-create the world through its own
+ * {@code CreateWorldOptions} rather than through Iris storage.
+ */
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onMultiverseWorldDelete(MVWorldDeleteEvent event) {
+ MultiverseWorld multiverseWorld = event.getWorld();
+ String worldName = multiverseWorld.getName();
+ if (!isIrisWorld(multiverseWorld)) {
+ return;
+ }
+ event.setCancelled(true);
+ String irisName = irisCommandName(multiverseWorld, worldName);
+ refuse(consumeCommandSender(),
+ "Multiverse cannot delete or regenerate Iris world " + worldName
+ + "; it would destroy the world-local pack snapshot.",
+ String.format(IRIS_REMOVE_HINT, irisName));
+ }
+
+ /**
+ * Multiverse auto-imports every world it finds that is not already in worlds.yml, using its own
+ * defaults, so an Iris world reconciled out of bukkit.yml arrives with {@code auto-load} and
+ * {@code adjust-spawn} on. Iris drives its own loading and owns the spawn, so both are turned back off.
+ */
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onMultiverseWorldImported(MVWorldImportedEvent event) {
+ MultiverseWorld multiverseWorld = event.getWorld();
+ if (!isIrisWorld(multiverseWorld)) {
+ return;
+ }
+ try {
+ if (!link.applyIrisWorldSettings(multiverseWorld, null)) {
+ return;
+ }
+ WorldManager manager = link.worldManager();
+ if (manager != null) {
+ manager.saveWorldsConfig().get();
+ }
+ IrisLogging.debug("Reset Multiverse auto-load and adjust-spawn for " + multiverseWorld.getName());
+ } catch (Throwable failure) {
+ IrisLogging.warn("Could not apply Iris settings to the Multiverse import of %s: %s",
+ multiverseWorld.getName(), failure.getClass().getSimpleName());
+ }
+ }
+
+ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
+ public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
+ if (inspectCommand(event.getPlayer(), event.getMessage())) {
+ event.setCancelled(true);
+ }
+ }
+
+ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
+ public void onServerCommand(ServerCommandEvent event) {
+ if (inspectCommand(event.getSender(), event.getCommand())) {
+ event.setCancelled(true);
+ }
+ }
+
+ /**
+ * Records the sender of a Multiverse command and vetoes a clone that reads from, or writes over, Iris
+ * storage.
+ *
+ * Multiverse fires no cancellable event for a clone: it copies the source world folder first and only
+ * then imports the copy, and the import of an Iris copy always fails. That leaves an orphaned copy of
+ * the world - frozen pack included - under {@code /dimensions/minecraft/}, which
+ * Multiverse then advertises as importable, so a later plain {@code mv import} would build a vanilla
+ * world on top of Iris region files. The command is the only place left to stop it.
+ *
+ * The destination is checked as well. Multiverse decides a name is free with its own
+ * {@code hasWorldFolder} check, which resolves the vanilla {@code /} path and never
+ * looks in the Iris dimension namespace, so a clone can be aimed straight at a live Iris world's slot.
+ *
+ * @return true when the command must be cancelled
+ */
+ private boolean inspectCommand(CommandSender sender, String commandLine) {
+ if (multiverseCommandRoot(commandLine) == null) {
+ return false;
+ }
+ multiverseCommandSender = new WeakReference<>(sender);
+ String load = multiverseLoad(commandLine);
+ if (load != null) {
+ return interceptLoad(sender, load);
+ }
+ CloneCommand clone = multiverseClone(commandLine);
+ if (clone == null) {
+ return false;
+ }
+ if (isIrisWorld(clone.source())) {
+ refuse(sender,
+ "Multiverse cannot clone Iris world " + clone.source()
+ + "; its pack snapshot is world-local and the copy is not a loadable world.",
+ "Create another Iris world with /iris create type= instead.");
+ return true;
+ }
+ if (clone.destination() != null && isIrisWorld(clone.destination())) {
+ refuse(sender,
+ "Multiverse cannot clone onto Iris world " + clone.destination()
+ + "; that name already owns Iris world storage.",
+ "Choose another destination name, or free it with /iris remove world="
+ + clone.destination() + " delete=true.");
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Takes over {@code /mv load} for an Iris world Iris can load itself.
+ *
+ * Multiverse only adopts a world it finds already in Bukkit; otherwise it rebuilds one from a
+ * WorldCreator carrying the environment it stored. Neither half of that works for an Iris world. The
+ * stored environment is written from the live world every time Multiverse binds it, and a custom
+ * dimension reports {@code NORMAL} in the session it was created and {@code CUSTOM} on every later boot,
+ * so {@code CraftServer.createWorld} refuses it with "Illegal dimension (CUSTOM)". Writing {@code normal}
+ * instead only trades that for the other failure: Multiverse would build a vanilla world beside the level
+ * root under the Iris world's name, and the next boot's adoption would fail on the mismatch.
+ *
+ * So the load is handed to Iris, which creates the world from a keyed creator at
+ * {@code /dimensions/iris/} with the pack's own environment, and the result is offered
+ * back to Multiverse. With no loader installed the command is refused rather than passed through, because
+ * passing it through is the failure this exists to prevent.
+ *
+ * @return true when the command must be cancelled
+ */
+ private boolean interceptLoad(CommandSender sender, String worldNameOrAlias) {
+ String worldName = irisWorldName(worldNameOrAlias);
+ if (worldName == null || Bukkit.getWorld(worldName) != null) {
+ // A live world is adopted by Multiverse without a WorldCreator; that path already works.
+ return false;
+ }
+ if (sender != null && !sender.hasPermission(MULTIVERSE_LOAD_PERMISSION)) {
+ // Taking the command over must never load a world for someone Multiverse would have refused.
+ return false;
+ }
+ ManagedWorldLoader loader = IrisServices.getOrNull(ManagedWorldLoader.class);
+ if (loader == null) {
+ refuse(sender,
+ "Iris owns loading for " + worldName + "; Multiverse would build a vanilla world in its place.",
+ "Load it with /iris load " + worldName + ", or restart the server.");
+ return true;
+ }
+ link.prepareOwnedWorldLoad(worldName);
+ IrisLogging.info("Multiverse load of %s handed to Iris.", worldName);
+ WeakReference target = new WeakReference<>(sender);
+ try {
+ // The load settles off the main thread, and handing the result to Multiverse fires a Bukkit
+ // event, so the whole tail runs back on the main thread exactly as /iris load's does.
+ loader.load(worldName).whenComplete((result, failure) ->
+ J.s(() -> settleLoad(target.get(), worldName, result, failure)));
+ } catch (Throwable failure) {
+ refuse(sender, "Iris could not load " + worldName + ": " + describe(failure),
+ "Load it with /iris load " + worldName + ", or restart the server.");
+ }
+ return true;
+ }
+
+ void settleLoad(
+ CommandSender sender,
+ String worldName,
+ ManagedWorldLoader.ManagedWorldLoad result,
+ Throwable failure
+ ) {
+ if (failure != null || result == null || !result.loaded()) {
+ String detail = failure != null
+ ? describe(failure)
+ : result == null ? "the load completed without a result" : result.message();
+ refuse(sender, "Iris could not load " + worldName + ": " + detail,
+ "Check the Iris log, then retry with /iris load " + worldName + ".");
+ return;
+ }
+ boolean adopted = link.adoptOwnedWorld(worldName);
+ IrisLogging.info("Iris loaded %s; multiverse adopted=%s.", worldName, adopted);
+ announce(sender, "Iris loaded " + worldName + ".");
+ }
+
+ /**
+ * The Bukkit startup name of the Iris world a Multiverse world argument names, or null when it names no
+ * Iris world. Multiverse accepts its own alias, so the argument is resolved through Multiverse first and
+ * the name Multiverse recorded is what Iris is asked for.
+ */
+ private String irisWorldName(String worldNameOrAlias) {
+ if (isIrisOwnedName(worldNameOrAlias)) {
+ return startupName(worldNameOrAlias);
+ }
+ WorldManager manager = link.worldManager();
+ if (manager == null) {
+ return null;
+ }
+ MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias)
+ .getOrElse((MultiverseWorld) null);
+ if (multiverseWorld == null) {
+ return null;
+ }
+ String recorded = multiverseWorld.getName();
+ return isIrisOwnedName(recorded) ? startupName(recorded) : null;
+ }
+
+ /**
+ * The Bukkit startup name for an Iris world named in any of the forms Iris answers to - the startup name
+ * itself, the bare key, or {@code iris:} - so the live-world check and the loader both see the one
+ * name the world is actually registered under.
+ */
+ private static String startupName(String worldName) {
+ try {
+ String levelName = IrisWorldStorage.levelRoot().getName();
+ return IrisWorldStorage.configuredWorldName(
+ IrisWorldStorage.managedKeyFromName(worldName, levelName),
+ levelName);
+ } catch (Throwable notAnIrisName) {
+ return worldName;
+ }
+ }
+
+ /**
+ * The world argument of a Multiverse load command, or null when the line is not one. Syntax is
+ * {@code /mv load [flags]} with the legacy alias {@code /mvload}.
+ */
+ static String multiverseLoad(String commandLine) {
+ String root = multiverseCommandRoot(commandLine);
+ if (root == null) {
+ return null;
+ }
+ String line = commandLine.trim();
+ if (line.startsWith("/")) {
+ line = line.substring(1);
+ }
+ String[] parts = line.split("\\s+");
+ if ("mv".equals(root)) {
+ return parts.length >= 3 && "load".equalsIgnoreCase(parts[1]) ? worldArgument(parts, 2) : null;
+ }
+ if ("mvload".equals(root)) {
+ return parts.length >= 2 ? worldArgument(parts, 1) : null;
+ }
+ return null;
+ }
+
+ /**
+ * The Multiverse command root, or null when the line is not one. Multiverse registers {@code /mv} plus
+ * per-command legacy aliases; anything Iris does not recognise is left alone, which is the behaviour a
+ * server without this listener already has.
+ */
+ static String multiverseCommandRoot(String commandLine) {
+ if (commandLine == null) {
+ return null;
+ }
+ String line = commandLine.trim();
+ if (line.startsWith("/")) {
+ line = line.substring(1);
+ }
+ int firstSpace = line.indexOf(' ');
+ String root = (firstSpace < 0 ? line : line.substring(0, firstSpace)).toLowerCase(Locale.ENGLISH);
+ // Bukkit always accepts the "plugin:command" form as well.
+ int namespace = root.lastIndexOf(':');
+ if (namespace >= 0) {
+ root = root.substring(namespace + 1);
+ }
+ // Every Multiverse alias starts with "mv". Matching the prefix rather than an enumeration keeps a
+ // new legacy alias from silently escaping the guard; a false match only stores a sender reference
+ // that nothing but a Multiverse world event ever reads.
+ return root.startsWith("mv") ? root : null;
+ }
+
+ /**
+ * The source and destination world arguments of a Multiverse clone command, or null when the line is
+ * not one. Syntax is {@code /mv clone [flags]} with the legacy aliases
+ * {@code /mvcl} and {@code /mvclone}. The destination is null when the admin has not typed it yet.
+ */
+ static CloneCommand multiverseClone(String commandLine) {
+ String root = multiverseCommandRoot(commandLine);
+ if (root == null) {
+ return null;
+ }
+ String line = commandLine.trim();
+ if (line.startsWith("/")) {
+ line = line.substring(1);
+ }
+ String[] parts = line.split("\\s+");
+ int worldIndex;
+ if ("mv".equals(root)) {
+ if (parts.length < 3 || !"clone".equalsIgnoreCase(parts[1])) {
+ return null;
+ }
+ worldIndex = 2;
+ } else if ("mvcl".equals(root) || "mvclone".equals(root)) {
+ if (parts.length < 2) {
+ return null;
+ }
+ worldIndex = 1;
+ } else {
+ return null;
+ }
+ String source = worldArgument(parts, worldIndex);
+ if (source == null) {
+ return null;
+ }
+ return new CloneCommand(source, worldArgument(parts, worldIndex + 1));
+ }
+
+ /**
+ * A world-name argument, or null when it is absent or a flag. Multiverse quotes names containing
+ * spaces; the guard only needs the leading token, so the opening quote is dropped and the rest kept.
+ */
+ private static String worldArgument(String[] parts, int index) {
+ if (index >= parts.length) {
+ return null;
+ }
+ String world = parts[index];
+ if (world.startsWith("-")) {
+ return null;
+ }
+ if (world.startsWith("\"")) {
+ world = world.substring(1);
+ }
+ return world.isEmpty() ? null : world;
+ }
+
+ record CloneCommand(String source, String destination) {
+ CloneCommand {
+ Objects.requireNonNull(source, "source");
+ }
+ }
+
+ private boolean isIrisWorld(MultiverseWorld multiverseWorld) {
+ LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
+ World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull();
+ if (bukkitWorld != null && IrisToolbelt.isIrisWorld(bukkitWorld)) {
+ return true;
+ }
+ return isIrisOwnedName(multiverseWorld.getName());
+ }
+
+ private boolean isIrisWorld(String worldNameOrAlias) {
+ World live = Bukkit.getWorld(worldNameOrAlias);
+ if (live != null && IrisToolbelt.isIrisWorld(live)) {
+ return true;
+ }
+ WorldManager manager = link.worldManager();
+ if (manager != null) {
+ MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias)
+ .getOrElse((MultiverseWorld) null);
+ if (multiverseWorld != null) {
+ return isIrisWorld(multiverseWorld);
+ }
+ }
+ return isIrisOwnedName(worldNameOrAlias);
+ }
+
+ private static boolean isIrisOwnedName(String worldName) {
+ try {
+ return MultiverseCoreLink.isIrisOwnedWorldName(worldName, IrisWorldStorage.levelRoot());
+ } catch (Throwable unavailable) {
+ return false;
+ }
+ }
+
+ /**
+ * The name {@code /iris remove} accepts. Multiverse reports the name it recorded at import, which is
+ * the Bukkit startup name for every world Iris registered.
+ */
+ private static String irisCommandName(MultiverseWorld multiverseWorld, String fallback) {
+ LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull();
+ World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull();
+ return bukkitWorld == null ? fallback : bukkitWorld.getName();
+ }
+
+ private CommandSender consumeCommandSender() {
+ WeakReference captured = multiverseCommandSender;
+ multiverseCommandSender = null;
+ return captured == null ? null : captured.get();
+ }
+
+ /**
+ * Multiverse reports a cancelled destructive command as its generic failure string, so the refusal has
+ * to reach the sender from here or the admin only sees "something went wrong".
+ */
+ private static void refuse(CommandSender sender, String reason, String remedy) {
+ // The refusal is the guard working, and the admin who typed the command is told directly. This is
+ // the console's copy of the same sentence, not a warning about the state of the server.
+ IrisLogging.info("%s %s", reason, remedy);
+ announce(sender, reason, remedy);
+ }
+
+ private static void announce(CommandSender sender, String... lines) {
+ if (sender == null || sender instanceof ConsoleCommandSender) {
+ // The console already saw it through the Iris log.
+ return;
+ }
+ try {
+ VolmitSender target = new VolmitSender(sender);
+ for (String line : lines) {
+ target.sendMessage(line);
+ }
+ } catch (Throwable ignored) {
+ for (String line : lines) {
+ sender.sendMessage(line);
+ }
+ }
+ }
+
+ private static String describe(Throwable failure) {
+ Throwable cause = failure instanceof CompletionException && failure.getCause() != null
+ ? failure.getCause()
+ : failure;
+ String message = cause.getMessage();
+ return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java
index 884004336..9cfe48269 100644
--- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java
+++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java
@@ -193,10 +193,6 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
return m;
}
- private static void printData(ResourceLoader> rl) {
- IrisLogging.warn(" " + rl.getResourceTypeName() + " @ /" + rl.getFolderName() + ": Cache=" + rl.getLoadCache().getSize() + " Folders=" + rl.getFolders().size());
- }
-
public static IrisObject loadAnyObject(String key, @Nullable IrisData nearest) {
return loadAny(IrisObject.class, key, nearest);
}
diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java
index c4a88a0b6..109eea2d5 100644
--- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java
+++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java
@@ -250,7 +250,7 @@ public class IrisPregenerator {
if (failedCount > 0) {
IrisLogging.warn("Pregen finished with " + Form.f(failedCount) + " failed chunk(s); failures are not cached, rerun to fill them");
}
- IrisLogging.info("Pregen finished: generated=" + Form.f(generated.get())
+ IrisLogging.notice("Pregen finished: generated=" + Form.f(generated.get())
+ " total=" + Form.f(totalChunks.get())
+ " failed=" + Form.f(failedCount)
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds())
diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java
index 032755be3..03ea5d2eb 100644
--- a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java
+++ b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java
@@ -39,7 +39,9 @@ public final class MantleHeapPressure {
System::currentTimeMillis,
System::gc,
() -> invokeHotSpotDiagnosticGc(ManagementFactory.getPlatformMBeanServer()),
- (double fraction) -> IrisLogging.warn(
+ // An escalation step in the reclaim policy, rate limited by that policy rather than by
+ // anything the operator did. What is actionable is the wait budget being exceeded.
+ (double fraction) -> IrisLogging.info(
"Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.",
fraction * 100.0D),
(String context, Throwable failure) -> IrisLogging.reportError(context, failure)));
diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java
index 834c6f5be..39152f161 100644
--- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java
+++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java
@@ -102,7 +102,9 @@ public final class PregenMantleBackpressure {
long logNow = M.ms();
if (logNow - lastLog >= 5_000L) {
lastLog = logNow;
- IrisLogging.warn("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap
+ // Pausing to stay under the plate cap is the design working, and it is reported every five
+ // seconds for the whole duration of a large pregen. Only exceeding the budget is a warning.
+ IrisLogging.info("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap
+ "), freed " + freed + " last pass, waited " + elapsed + "ms.");
}
@@ -147,7 +149,7 @@ public final class PregenMantleBackpressure {
long logNow = M.ms();
if (logNow - lastLog >= 5_000L) {
lastLog = logNow;
- IrisLogging.warn("Pregen heap pressure: pausing generation at "
+ IrisLogging.info("Pregen heap pressure: pausing generation at "
+ Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom"
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
}
diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java
index e6a17c91e..f89b9ec23 100644
--- a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java
+++ b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java
@@ -40,6 +40,7 @@ public class PregenCacheImpl implements PregenCache {
private final File directory;
private final int maxSize;
+ private final AtomicBoolean missingStorageReported = new AtomicBoolean();
private final Long2ObjectLinkedOpenHashMap cache = new Long2ObjectLinkedOpenHashMap<>();
public PregenCacheImpl(File directory, int maxSize) {
@@ -183,6 +184,9 @@ public class PregenCacheImpl implements PregenCache {
if (!plate.dirty) {
return;
}
+ if (!prepareCacheDirectory()) {
+ return;
+ }
File file = null;
try {
@@ -196,10 +200,29 @@ public class PregenCacheImpl implements PregenCache {
}
}
- private File fileForPlate(int x, int z) {
- if (!directory.exists() && !directory.mkdirs()) {
+ /**
+ * Creates the cache directory under an existing parent only. This is a cache: when the storage it lives
+ * in has been deleted the plate is dropped rather than rebuilt, so a trim cannot resurrect a world tree.
+ */
+ private boolean prepareCacheDirectory() {
+ if (directory.isDirectory()) {
+ return true;
+ }
+ File parent = directory.getParentFile();
+ if (parent == null || !parent.isDirectory()) {
+ if (missingStorageReported.compareAndSet(false, true)) {
+ IrisLogging.warn("Pregen cache storage is gone at %s; cached plates are being dropped.",
+ directory.getAbsolutePath());
+ }
+ return false;
+ }
+ if (!directory.mkdirs() && !directory.isDirectory()) {
throw new IllegalStateException("Cannot create directory: " + directory.getAbsolutePath());
}
+ return true;
+ }
+
+ private File fileForPlate(int x, int z) {
return new File(directory, "c." + x + "." + z + ".lz4b");
}
diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java
index ada2c93fc..a56aa24a7 100644
--- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java
+++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java
@@ -518,7 +518,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
int suppressed = suppressedSlowRequestLogs.getAndSet(0);
String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed;
- IrisLogging.warn("Async pregen chunk load at " + x + "," + z
+ // Slow chunk loads are what the adaptive in flight limit exists to absorb, and this line is already
+ // interval throttled with a suppression count. It reports the throttle working, not a fault.
+ IrisLogging.info("Async pregen chunk load at " + x + "," + z
+ " is still pending after " + slowRequestWarningSeconds + "s."
+ " adaptiveLimit=" + adaptiveInFlightLimit.get()
+ suppressedText + " " + metricsSnapshot());
diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java
index 52600e294..92232b937 100644
--- a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java
+++ b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java
@@ -86,7 +86,9 @@ public final class IrisSafeguard {
}
for (ValueWithDiagnostics value : results.values()) {
- value.log(true, true);
+ // Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log
+ // record per frame at the diagnostic's own severity. Traces go through reportError.
+ value.log(true, false);
}
}
@@ -98,20 +100,18 @@ public final class IrisSafeguard {
}
}
+ // A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of
+ // dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log.
private static void warning() {
IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode");
IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions.");
IrisLogging.warn(C.GRAY + "Iris will continue startup normally.");
- IrisLogging.warn("");
}
private static void unstable() {
IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode");
- IrisLogging.error("");
- IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--");
IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible.");
// No startup sleep: blocking the boot thread protected nothing — world creation and
// player admission are already gated by IrisStartupValidation.
- IrisLogging.info("");
}
}
diff --git a/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java
new file mode 100644
index 000000000..bb27f660d
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java
@@ -0,0 +1,109 @@
+/*
+ * Iris is a World Generator for Minecraft Bukkit Servers
+ * Copyright (c) 2022 Arcane Arts (Volmit Software)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package art.arcane.iris.core.service;
+
+import art.arcane.iris.core.link.MultiverseCoreLink;
+import art.arcane.iris.core.link.MultiverseGuardListener;
+import art.arcane.iris.spi.IrisLogging;
+import art.arcane.iris.spi.IrisServices;
+import art.arcane.iris.util.common.plugin.IrisService;
+import art.arcane.iris.platform.bukkit.BukkitPlatform;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.server.PluginDisableEvent;
+import org.bukkit.event.server.PluginEnableEvent;
+
+/**
+ * Brings Iris' Multiverse integration up exactly when Multiverse is usable and takes it back down when it
+ * is not.
+ *
+ * Nothing in this class names a Multiverse type. The guard listener does, and Bukkit resolves a listener's
+ * handler parameter types at registration, so it is only constructed once Multiverse is enabled. Iris
+ * declares {@code loadbefore: Multiverse-Core}, which means Multiverse normally enables after Iris and the
+ * plugin event is the live path; a reload or a plugin manager can invert that, so enablement is checked
+ * directly as well.
+ */
+public class MultiverseSVC implements IrisService {
+ private Listener guard;
+
+ @Override
+ public void onEnable() {
+ activate();
+ }
+
+ @Override
+ public void onDisable() {
+ deactivate();
+ }
+
+ @EventHandler
+ public void onPluginEnable(PluginEnableEvent event) {
+ if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) {
+ return;
+ }
+ activate();
+ }
+
+ @EventHandler
+ public void onPluginDisable(PluginDisableEvent event) {
+ if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) {
+ return;
+ }
+ deactivate();
+ }
+
+ /**
+ * Registers the destructive-command guard and re-imposes Iris' intended Multiverse state on every
+ * world Iris owns. Multiverse imports the worlds it did not know about during its own enable, which is
+ * before this runs, so the reconciliation pass is the only thing that can correct those entries.
+ */
+ private synchronized void activate() {
+ if (guard != null) {
+ return;
+ }
+ MultiverseCoreLink link = IrisServices.getOrNull(MultiverseCoreLink.class);
+ if (link == null || !link.isActive()) {
+ return;
+ }
+ try {
+ Listener listener = new MultiverseGuardListener(link);
+ BukkitPlatform.volmitPlugin().registerListener(listener);
+ guard = listener;
+ } catch (Throwable failure) {
+ // A Multiverse whose world events Iris cannot bind to is a Multiverse Iris must not touch.
+ IrisLogging.warn("Multiverse world events are not bindable (%s); Iris will not guard its"
+ + " destructive commands.", failure.getClass().getSimpleName());
+ return;
+ }
+ link.reconcileOwnedWorlds();
+ }
+
+ private synchronized void deactivate() {
+ Listener listener = guard;
+ if (listener == null) {
+ return;
+ }
+ guard = null;
+ try {
+ BukkitPlatform.volmitPlugin().unregisterListener(listener);
+ } catch (Throwable ignored) {
+ // Shutdown ordering already unregisters every handler.
+ }
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java b/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java
index 9e5f2480a..84d54756b 100644
--- a/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java
+++ b/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java
@@ -18,6 +18,7 @@
package art.arcane.iris.engine;
+import art.arcane.iris.core.lifecycle.VanishedWorldStorage;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisEngineData;
import art.arcane.iris.spi.IrisLogging;
@@ -31,6 +32,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
+import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -40,8 +42,14 @@ import java.nio.file.StandardCopyOption;
* atomic temp-file move so a crash mid-save can never truncate the live engine data.
*/
final class EngineDataStore {
+ private static final String ENGINE_DATA_DIRECTORY = "iris/engine-data";
private final IrisEngine engine;
private final Object engineDataLock = new Object();
+ /**
+ * Set once this store has written {@code iris/engine-data} into the world folder. From then on that
+ * directory going missing is a delete, whether or not the server has already put the world folder back.
+ */
+ private volatile boolean engineDataEstablished;
EngineDataStore(IrisEngine engine) {
this.engine = engine;
@@ -57,8 +65,9 @@ final class EngineDataStore {
if (loaded != null) {
return loaded;
}
- File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json");
+ File f = engineDataFile();
if (f.exists()) {
+ engineDataEstablished = true;
try {
loaded = new Gson().fromJson(IO.readAll(f), IrisEngineData.class);
if (loaded == null) {
@@ -79,12 +88,15 @@ final class EngineDataStore {
if (loaded.getStatistics().getVersion() == -1 || loaded.getStatistics().getMCVersion() == -1) {
IrisLogging.error("Failed to setup Engine Data!");
}
- try {
- writeEngineDataAtomically(f, loaded);
- } catch (IOException e) {
- IrisLogging.reportError(e);
- e.printStackTrace();
- throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e);
+ if (!storageVanished()) {
+ try {
+ writeEngineDataAtomically(f, loaded);
+ engineDataEstablished = true;
+ } catch (IOException e) {
+ IrisLogging.reportError(e);
+ e.printStackTrace();
+ throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e);
+ }
}
}
engine.engineData = loaded;
@@ -92,11 +104,39 @@ final class EngineDataStore {
}
}
+ /**
+ * True when this engine's world storage is gone, which stops persistence rather than letting the write
+ * rebuild the tree it is supposed to be writing into.
+ *
+ * The world folder existing is not enough. A {@code save-all} after a hot delete writes the level's own
+ * {@code data/*.dat} files back and recreates the folder, and Iris' {@code WorldSaveEvent} handler runs
+ * after that, so a folder check alone lets the save rebuild {@code iris/engine-data} - which is exactly
+ * the directory the next boot's storage audit reads as "this is an Iris world whose pack snapshot broke".
+ * Once this store has written that directory, its absence is the delete.
+ */
+ private boolean storageVanished() {
+ File worldFolder = engine.getWorld().worldFolder();
+ if (!engineDataEstablished) {
+ return VanishedWorldStorage.vanished(worldFolder);
+ }
+ return VanishedWorldStorage.vanished(worldFolder, new File(worldFolder, ENGINE_DATA_DIRECTORY));
+ }
+
+ private File engineDataFile() {
+ return new File(
+ engine.getWorld().worldFolder(),
+ ENGINE_DATA_DIRECTORY + "/" + engine.getDimension().getLoadKey() + ".json");
+ }
+
void saveEngineData() {
synchronized (engineDataLock) {
- File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json");
+ if (storageVanished()) {
+ return;
+ }
+ File f = engineDataFile();
try {
writeEngineDataAtomically(f, engine.getEngineData());
+ engineDataEstablished = true;
IrisLogging.debug("Saved Engine Data");
} catch (IOException e) {
IrisLogging.error("Failed to save Engine Data");
@@ -122,6 +162,13 @@ final class EngineDataStore {
if (parent == null) {
throw new IOException("Engine data path has no parent: " + output);
}
+ // /iris/engine-data/.json: the world folder itself is never created here, so a
+ // deleted world cannot be rebuilt by a save that races the guard in saveEngineData.
+ Path irisRoot = parent.getParent();
+ Path worldFolder = irisRoot == null ? null : irisRoot.getParent();
+ if (worldFolder == null || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException("Iris world storage is missing: " + worldFolder);
+ }
Files.createDirectories(parent);
Path temporary = Files.createTempFile(parent, output.getFileName().toString(), ".tmp");
try {
diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java
index 028022c06..04aef3d5f 100644
--- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java
+++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java
@@ -713,9 +713,12 @@ public class IrisComplex implements DataProvider {
} catch (Throwable e) {
long now = System.currentTimeMillis();
long last = lastBoundsFailureLog.get();
- if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now)) {
+ // The five second gate keeps this off the hot path; the once key keeps it out of a log
+ // scan for the rest of the generation, where the same cause repeats for every column.
+ if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now)
+ && IrisLogging.warnOnce("biome-bounds:" + e.getClass().getName() + ":" + e.getMessage(),
+ "Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage())) {
IrisLogging.reportError(e);
- IrisLogging.warn("Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java
index d406bff14..5646c1fc3 100644
--- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java
+++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java
@@ -226,7 +226,7 @@ public class IrisEngine implements Engine {
IrisLogging.reportError(e);
e.printStackTrace();
}
- IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
+ IrisLogging.notice("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
_t0 = M.ms();
phaseStartedAt = System.nanoTime();
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
diff --git a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java
index b1fac57af..a9e9ca322 100644
--- a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java
+++ b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java
@@ -28,6 +28,7 @@ public class AtomicCache {
private transient final Object initLock = new Object();
private transient final boolean nullSupport;
private transient volatile Object value;
+ private transient volatile Throwable failure;
public AtomicCache() {
this(false);
@@ -40,6 +41,7 @@ public class AtomicCache {
public void reset() {
synchronized (initLock) {
value = null;
+ failure = null;
}
}
@@ -72,6 +74,10 @@ public class AtomicCache {
* Like {@link #aquire(Supplier)} but propagates a supplier failure to the caller instead
* of swallowing it into a null return. For values that are mandatory: a caller of a
* "@NotNull" accessor should see the supplier's real exception, not a downstream NPE.
+ *
+ * The supplier is retried on every call until it produces a value, which is what a caller that can
+ * repair the cause between attempts needs. Use {@link #aquireOnceOrThrow(Supplier)} when re-running a
+ * failed supplier is itself the problem.
*/
public T aquireOrThrow(Supplier t) {
Object v = value;
@@ -96,6 +102,56 @@ public class AtomicCache {
}
}
+ /**
+ * Like {@link #aquireOrThrow(Supplier)} but memoizes the failure as well as the value, so a supplier
+ * that cannot produce one is run exactly once and every later caller is told the same reason. For
+ * values whose supplier rebuilds shared state, where re-running it per call repeats that work and
+ * hides the original cause behind whatever the retry happens to fail on. {@link #reset()} clears it.
+ */
+ public T aquireOnceOrThrow(Supplier t) {
+ Object v = value;
+
+ if (v != null) {
+ return unwrap(v);
+ }
+ rethrowFailure(failure);
+
+ synchronized (initLock) {
+ v = value;
+
+ if (v != null) {
+ return unwrap(v);
+ }
+ rethrowFailure(failure);
+
+ T computed;
+ try {
+ computed = t.get();
+ if (computed == null) {
+ throw new IllegalStateException("Atomic cache supplier produced null");
+ }
+ } catch (Throwable e) {
+ failure = e;
+ throw e;
+ }
+ value = computed;
+ return computed;
+ }
+ }
+
+ private static void rethrowFailure(Throwable memoized) {
+ if (memoized == null) {
+ return;
+ }
+ if (memoized instanceof RuntimeException runtimeFailure) {
+ throw runtimeFailure;
+ }
+ if (memoized instanceof Error error) {
+ throw error;
+ }
+ throw new IllegalStateException("Atomic cache supplier failed", memoized);
+ }
+
public T aquire(Supplier t) {
Object v = value;
@@ -122,8 +178,12 @@ public class AtomicCache {
value = NULL_VALUE;
}
} catch (Throwable e) {
- IrisLogging.error("Atomic cache failure!");
- e.printStackTrace();
+ // aquire retries on every call, so a supplier that stays broken is reached per sample.
+ // The first statement of it carries the stack; the rest are traces of the same cause.
+ if (IrisLogging.warnOnce("atomic-cache:" + e.getClass().getName() + ":" + e.getMessage(),
+ "Atomic cache supplier failed: %s: %s", e.getClass().getSimpleName(), e.getMessage())) {
+ IrisLogging.reportError(e);
+ }
}
return null;
diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java
index 7ec5bcf6f..0d706e139 100644
--- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java
+++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java
@@ -68,7 +68,9 @@ public class WorldObjectPlacer implements IObjectPlacer {
if (d instanceof IrisCustomData data) {
block.setBlockData(data.getBase(), false);
- IrisLogging.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!");
+ // Reached per block for every custom block in the pack; the pack is what needs changing, and
+ // one statement of that is enough.
+ IrisLogging.warnOnce("custom-block-placer", "Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported.");
} else block.setBlockData(d, false);
if (storageChest && !J.runRegion(world, x >> 4, z >> 4, () -> fillLoot(block), 1)) {
diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java
index 48b2c6ed3..a17e4a4a7 100644
--- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java
+++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java
@@ -341,7 +341,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
public MantleChunk acquireChunk(int cx, int cz) {
int index = windowIndex(cx, cz);
if (index < 0) {
- IrisLogging.error("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz);
+ IrisLogging.debug("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz);
return null;
}
@@ -927,7 +927,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
try {
setData(pos.getX(), pos.getY(), pos.getZ(), data);
} catch (Throwable e) {
- IrisLogging.error("No set? " + data.toString() + " for " + pos.toString());
+ // Reached per position while an object writes past the edge of the writer window, which is
+ // how a large object is clipped. Nothing here is actionable from outside the engine.
+ IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString());
}
}
diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java
index 4c1e1f0a2..0e8097ccc 100644
--- a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java
+++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java
@@ -276,7 +276,7 @@ public class IrisCompat {
searching:
while (true) {
if (err-- <= 0) {
- IrisLogging.error("Can't find block data for " + n);
+ IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE.");
return BukkitBlockResolution.getNoCompat("STONE");
}
String m = buf;
@@ -300,7 +300,7 @@ public class IrisCompat {
}
}
- IrisLogging.error("Can't find block data for " + n);
+ IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE.");
return BukkitBlockResolution.getNoCompat("STONE");
}
}
diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java
index 8db494a8b..a508ec57f 100644
--- a/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java
+++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java
@@ -182,7 +182,7 @@ public class IrisDecorator {
public PlatformBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) {
if (getBlockData(data).isEmpty()) {
- IrisLogging.warn("Empty Block Data for " + b.getName());
+ IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName());
return null;
}
diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java
index b2aa8d026..c16790273 100644
--- a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java
+++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java
@@ -19,13 +19,9 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisRegistrant;
-import art.arcane.iris.spi.IrisLogging;
-import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.IOException;
import java.util.Objects;
public class IrisImage extends IrisRegistrant {
@@ -116,21 +112,4 @@ public class IrisImage extends IrisRegistrant {
return "Image";
}
- public void writeDebug(IrisImageChannel channel) {
-
-
- try {
- File at = new File(getLoadFile().getParentFile(), "debug-see-" + getLoadFile().getName());
- BufferedImage b = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB);
- for (int i = 0; i < getWidth(); i++) {
- for (int j = 0; j < getHeight(); j++) {
- b.setRGB(i, j, Color.getHSBColor(0, 0, (float) getValue(channel, i, j)).getRGB());
- }
- }
- ImageIO.write(b, "png", at);
- IrisLogging.warn("Debug image written to " + at.getPath() + " for channel " + channel.name());
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
}
diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java
index aae29ede6..8638a1fb5 100644
--- a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java
+++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java
@@ -67,7 +67,9 @@ public class IrisImageMap {
public double getNoise(IrisData data, int x, int z) {
IrisImage i = imageCache.aquire(() -> data.getImageLoader().load(image));
if (i == null) {
- IrisLogging.error("NULL IMAGE FOR " + image);
+ // Reached per sample for as long as the pack points at an image that will not load, so the
+ // pack key is what the operator needs and one statement of it is enough.
+ IrisLogging.warnOnce("image-map:" + image, "No image %s in the pack; that noise samples as zero.", image);
return 0;
}
diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java
index 9d326ac5e..00d18bbc5 100644
--- a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java
+++ b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java
@@ -38,7 +38,9 @@ public class SlimJar {
.debug(DEBUG)
.build();
} catch (Throwable e) {
- IrisLogging.warn("Failed to inject the library loader, falling back to application builder");
+ // The Spigot builder is a probe: not every server exposes it, and the fallback is the
+ // supported path on the ones that do not.
+ IrisLogging.info("Failed to inject the library loader, falling back to application builder");
ApplicationBuilder.appending(plugin.getName())
.injectableFactory(InjectableFactory.selecting(InjectableFactory.ERROR, InjectableFactory.INJECTABLE, InjectableFactory.WRAPPED, InjectableFactory.UNSAFE))
.downloadDirectoryPath(downloadPath)
diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java b/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java
index 925240adf..57e10df6f 100644
--- a/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java
+++ b/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java
@@ -1009,7 +1009,9 @@ public interface Hunk extends HunkLike {
*/
default void set(int x, int y, int z, T t) {
if (!contains(x, y, z)) {
- IrisLogging.warn("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth());
+ // The clamp on the default write path: callers write past the edge of a hunk by design and
+ // rely on the write being dropped, so this is a trace of a normal event, not a problem.
+ IrisLogging.debug("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth());
return;
}
diff --git a/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java
new file mode 100644
index 000000000..03913867f
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java
@@ -0,0 +1,150 @@
+package art.arcane.iris.core;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Every core diagnostic keeps its severity through to the server log, so the level a call site picks is the
+ * level an operator scanning for WARN and SEVERE sees. This pins the two ends of that decision: conditions an
+ * operator must act on stay at WARN or ERROR, and conditions that repeat per block, per sample or per chunk -
+ * or that report designed backpressure, a probe on an optional dependency, or a success - do not.
+ *
+ * The pin is on the source rather than on a live engine because most of these call sites need a running world
+ * to reach, and what is being fixed is the choice of level, not the condition that triggers it.
+ */
+public class DiagnosticSeverityPolicyTest {
+ @Test
+ public void perBlockAndPerSampleConditionsDoNotReachTheServerLogAtWarnLevel() {
+ assertLoggedWith("util/project/hunk/Hunk.java", "OUT OF BOUNDS ", "debug");
+ assertLoggedWith("engine/mantle/MantleWriter.java", "No set? ", "debug");
+ assertLoggedWith("engine/mantle/MantleWriter.java", "Mantle Writer Accessed chunk out of bounds", "debug");
+ assertLoggedWith("engine/object/IrisImageMap.java", "No image ", "warnOnce");
+ assertLoggedWith("engine/object/IrisDecorator.java", "Empty Block Data for ", "warnOnce");
+ assertLoggedWith("engine/object/IrisCompat.java", "Can't find block data for ", "warnOnce");
+ assertLoggedWith("engine/data/cache/AtomicCache.java", "Atomic cache supplier failed: %s: %s", "warnOnce");
+ assertLoggedWith("engine/IrisComplex.java", "Failed to sample interpolated biome bounds", "warnOnce");
+ assertLoggedWith("engine/framework/placer/WorldObjectPlacer.java", "Tried to place custom block at", "warnOnce");
+ }
+
+ /**
+ * Backpressure pausing generation is the design working. It is reported every five seconds for the whole
+ * duration of a large pregen, so at WARN it would be the only thing a log scan finds.
+ */
+ @Test
+ public void designedBackpressureIsReportedWithoutRaisingAWarning() {
+ assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure: ", "info");
+ assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure: pausing generation", "info");
+ assertLoggedWith("core/pregenerator/MantleHeapPressure.java", "Iris heap remained at", "info");
+ assertLoggedWith("core/pregenerator/methods/AsyncPregenMethod.java", "is still pending after", "info");
+ }
+
+ @Test
+ public void probesOnOptionalDependenciesAreNotWarnings() {
+ assertLoggedWith("core/link/MultiverseCoreLink.java", "is not reachable; Iris world settings", "info");
+ assertLoggedWith("core/link/MultiverseCoreLink.java", "Multiverse will record the live name", "info");
+ assertLoggedWith("core/gui/GuiHost.java", "Unable to install the Iris desktop quit guard", "info");
+ assertLoggedWith("util/common/misc/SlimJar.java", "Failed to inject the library loader", "info");
+ }
+
+ /**
+ * A refused destructive command is the guard working, and the admin who typed it is told directly. The
+ * console record of it is not a warning about the server.
+ */
+ @Test
+ public void aRefusedMultiverseCommandIsRecordedWithoutRaisingAWarning() throws IOException {
+ String guard = read("core/link/MultiverseGuardListener.java");
+
+ assertTrue("the console record of a refusal is kept", guard.contains("IrisLogging.info(\"%s %s\", reason, remedy)"));
+ assertFalse("the refusal is not a warning about the server", guard.contains("IrisLogging.warn(\"%s %s\""));
+ }
+
+ @Test
+ public void decorationAndBlankLinesAreNotEmittedAtAnySeverity() throws IOException {
+ String safeguard = read("core/safeguard/IrisSafeguard.java");
+ assertFalse("a blank record renders as an empty [WARN] line", safeguard.contains("IrisLogging.warn(\"\")"));
+ assertFalse("a blank record renders as an empty [SEVERE] line", safeguard.contains("IrisLogging.error(\"\")"));
+ assertFalse("a blank record renders as an empty [INFO] line", safeguard.contains("IrisLogging.info(\"\")"));
+ assertFalse("a separator rule is not a diagnostic", safeguard.contains("--==<"));
+
+ String configurator = read("core/ServerConfigurator.java");
+ assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"===="));
+ assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"----"));
+ }
+
+ /**
+ * A written debug image is a success, and the method that wrote it had no callers at all.
+ */
+ @Test
+ public void theUnusedDebugImageWriterIsGone() throws IOException {
+ assertFalse(read("engine/object/IrisImage.java").contains("writeDebug"));
+ }
+
+ /**
+ * The other end of the policy. These are the conditions the promotion exists for.
+ */
+ @Test
+ public void operatorActionableConditionsKeepTheirSeverity() {
+ assertLoggedWith("core/lifecycle/VanishedWorldStorage.java", "Iris world storage is gone at", "error");
+ assertLoggedWith("core/IrisWorlds.java", "has unusable world storage and is excluded", "error");
+ assertLoggedWith("engine/EngineDataStore.java", "Failed to setup Engine Data", "error");
+ assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk section, skipping it.", "error");
+ assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk, creating a new chunk instead.", "error");
+ assertLoggedWith("util/common/reflect/WrappedField.java", "Failed to created WrappedField", "error");
+ assertLoggedWith("core/datapack/DatapackIngestService.java", "Repairing modified or corrupt Iris-managed datapack", "warn");
+ assertLoggedWith("core/pregenerator/cache/PregenCacheImpl.java", "Pregen cache storage is gone at", "warn");
+ assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure exceeded ", "warn");
+ assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure wait exceeded ", "warn");
+ assertLoggedWith("core/pregenerator/IrisPregenerator.java", "failed chunk(s); failures are not cached", "warn");
+ assertLoggedWith("core/service/MultiverseSVC.java", "Multiverse world events are not bindable", "warn");
+ }
+
+ /**
+ * A handful of lifecycle lines an operator reads logs/latest.log to find. NOTICE is the level adapters
+ * route to the server's own logger rather than to the console sender.
+ */
+ @Test
+ public void lifecycleLinesAreRaisedAtNoticeLevel() {
+ assertLoggedWith("engine/IrisEngine.java", "Engine init: ", "notice");
+ assertLoggedWith("core/link/MultiverseCoreLink.java", "Adopted %d live Iris world", "notice");
+ assertLoggedWith("core/pregenerator/IrisPregenerator.java", "Pregen finished: ", "notice");
+ }
+
+ private static void assertLoggedWith(String relativePath, String fragment, String expectedMethod) {
+ String source;
+ try {
+ source = read(relativePath);
+ } catch (IOException unreadable) {
+ throw new AssertionError("Cannot read " + relativePath, unreadable);
+ }
+
+ int occurrences = 0;
+ for (int at = source.indexOf(fragment); at >= 0; at = source.indexOf(fragment, at + 1)) {
+ occurrences++;
+ assertEquals(relativePath + " @ \"" + fragment + "\" occurrence " + occurrences,
+ expectedMethod, loggingMethodBefore(source, at, relativePath, fragment));
+ }
+ assertTrue(relativePath + " no longer contains \"" + fragment + "\"", occurrences > 0);
+ }
+
+ private static String loggingMethodBefore(String source, int at, String relativePath, String fragment) {
+ int call = source.lastIndexOf("IrisLogging.", at);
+ assertTrue(relativePath + " @ \"" + fragment + "\" is not logged through IrisLogging", call >= 0);
+ int start = call + "IrisLogging.".length();
+ int end = start;
+ while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) {
+ end++;
+ }
+ return source.substring(start, end);
+ }
+
+ private static String read(String relativePath) throws IOException {
+ return Files.readString(Path.of("src/main/java/art/arcane/iris").resolve(relativePath));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java
index a77a0a0c6..2f4199e5d 100644
--- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java
+++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java
@@ -427,4 +427,21 @@ public class IrisWorldStorageTest {
Path packRoot = Files.createDirectory(irisRoot.resolve("pack"));
assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot));
}
+
+ @Test
+ public void managedWorldStorageIsOnlyReportedForRealIrisWorldDirectories() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("managed-storage-level-root");
+
+ assertFalse(IrisWorldStorage.hasManagedWorldStorage(null));
+ assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
+
+ Path namespace = Files.createDirectories(levelRoot.toPath().resolve("dimensions/iris"));
+ assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
+
+ Files.writeString(namespace.resolve("stray"), "not a world directory");
+ assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
+
+ Files.createDirectory(namespace.resolve("moon"));
+ assertTrue(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
+ }
}
diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java
index 32e88c8c0..92d82fc3d 100644
--- a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java
+++ b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java
@@ -1,5 +1,6 @@
package art.arcane.iris.core;
+import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -11,7 +12,9 @@ import java.util.Map;
import java.util.Set;
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 IrisWorldsTest {
@Rule
@@ -66,6 +69,54 @@ public class IrisWorldsTest {
assertNull(IrisWorlds.generatorLoadKey(null, "overworld"));
}
+ @Test
+ public void orphanedIrisWorldsAreReportedButVanillaSlotsAreNot() throws Exception {
+ MissingWorldStorageLog.reset();
+ Path levelRoot = temporaryFolder.newFolder("orphan-report", "world").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/present"));
+
+ Map configuredWorlds = new LinkedHashMap<>();
+ configuredWorlds.put("world_nether", "underworld");
+ configuredWorlds.put("world_iris_present", "overworld");
+ configuredWorlds.put("world_iris_gone", "overworld");
+
+ assertEquals(
+ Set.of("world_iris_present"),
+ IrisWorlds.filterBukkitWorldsByStorage(levelRoot, configuredWorlds).keySet());
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
+ assertFalse("a vanilla slot that was never created is not an orphan",
+ MissingWorldStorageLog.hasWarned("world_nether"));
+ assertFalse(MissingWorldStorageLog.hasWarned("world_iris_present"));
+ MissingWorldStorageLog.reset();
+ }
+
+ /**
+ * The registry is built from a private constructor behind a static cache, so the isolation contract is
+ * asserted against the source: one unusable world folder used to throw out of {@code clean()}, through
+ * the constructor and into the cache, which returned null and NPE'd every caller.
+ */
+ @Test
+ public void oneUnusableWorldIsExcludedInsteadOfFailingTheWholeRegistry() throws Exception {
+ String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/IrisWorlds.java"));
+
+ int accessor = source.indexOf("public static IrisWorlds get()");
+ int accessorEnd = source.indexOf("public synchronized void put(", accessor);
+ assertTrue("get() must not swallow a failure into a null return",
+ source.substring(accessor, accessorEnd).contains("cache.aquireOnceOrThrow("));
+
+ int clean = source.indexOf("public synchronized void clean()");
+ int cleanEnd = source.indexOf("public synchronized void save()", clean);
+ String cleanBody = source.substring(clean, cleanEnd);
+ assertTrue("clean() must isolate an unusable entry",
+ cleanBody.contains("catch (IllegalStateException e)"));
+ assertTrue("an unusable entry stays in the registry so /iris remove can still find it",
+ cleanBody.contains("warnUnusableStorage(entry.getKey(), e);"));
+
+ int loadDimension = source.indexOf("private IrisDimension loadDimension(");
+ assertTrue("loadDimension must exclude an unusable world rather than propagate",
+ source.substring(loadDimension).contains("catch (IllegalStateException unusableStorage)"));
+ }
+
@Test
public void bukkitWorldFilteringRecognizesCurrentCraftBukkitConfiguredStorage() throws Exception {
Path worldContainer = temporaryFolder.newFolder("configured-server").toPath();
diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java
index 0807b226b..a7ae182ac 100644
--- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java
+++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java
@@ -151,6 +151,186 @@ public class BukkitWorldConfigurationTest {
).isEmpty());
}
+ @Test
+ public void auditSeparatesUsableStorageFromOrphansAndBrokenSnapshots() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-bukkit.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/healthy/iris/pack"));
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/broken/region"));
+ Files.writeString(levelRoot.resolve("dimensions/iris/broken/region/r.0.0.mca"), "terrain");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_healthy.generator", "Iris:overworld");
+ yaml.set("worlds.world_iris_broken.generator", "Iris:overworld");
+ yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ List audited =
+ BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
+
+ assertEquals(3, audited.size());
+ assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_broken"));
+ assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.MISSING, stateOf(audited, "world_iris_gone"));
+ assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.PRESENT, stateOf(audited, "world_iris_healthy"));
+ assertEquals(
+ levelRoot.resolve("dimensions/iris/broken").toAbsolutePath().normalize(),
+ entryOf(audited, "world_iris_broken").storagePath());
+ assertTrue(entryOf(audited, "world_iris_broken").detail().contains("pack"));
+ }
+
+ @Test
+ public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-file-slot.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-file-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris"));
+ Files.writeString(levelRoot.resolve("dimensions/iris/file"), "not a world directory");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_file.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ assertEquals(
+ BukkitWorldConfiguration.IrisWorldStorageState.MISSING,
+ stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
+ "world_iris_file"));
+ }
+
+ /**
+ * Deleting a live world's folder and letting the server save it back leaves a five-file data/ skeleton
+ * with no regions and no iris/ directory. Nothing in it can be overwritten, so it must not hold the
+ * server hostage on the next boot.
+ */
+ @Test
+ public void auditTreatsAServerRewrittenHuskAsAnOrphan() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-husk.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-husk-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/paper"));
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/minecraft"));
+ Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/paper/level_overrides.dat"), "x");
+ Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/minecraft/raids.dat"), "x");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_husk.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ List audited =
+ BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
+
+ assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.EMPTY, stateOf(audited, "world_iris_husk"));
+ assertEquals(
+ levelRoot.resolve("dimensions/iris/husk").toAbsolutePath().normalize(),
+ entryOf(audited, "world_iris_husk").storagePath());
+ }
+
+ @Test
+ public void auditFailsClosedForMantleDataWithoutAPackSnapshot() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-mantle.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-mantle-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/mantled/mantle"));
+ Files.writeString(levelRoot.resolve("dimensions/iris/mantled/mantle/0.0.ttp"), "mantle");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_mantled.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ assertEquals(
+ BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE,
+ stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
+ "world_iris_mantled"));
+ }
+
+ /**
+ * An iris/ directory without iris/pack is an Iris world whose pack snapshot broke, not an empty folder.
+ */
+ @Test
+ public void auditFailsClosedForAnIrisMarkerWithoutAPackSnapshot() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-marker.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-marker-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/marked/iris/engine-data"));
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_marked.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ assertEquals(
+ BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE,
+ stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
+ "world_iris_marked"));
+ }
+
+ /**
+ * IrisWorldStorage refuses to resolve through a symbolic link, so a linked dimension path is storage Iris
+ * cannot use - not storage that is not there. Reporting it as a cold orphan tells the operator to restore
+ * a folder that is already present.
+ */
+ @Test
+ public void auditFailsClosedForASymlinkedDimensionPath() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-symlink.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-symlink-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris"));
+ Path elsewhere = temporaryFolder.newFolder("audit-symlink-target").toPath();
+ Files.createDirectories(elsewhere.resolve("iris/pack"));
+ try {
+ Files.createSymbolicLink(levelRoot.resolve("dimensions/iris/linked"), elsewhere);
+ } catch (IOException | UnsupportedOperationException unsupported) {
+ assumeNoException("symbolic links are not creatable on this filesystem", unsupported);
+ }
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_linked.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ List audited =
+ BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
+
+ assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_linked"));
+ assertTrue(entryOf(audited, "world_iris_linked").detail(),
+ entryOf(audited, "world_iris_linked").detail().contains("symbolic link"));
+ }
+
+ @Test
+ public void auditStillReportsATrulyAbsentDimensionPathAsAnOrphan() throws Exception {
+ File configuration = temporaryFolder.newFile("audit-absent.yml");
+ Path levelRoot = temporaryFolder.newFolder("audit-absent-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris"));
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_absent.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ assertEquals(
+ BukkitWorldConfiguration.IrisWorldStorageState.MISSING,
+ stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot),
+ "world_iris_absent"));
+ }
+
+ @Test
+ public void missingStorageIsReportedOncePerWorld() throws Exception {
+ MissingWorldStorageLog.reset();
+ File configuration = temporaryFolder.newFile("orphan-warning-bukkit.yml");
+ Path levelRoot = temporaryFolder.newFolder("orphan-warning-level-root").toPath();
+ Files.createDirectories(levelRoot.resolve("dimensions/iris"));
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
+ yaml.save(configuration);
+
+ assertFalse(MissingWorldStorageLog.hasWarned("world_iris_gone"));
+ BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot);
+
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
+ MissingWorldStorageLog.reset();
+ }
+
+ private static BukkitWorldConfiguration.IrisWorldStorageState stateOf(
+ List entries,
+ String configuredWorldName
+ ) {
+ return entryOf(entries, configuredWorldName).state();
+ }
+
+ private static BukkitWorldConfiguration.IrisWorldStorageEntry entryOf(
+ List entries,
+ String configuredWorldName
+ ) {
+ return entries.stream()
+ .filter(entry -> entry.configuredWorldName().equals(configuredWorldName))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No audit entry for " + configuredWorldName));
+ }
+
@Test
public void registersAndRemovesWorldAtomically() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java
new file mode 100644
index 000000000..6cc517f79
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java
@@ -0,0 +1,269 @@
+package art.arcane.iris.core.lifecycle;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
+
+/**
+ * Deleting a loaded Iris world's folder and letting the server save the level back leaves a directory the
+ * server still enumerates but that owns nothing: no pack snapshot, no chunk data. Left in the dimensions
+ * tree it stops startup either way - Iris refuses to boot over it, or excluding it trips Paper's interactive
+ * world-migration gate - so the bootstrap has to move it out before any level is created.
+ */
+public class HuskWorldQuarantineTest {
+ private static final Instant STAMP = Instant.parse("2026-08-20T04:05:06Z");
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void aServerSkeletonWithNoPackAndNoChunkDataIsMovedOutOfTheDimensionsTree() throws IOException {
+ Path levelRoot = level("husk");
+ Path husk = dimensionRoot(levelRoot, "irisworld");
+ write(husk.resolve("data/paper/level_overrides.dat"));
+ write(husk.resolve("data/minecraft/raids.dat"));
+
+ List warnings = new ArrayList<>();
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP);
+
+ assertEquals(1, moved.size());
+ assertFalse("the server must not enumerate the husk", Files.exists(husk, LinkOption.NOFOLLOW_LINKS));
+ Path destination = levelRoot.resolve("iris/husks/irisworld-20260820T040506");
+ assertEquals(destination, moved.getFirst().destination());
+ assertTrue(Files.isRegularFile(destination.resolve("data/paper/level_overrides.dat")));
+ assertEquals(1, warnings.size());
+ String warning = warnings.getFirst();
+ assertTrue(warning, warning.contains(husk.toString()));
+ assertTrue(warning, warning.contains(destination.toString()));
+ assertTrue(warning, warning.contains("/iris remove world=world_iris_irisworld delete=true"));
+ }
+
+ @Test
+ public void aWorldWithRegionDataIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("region");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("region/r.0.0.mca"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void aWorldWithEntitiesOrPoiDataIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("entities");
+ Path entities = dimensionRoot(levelRoot, "onlyentities");
+ write(entities.resolve("entities/r.0.0.mca"));
+ Path poi = dimensionRoot(levelRoot, "onlypoi");
+ write(poi.resolve("poi/r.0.0.mca"));
+
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertTrue(moved.isEmpty());
+ assertTrue(Files.isDirectory(entities));
+ assertTrue(Files.isDirectory(poi));
+ }
+
+ @Test
+ public void aWorldWithMantleDataIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("mantle");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("mantle/pv.0.ttp.lz4b"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void anIrisMarkerDirectoryIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("marker");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ Files.createDirectories(world.resolve("iris/engine-data"));
+ write(world.resolve("data/paper/level_overrides.dat"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void aFrozenPackSnapshotIsLeftAlone() throws IOException {
+ Path levelRoot = level("pack");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ Files.createDirectories(world.resolve("iris/pack/dimensions"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ /**
+ * macOS recreates a directory to hold a .DS_Store while a delete is still in flight, so a husk whose
+ * iris/ folder holds nothing but that file is exactly as worthless as one with no iris/ folder at all.
+ */
+ @Test
+ public void anIrisFolderHoldingOnlyOsMetadataIsTreatedAsAbsent() throws IOException {
+ Path levelRoot = level("dsstore");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("iris/.DS_Store"));
+ write(world.resolve("data/paper/level_overrides.dat"));
+
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertEquals(1, moved.size());
+ assertFalse("the server must not enumerate the husk", Files.exists(world, LinkOption.NOFOLLOW_LINKS));
+ }
+
+ @Test
+ public void everyOsMetadataFileCountsAsAbsent() throws IOException {
+ Path levelRoot = level("osmetadata");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("iris/.DS_Store"));
+ write(world.resolve("iris/Thumbs.db"));
+ write(world.resolve("iris/desktop.ini"));
+ write(world.resolve("iris/._pack"));
+
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertEquals(1, moved.size());
+ }
+
+ @Test
+ public void anIrisFolderWithRealContentBesideOsMetadataIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("dsstore-and-pack");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("iris/.DS_Store"));
+ Files.createDirectories(world.resolve("iris/pack/dimensions"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void anIrisFolderHoldingOnlyAnEmptySubdirectoryIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("dsstore-and-dir");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("iris/.DS_Store"));
+ Files.createDirectories(world.resolve("iris/engine-data"));
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void aSymlinkedIrisFolderIsLeftForTheFailClosedGuard() throws IOException {
+ Path levelRoot = level("iris-symlink");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ Path elsewhere = temporaryFolder.newFolder("iris-symlink-target").toPath();
+ try {
+ Files.createSymbolicLink(world.resolve("iris"), elsewhere);
+ } catch (IOException | UnsupportedOperationException unsupported) {
+ assumeTrue("symbolic links are not creatable on this filesystem", false);
+ }
+
+ assertQuarantinesNothing(levelRoot, world);
+ }
+
+ @Test
+ public void aSymlinkedDimensionRootIsNeverMoved() throws IOException {
+ Path levelRoot = level("symlink");
+ Path namespace = levelRoot.resolve("dimensions/iris");
+ Files.createDirectories(namespace);
+ Path elsewhere = temporaryFolder.newFolder("symlink-target").toPath();
+ write(elsewhere.resolve("data/paper/level_overrides.dat"));
+ Path link = namespace.resolve("irisworld");
+ try {
+ Files.createSymbolicLink(link, elsewhere);
+ } catch (IOException | UnsupportedOperationException unsupported) {
+ assumeTrue("symbolic links are not creatable on this filesystem", false);
+ }
+
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertTrue(moved.isEmpty());
+ assertTrue(Files.isSymbolicLink(link));
+ assertTrue(Files.isRegularFile(elsewhere.resolve("data/paper/level_overrides.dat")));
+ }
+
+ @Test
+ public void anUnreadableDimensionRootIsNeverMoved() throws IOException {
+ Path levelRoot = level("unreadable");
+ Path world = dimensionRoot(levelRoot, "irisworld");
+ write(world.resolve("data/paper/level_overrides.dat"));
+ assumeTrue("posix permissions are required to make a directory unreadable",
+ Files.getFileStore(world).supportsFileAttributeView("posix"));
+ Files.setPosixFilePermissions(world, Set.of());
+ try {
+ assumeTrue("running as root defeats the permission bits", !Files.isReadable(world));
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertTrue(moved.isEmpty());
+ } finally {
+ Files.setPosixFilePermissions(world, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------"));
+ }
+ }
+
+ @Test
+ public void aSecondHuskInTheSameSecondGetsItsOwnDestination() throws IOException {
+ Path levelRoot = level("collision");
+ Path first = dimensionRoot(levelRoot, "irisworld");
+ write(first.resolve("data/paper/level_overrides.dat"));
+ Files.createDirectories(levelRoot.resolve("iris/husks/irisworld-20260820T040506"));
+
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP);
+
+ assertEquals(1, moved.size());
+ assertEquals(levelRoot.resolve("iris/husks/irisworld-20260820T040506-2"), moved.getFirst().destination());
+ }
+
+ @Test
+ public void aLevelWithNoIrisNamespaceIsAnEmptySweep() throws IOException {
+ Path levelRoot = level("none");
+
+ assertTrue(HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> {
+ }, STAMP).isEmpty());
+ }
+
+ private void assertQuarantinesNothing(Path levelRoot, Path world) {
+ List warnings = new ArrayList<>();
+ List moved =
+ HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP);
+
+ assertTrue(moved.isEmpty());
+ assertTrue(warnings.isEmpty());
+ assertTrue(Files.isDirectory(world, LinkOption.NOFOLLOW_LINKS));
+ }
+
+ private Path level(String scope) throws IOException {
+ return temporaryFolder.newFolder(scope, "world").toPath();
+ }
+
+ private static Path dimensionRoot(Path levelRoot, String key) throws IOException {
+ Path root = levelRoot.resolve("dimensions/iris").resolve(key);
+ Files.createDirectories(root);
+ return root;
+ }
+
+ private static void write(Path file) throws IOException {
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, "x");
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java
new file mode 100644
index 000000000..8c6c4d1e5
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java
@@ -0,0 +1,123 @@
+package art.arcane.iris.core.lifecycle;
+
+import art.arcane.iris.spi.IrisPlatform;
+import art.arcane.iris.spi.IrisPlatforms;
+import art.arcane.iris.spi.LogLevel;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the
+ * console and the runtime log but never the instance's logs/latest.log - the only log most operators read.
+ */
+public class MissingWorldStorageLogTest {
+ private final List emitted = new ArrayList<>();
+ private final List levels = new ArrayList<>();
+ private IrisPlatform previousPlatform;
+
+ @Before
+ public void captureLog() {
+ previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
+ IrisPlatforms.unbind();
+ MissingWorldStorageLog.reset();
+ emitted.clear();
+ levels.clear();
+ }
+
+ @After
+ public void restorePlatform() {
+ IrisPlatforms.unbind();
+ if (previousPlatform != null) {
+ IrisPlatforms.bind(previousPlatform);
+ }
+ MissingWorldStorageLog.reset();
+ }
+
+ @Test
+ public void bootstrapWarningsAreReplayedOnceThePlatformLogIsUp() {
+ MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone"));
+
+ bindCapturingPlatform();
+ MissingWorldStorageLog.replayToPlatformLog();
+
+ assertEquals(2, emitted.size());
+ assertTrue(emitted.get(0), emitted.get(0).contains("world_iris_gone"));
+ assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/gone"));
+ }
+
+ @Test
+ public void replayIsIdempotentAndNeverRepeatsAWarningThePlatformAlreadySaw() {
+ MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
+ bindCapturingPlatform();
+ MissingWorldStorageLog.replayToPlatformLog();
+ emitted.clear();
+
+ MissingWorldStorageLog.replayToPlatformLog();
+ MissingWorldStorageLog.warnOnce("world_iris_later", Path.of("/srv/world/dimensions/iris/later"));
+ int afterLiveWarning = emitted.size();
+ MissingWorldStorageLog.replayToPlatformLog();
+
+ assertEquals(afterLiveWarning, emitted.size());
+ }
+
+ @Test
+ public void anEmptyWorldFolderIsReportedWithItsOwnRemedy() {
+ bindCapturingPlatform();
+
+ MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk"));
+
+ assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk"));
+ assertEquals(2, emitted.size());
+ assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/husk"));
+ assertTrue(emitted.get(1), emitted.get(1).contains("world_iris_husk"));
+ assertFalse("an empty folder is deleted, not restored", emitted.get(1).contains("Restore"));
+ }
+
+ /**
+ * An orphan report is what an operator scans logs/latest.log at WARN level for; emitting it at INFO makes
+ * it invisible to exactly the search that would find it.
+ */
+ @Test
+ public void everyOrphanReportIsRaisedAtWarnLevel() {
+ bindCapturingPlatform();
+
+ MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
+ MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk"));
+
+ assertEquals(4, levels.size());
+ assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN));
+ }
+
+ @Test
+ public void replayedOrphanReportsKeepTheirWarnLevel() {
+ MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone"));
+ bindCapturingPlatform();
+
+ MissingWorldStorageLog.replayToPlatformLog();
+
+ assertEquals(2, levels.size());
+ assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN));
+ }
+
+ private void bindCapturingPlatform() {
+ IrisPlatform platform = mock(IrisPlatform.class);
+ doAnswer(invocation -> {
+ levels.add(invocation.getArgument(0, LogLevel.class));
+ emitted.add(invocation.getArgument(1, String.class));
+ return null;
+ }).when(platform).log(org.mockito.ArgumentMatchers.any(LogLevel.class), org.mockito.ArgumentMatchers.anyString());
+ IrisPlatforms.bind(platform);
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java
index eb8eb45eb..91fe49562 100644
--- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java
+++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java
@@ -116,4 +116,34 @@ public class WorldRemovalPathPolicyTest {
);
assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, targetFailure.reason());
}
+
+ /**
+ * "iris worlds", Multiverse and the storage folder each print a different name for the same world, so
+ * all three forms have to select it. Storage presence must not change which world is selected either;
+ * an orphan is exactly the world an admin needs to remove.
+ */
+ @Test
+ public void everyPrintedFormOfAWorldNameSelectsTheSameTarget() throws Exception {
+ Path levelRoot = temporaryFolder.newFolder("name-forms", "world").toPath();
+
+ for (boolean storagePresent : new boolean[]{false, true}) {
+ if (storagePresent) {
+ Files.createDirectories(levelRoot.resolve("dimensions/iris/orphan1"));
+ }
+ WorldRemovalPathPolicy.Target bare =
+ WorldRemovalPathPolicy.resolve("orphan1", "world", levelRoot);
+ WorldRemovalPathPolicy.Target startup =
+ WorldRemovalPathPolicy.resolve("world_iris_orphan1", "world", levelRoot);
+ WorldRemovalPathPolicy.Target namespaced =
+ WorldRemovalPathPolicy.resolve("iris:orphan1", "world", levelRoot);
+
+ assertEquals("iris:orphan1", bare.worldKey().toString());
+ assertEquals(bare.worldKey(), startup.worldKey());
+ assertEquals(bare.worldKey(), namespaced.worldKey());
+ assertEquals(bare.worldDirectory(), startup.worldDirectory());
+ assertEquals(bare.worldDirectory(), namespaced.worldDirectory());
+ assertEquals(bare.storageDirectory(), startup.storageDirectory());
+ assertEquals(bare.storageDirectory(), namespaced.storageDirectory());
+ }
+ }
}
diff --git a/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java b/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java
index 499630fac..d0dedbe62 100644
--- a/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java
+++ b/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java
@@ -1,19 +1,46 @@
package art.arcane.iris.core.link;
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
import org.bukkit.World;
+import org.bukkit.plugin.PluginManager;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.MockedStatic;
+import org.mvplugins.multiverse.core.utils.result.Attempt;
+import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
+import org.mvplugins.multiverse.core.world.MultiverseWorld;
+import org.mvplugins.multiverse.core.world.WorldManager;
+import org.mvplugins.multiverse.core.world.options.LoadWorldOptions;
+import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
+import org.mvplugins.multiverse.external.vavr.control.Option;
+import java.io.File;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
+import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+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 MultiverseCoreLinkTest {
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
@Test
public void multiverseRegistrationRequiresTheConfiguredWorldName() throws Exception {
Method updateWorld = MultiverseCoreLink.class
@@ -37,17 +64,179 @@ public class MultiverseCoreLinkTest {
}
@Test
- public void lookupNamesAddTheIrisKeyForConfiguredStartupNames() {
+ public void lookupNamesAddTheIrisKeyAndRuntimeNameForConfiguredStartupNames() {
assertEquals(
- List.of("world_iris_mvtest", "iris:mvtest"),
+ List.of("world_iris_mvtest", "iris:mvtest", "iris_mvtest"),
MultiverseCoreLink.lookupNames("world_iris_mvtest", "world")
);
assertEquals(
- List.of("survival_iris_mvtest", "iris:mvtest"),
+ List.of("survival_iris_mvtest", "iris:mvtest", "iris_mvtest"),
MultiverseCoreLink.lookupNames("survival_iris_mvtest", "survival")
);
}
+ /**
+ * Multiverse re-creates a world under whatever name its own WorldCreator produced, so an entry it
+ * reloaded can be indexed under the keyed runtime name instead of the startup name Iris registered.
+ */
+ @Test
+ public void resolveFindsAWorldMultiverseOnlyHoldsUnderItsRuntimeName() {
+ MultiverseWorld target = multiverseWorld("iris_mvtest", new NamespacedKey("iris", "mvtest"));
+
+ assertSame(target, new MultiverseCoreLink()
+ .resolve(worldManagerHolding(Map.of("iris_mvtest", target)), "world_iris_mvtest", "world"));
+ }
+
+ @Test
+ public void resolveNeverReturnsADifferentWorldSittingOnTheRuntimeName() {
+ MultiverseWorld impostor = multiverseWorld("iris_mvtest", NamespacedKey.minecraft("iris_mvtest"));
+
+ assertNull(new MultiverseCoreLink()
+ .resolve(worldManagerHolding(Map.of("iris_mvtest", impostor)), "world_iris_mvtest", "world"));
+ }
+
+ @Test
+ public void resolveStillMatchesALegacyNameKeyedEntryUnderTheConfiguredName() {
+ MultiverseWorld legacy = multiverseWorld("world_iris_mvtest", NamespacedKey.minecraft("world_iris_mvtest"));
+
+ assertSame(legacy, new MultiverseCoreLink()
+ .resolve(worldManagerHolding(Map.of("world_iris_mvtest", legacy)), "world_iris_mvtest", "world"));
+ }
+
+ /**
+ * Iris unloads the world and closes its generator before it unregisters it, so Multiverse must not run
+ * its own unload-before-remove: that dereferences a Bukkit world which is already gone and fails the
+ * whole removal.
+ */
+ @Test
+ public void removalSkipsTheMultiverseUnloadWhenTheBukkitWorldIsAlreadyGone() {
+ MultiverseWorld unloaded = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
+ when(unloaded.asLoadedWorld()).thenReturn(Option.none());
+
+ RemoveWorldOptions options = MultiverseCoreLink.removalOptions(unloaded);
+
+ assertFalse(options.unloadBukkitWorld());
+ assertFalse(options.saveBukkitWorld());
+ }
+
+ @Test
+ public void removalStillLetsMultiverseUnloadAWorldThatIsStillLive() {
+ MultiverseWorld live = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
+ LoadedMultiverseWorld loaded = mock(LoadedMultiverseWorld.class);
+ when(loaded.getBukkitWorld()).thenReturn(Option.of(mock(World.class)));
+ when(live.asLoadedWorld()).thenReturn(Option.of(loaded));
+
+ assertTrue(MultiverseCoreLink.removalOptions(live).unloadBukkitWorld());
+ }
+
+ /**
+ * Multiverse's loadWorld short-circuits to the already-loaded Bukkit world only when Bukkit has one
+ * under the name Multiverse recorded. Without that world it builds one, so adoption is skipped rather
+ * than letting Multiverse re-create a world Iris already owns.
+ */
+ @Test
+ public void adoptionIsSkippedWhenBukkitHasNoWorldUnderTheRecordedName() {
+ NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
+ MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
+ WorldManager manager = mock(WorldManager.class);
+ when(manager.isLoadedWorld(target)).thenReturn(false);
+
+ withBukkitWorld(null, () -> assertFalse(
+ new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
+ verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
+ }
+
+ @Test
+ public void adoptionIsSkippedWhenTheLiveWorldUnderThatNameIsNotTheTarget() {
+ NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
+ MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
+ WorldManager manager = mock(WorldManager.class);
+ when(manager.isLoadedWorld(target)).thenReturn(false);
+ World other = mock(World.class);
+ when(other.getKey()).thenReturn(NamespacedKey.minecraft("overworld"));
+
+ withBukkitWorld(other, () -> assertFalse(
+ new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
+ verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
+ }
+
+ @Test
+ public void adoptionIsSkippedWhenMultiverseAlreadyHoldsTheWorldAsLoaded() {
+ NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
+ MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
+ WorldManager manager = mock(WorldManager.class);
+ when(manager.isLoadedWorld(target)).thenReturn(true);
+
+ assertFalse(new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey));
+ verify(manager, never()).loadWorld(any(LoadWorldOptions.class));
+ }
+
+ @Test
+ public void adoptionBindsALiveIrisWorldIntoTheMultiverseLoadedRegistry() {
+ NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
+ MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey);
+ WorldManager manager = mock(WorldManager.class);
+ when(manager.isLoadedWorld(target)).thenReturn(false);
+ when(manager.loadWorld(any(LoadWorldOptions.class)))
+ .thenReturn(Attempt.success(mock(LoadedMultiverseWorld.class)));
+ World live = mock(World.class);
+ when(live.getKey()).thenReturn(worldKey);
+
+ withBukkitWorld(live, () -> assertTrue(
+ new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)));
+ }
+
+ /**
+ * Multiverse refuses to adopt a world whose stored environment disagrees with the live one, so the live
+ * world is the value the entry is corrected to whenever there is one.
+ */
+ @Test
+ public void theStoredEnvironmentIsTakenFromTheLiveWorld() {
+ NamespacedKey worldKey = new NamespacedKey("iris", "mvtest");
+ World live = mock(World.class);
+ when(live.getKey()).thenReturn(worldKey);
+ when(live.getEnvironment()).thenReturn(World.Environment.NETHER);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getWorlds).thenReturn(List.of(live));
+
+ assertEquals(World.Environment.NETHER,
+ new MultiverseCoreLink().ownedWorldEnvironment(worldKey, "overworld"));
+ }
+ }
+
+ @Test
+ public void anEnvironmentThatMatchesWhatMultiverseAlreadyStoredIsNotRewritten() {
+ MultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest"));
+ when(multiverseWorld.getEnvironment()).thenReturn(World.Environment.NORMAL);
+ MultiverseCoreLink link = new MultiverseCoreLink();
+
+ assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, World.Environment.NORMAL));
+ assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, null));
+ }
+
+ private static void withBukkitWorld(World world, Runnable body) {
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world);
+ body.run();
+ }
+ }
+
+ private static MultiverseWorld multiverseWorld(String name, NamespacedKey key) {
+ MultiverseWorld multiverseWorld = mock(MultiverseWorld.class);
+ when(multiverseWorld.getName()).thenReturn(name);
+ when(multiverseWorld.getKey()).thenReturn(key);
+ when(multiverseWorld.asLoadedWorld()).thenReturn(Option.none());
+ return multiverseWorld;
+ }
+
+ private static WorldManager worldManagerHolding(Map registry) {
+ WorldManager manager = mock(WorldManager.class);
+ when(manager.getWorld(anyString()))
+ .thenAnswer(invocation -> Option.of(registry.get(invocation.getArgument(0))));
+ return manager;
+ }
+
@Test
public void lookupNamesKeepNonConfiguredNamesUntouched() {
assertEquals(List.of("iris_mvtest"), MultiverseCoreLink.lookupNames("iris_mvtest", "world"));
@@ -55,4 +244,73 @@ public class MultiverseCoreLinkTest {
assertEquals(List.of("iris:mvtest"), MultiverseCoreLink.lookupNames("iris:mvtest", "world"));
assertEquals(List.of("iris_studio-demo"), MultiverseCoreLink.lookupNames("iris_studio-demo", "world"));
}
+
+ @Test
+ public void everyLinkCallIsANoOpWithoutMultiverse() {
+ withMultiverse(false, link -> {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn("world_iris_mvtest");
+
+ assertFalse(link.isActive());
+ assertFalse(link.removeIfPresent(world));
+ assertFalse(link.removeFromConfig("world_iris_mvtest"));
+ assertEquals(0, link.reconcileOwnedWorlds());
+ link.updateWorld(world, "world_iris_mvtest", "overworld");
+ link.prepareOwnedWorldLoad("world_iris_mvtest");
+ assertFalse(link.adoptOwnedWorld("world_iris_mvtest"));
+ });
+ }
+
+ @Test
+ public void anEnabledButUnloadedMultiverseDegradesInsteadOfThrowing() {
+ // Iris is asked for generators while Multiverse is still enabling, so plugin enablement alone
+ // must never be treated as a usable API.
+ withMultiverse(true, link -> {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn("world_iris_mvtest");
+
+ assertFalse("MultiverseCoreApi has no instance until Multiverse finishes enabling",
+ link.isActive());
+ assertFalse(link.removeIfPresent(world));
+ assertFalse(link.removeFromConfig("world_iris_mvtest"));
+ assertEquals(0, link.reconcileOwnedWorlds());
+ link.updateWorld(world, "world_iris_mvtest", "overworld");
+ link.prepareOwnedWorldLoad("world_iris_mvtest");
+ assertFalse(link.adoptOwnedWorld("world_iris_mvtest"));
+ });
+ }
+
+ @Test
+ public void aBlankWorldNameIsNotAnErrorWhileMultiverseIsAbsent() {
+ withMultiverse(false, link -> {
+ assertFalse(link.removeFromConfig(" "));
+ assertFalse(link.removeFromConfig(null));
+ link.prepareOwnedWorldLoad(null);
+ assertFalse(link.adoptOwnedWorld(" "));
+ });
+ }
+
+ @Test
+ public void irisOwnershipOfAWorldNameFollowsItsStorage() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("ownership", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+
+ assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_mvtest", levelRoot));
+ assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", levelRoot));
+ assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("iris:mvtest", levelRoot));
+ assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_other", levelRoot));
+ assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world", levelRoot));
+ assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("../escape", levelRoot));
+ assertFalse(MultiverseCoreLink.isIrisOwnedWorldName(null, levelRoot));
+ assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", null));
+ }
+
+ private static void withMultiverse(boolean enabled, java.util.function.Consumer body) {
+ PluginManager pluginManager = mock(PluginManager.class);
+ when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(enabled);
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ body.accept(new MultiverseCoreLink());
+ }
+ }
}
diff --git a/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java b/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java
new file mode 100644
index 000000000..f6a235721
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java
@@ -0,0 +1,406 @@
+package art.arcane.iris.core.link;
+
+import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
+import art.arcane.iris.engine.platform.BukkitChunkGenerator;
+import art.arcane.iris.spi.IrisServices;
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.World;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.event.server.ServerCommandEvent;
+import org.bukkit.plugin.PluginManager;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.MockedStatic;
+import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent;
+import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
+import org.mvplugins.multiverse.external.vavr.control.Option;
+
+import java.io.File;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+public class MultiverseGuardListenerTest {
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void multiverseCommandRootsAreRecognisedThroughEveryDispatchForm() {
+ assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/mv clone a b"));
+ assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("mv clone a b"));
+ assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/multiverse-core:mv delete a"));
+ assertEquals("mvclone", MultiverseGuardListener.multiverseCommandRoot("/MVClone a b"));
+ assertEquals("mvregen", MultiverseGuardListener.multiverseCommandRoot("/mvregen a"));
+ assertNull(MultiverseGuardListener.multiverseCommandRoot("/iris remove world=a"));
+ assertNull(MultiverseGuardListener.multiverseCommandRoot("/tp somebody"));
+ assertNull(MultiverseGuardListener.multiverseCommandRoot(" "));
+ assertNull(MultiverseGuardListener.multiverseCommandRoot(null));
+ }
+
+ @Test
+ public void cloneSourceAndDestinationAreReadFromEveryCloneAlias() {
+ assertClone("mvtest", "copy", "/mv clone mvtest copy");
+ assertClone("mvtest", "copy", "mv clone mvtest copy --reset-gamerules");
+ assertClone("mvtest", "copy", "/mvcl mvtest copy");
+ assertClone("mvtest", "copy", "/mvclone mvtest copy");
+ assertClone("mvtest", "copy", "/mv clone mvtest copy");
+ assertClone("mvtest", null, "/mvcl mvtest");
+ assertClone("mvtest", null, "/mvcl mvtest --reset-gamerules");
+ }
+
+ @Test
+ public void loadTargetsAreReadFromEveryLoadAlias() {
+ assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest"));
+ assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("mv LOAD world_iris_mvtest"));
+ assertEquals("world_iris_mvtest",
+ MultiverseGuardListener.multiverseLoad("/mvload world_iris_mvtest --skip-folder-check"));
+ assertEquals("world_iris_mvtest",
+ MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest"));
+ assertNull(MultiverseGuardListener.multiverseLoad("/mv load"));
+ assertNull(MultiverseGuardListener.multiverseLoad("/mvload"));
+ assertNull(MultiverseGuardListener.multiverseLoad("/mv unload world_iris_mvtest"));
+ assertNull(MultiverseGuardListener.multiverseLoad("/mv clone a b"));
+ assertNull(MultiverseGuardListener.multiverseLoad("/iris load world_iris_mvtest"));
+ }
+
+ /**
+ * Multiverse rebuilds an unloaded world from a WorldCreator carrying the environment it stored, and an
+ * Iris world reports CUSTOM after its first restart, which CraftServer refuses outright. Iris owns the
+ * load, so the command is taken over rather than left to fail.
+ */
+ @Test
+ public void loadingAnUnloadedIrisWorldIsDelegatedToIris() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-delegate", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ RecordingLoader loader = new RecordingLoader(true, "Loaded world_iris_mvtest.");
+ IrisServices.register(ManagedWorldLoader.class, loader);
+ try {
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
+ listener.onServerCommand(event);
+ assertTrue("Multiverse must never reach CraftServer.createWorld for an Iris world",
+ event.isCancelled());
+ });
+ assertEquals("world_iris_mvtest", loader.requested);
+ } finally {
+ IrisServices.remove(ManagedWorldLoader.class);
+ }
+ }
+
+ /**
+ * Multiverse takes the bare key or iris: just as happily as the startup name; Iris has to load the
+ * one name the world is registered under either way.
+ */
+ @Test
+ public void anAliasFormOfAnIrisWorldIsLoadedUnderItsStartupName() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-alias", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ RecordingLoader loader = new RecordingLoader(true, "Loaded.");
+ IrisServices.register(ManagedWorldLoader.class, loader);
+ try {
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv load iris:mvtest");
+ listener.onServerCommand(event);
+ assertTrue(event.isCancelled());
+ });
+ assertEquals("world_iris_mvtest", loader.requested);
+ } finally {
+ IrisServices.remove(ManagedWorldLoader.class);
+ }
+ }
+
+ @Test
+ public void loadingAnIrisWorldThatIsAlreadyLiveIsLeftToMultiverse() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-live", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ RecordingLoader loader = new RecordingLoader(true, "Loaded.");
+ IrisServices.register(ManagedWorldLoader.class, loader);
+ try {
+ withServer(levelRoot, irisWorld("world_iris_mvtest"), listener -> {
+ ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
+ listener.onServerCommand(event);
+ assertFalse("Multiverse adopts a live world without a WorldCreator", event.isCancelled());
+ });
+ assertNull(loader.requested);
+ } finally {
+ IrisServices.remove(ManagedWorldLoader.class);
+ }
+ }
+
+ @Test
+ public void loadingAWorldIrisDoesNotOwnIsLeftAlone() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-vanilla", "world");
+ RecordingLoader loader = new RecordingLoader(true, "Loaded.");
+ IrisServices.register(ManagedWorldLoader.class, loader);
+ try {
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv load plots");
+ listener.onServerCommand(event);
+ assertFalse(event.isCancelled());
+ });
+ assertNull(loader.requested);
+ } finally {
+ IrisServices.remove(ManagedWorldLoader.class);
+ }
+ }
+
+ /**
+ * Without a loader the command is still refused: letting Multiverse through would either fail on the
+ * CUSTOM environment or build a vanilla world beside the level root under the Iris world's name.
+ */
+ @Test
+ public void loadingAnIrisWorldWithNoLoaderRegisteredIsRefusedRatherThanPassedThrough() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-no-loader", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ IrisServices.remove(ManagedWorldLoader.class);
+
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest");
+ listener.onServerCommand(event);
+ assertTrue(event.isCancelled());
+ });
+ }
+
+ /**
+ * Taking the command over must never do for a sender what Multiverse would have refused them.
+ */
+ @Test
+ public void loadingAnIrisWorldWithoutTheMultiversePermissionIsLeftToMultiverse() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-unprivileged", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ RecordingLoader loader = new RecordingLoader(true, "Loaded.");
+ IrisServices.register(ManagedWorldLoader.class, loader);
+ try {
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = unprivilegedCommand("mv load world_iris_mvtest");
+ listener.onServerCommand(event);
+ assertFalse(event.isCancelled());
+ });
+ assertNull(loader.requested);
+ } finally {
+ IrisServices.remove(ManagedWorldLoader.class);
+ }
+ }
+
+ /**
+ * The tail of a delegated load runs on the main thread and hands the world to Multiverse. Without
+ * Multiverse it must still settle, in both directions, rather than throw into the scheduler.
+ */
+ @Test
+ public void aSettledLoadHandsTheWorldToMultiverseAndNeverThrows() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("load-settle", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+
+ withServer(levelRoot, null, listener -> {
+ listener.settleLoad(null, "world_iris_mvtest",
+ new ManagedWorldLoader.ManagedWorldLoad(true, "Loaded."), null);
+ listener.settleLoad(null, "world_iris_mvtest",
+ new ManagedWorldLoader.ManagedWorldLoad(false, "Storage is missing."), null);
+ listener.settleLoad(null, "world_iris_mvtest", null, new IllegalStateException("boom"));
+ listener.settleLoad(null, "world_iris_mvtest", null, null);
+ });
+ }
+
+ private static final class RecordingLoader implements ManagedWorldLoader {
+ private final boolean loaded;
+ private final String message;
+ private volatile String requested;
+
+ private RecordingLoader(boolean loaded, String message) {
+ this.loaded = loaded;
+ this.message = message;
+ }
+
+ @Override
+ public CompletableFuture load(String configuredWorldName) {
+ requested = configuredWorldName;
+ return CompletableFuture.completedFuture(new ManagedWorldLoad(loaded, message));
+ }
+ }
+
+ @Test
+ public void nonCloneMultiverseCommandsCarryNoCloneSource() {
+ assertNull(MultiverseGuardListener.multiverseClone("/mv delete mvtest"));
+ assertNull(MultiverseGuardListener.multiverseClone("/mv regen mvtest"));
+ assertNull(MultiverseGuardListener.multiverseClone("/mv clone"));
+ assertNull(MultiverseGuardListener.multiverseClone("/mv"));
+ assertNull(MultiverseGuardListener.multiverseClone("/mvcl"));
+ assertNull(MultiverseGuardListener.multiverseClone("/iris create mvtest"));
+ }
+
+ @Test
+ public void cloningOntoAnIrisWorldSlotIsRefused() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("clone-destination", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv clone plots world_iris_mvtest");
+ listener.onServerCommand(event);
+ assertTrue("Multiverse resolves the vanilla path and never sees the Iris slot",
+ event.isCancelled());
+ });
+ }
+
+ @Test
+ public void cloningOntoAFreeNameIsLeftAlone() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("clone-destination-free", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv clone plots plots_copy");
+ listener.onServerCommand(event);
+ assertFalse(event.isCancelled());
+ });
+ }
+
+ private static void assertClone(String source, String destination, String commandLine) {
+ MultiverseGuardListener.CloneCommand clone = MultiverseGuardListener.multiverseClone(commandLine);
+ assertEquals(commandLine, new MultiverseGuardListener.CloneCommand(source, destination), clone);
+ }
+
+ @Test
+ public void multiverseDeleteOfALoadedIrisWorldIsCancelled() {
+ World world = irisWorld("world_iris_mvtest");
+ LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", world);
+ MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
+
+ withServer(emptyLevelRoot("delete-iris"), listener -> listener.onMultiverseWorldDelete(event));
+
+ assertTrue("mv delete and mv regen both funnel through this event", event.isCancelled());
+ }
+
+ @Test
+ public void multiverseDeleteOfAWorldIrisDoesNotOwnIsLeftAlone() {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn("plots");
+ LoadedMultiverseWorld multiverseWorld = multiverseWorld("plots", world);
+ MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
+
+ withServer(emptyLevelRoot("delete-vanilla"), listener -> listener.onMultiverseWorldDelete(event));
+
+ assertFalse(event.isCancelled());
+ }
+
+ @Test
+ public void multiverseDeleteOfAnUnloadedIrisWorldIsCancelledFromStorage() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("delete-unloaded", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+ LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", null);
+ MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld);
+
+ withServer(levelRoot, listener -> listener.onMultiverseWorldDelete(event));
+
+ assertTrue(event.isCancelled());
+ }
+
+ @Test
+ public void cloningALoadedIrisWorldIsRefusedAtTheCommand() {
+ World world = irisWorld("world_iris_mvtest");
+
+ withServer(emptyLevelRoot("clone-iris"), world, listener -> {
+ ServerCommandEvent event = consoleCommand("mv clone world_iris_mvtest copy");
+ listener.onServerCommand(event);
+ assertTrue("Multiverse fires no cancellable event for a clone", event.isCancelled());
+ });
+ }
+
+ @Test
+ public void cloningAnUnloadedIrisWorldIsRefusedFromStorage() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("clone-unloaded", "world");
+ assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs());
+
+ withServer(levelRoot, null, listener -> {
+ ServerCommandEvent event = consoleCommand("mvclone world_iris_mvtest copy");
+ listener.onServerCommand(event);
+ assertTrue(event.isCancelled());
+ });
+ }
+
+ @Test
+ public void cloningAWorldIrisDoesNotOwnIsLeftAlone() {
+ withServer(emptyLevelRoot("clone-vanilla"), null, listener -> {
+ ServerCommandEvent event = consoleCommand("mv clone plots copy");
+ listener.onServerCommand(event);
+ assertFalse(event.isCancelled());
+ });
+ }
+
+ @Test
+ public void unrelatedCommandsAreNeverInspected() {
+ withServer(emptyLevelRoot("clone-unrelated"), null, listener -> {
+ ServerCommandEvent event = consoleCommand("iris remove world=world_iris_mvtest delete=true");
+ listener.onServerCommand(event);
+ assertFalse(event.isCancelled());
+ });
+ }
+
+ private static ServerCommandEvent consoleCommand(String command) {
+ ConsoleCommandSender sender = mock(ConsoleCommandSender.class);
+ when(sender.hasPermission(org.mockito.ArgumentMatchers.anyString())).thenReturn(true);
+ return new ServerCommandEvent(sender, command);
+ }
+
+ private static ServerCommandEvent unprivilegedCommand(String command) {
+ return new ServerCommandEvent(mock(ConsoleCommandSender.class), command);
+ }
+
+ private File emptyLevelRoot(String scope) {
+ try {
+ return temporaryFolder.newFolder(scope, "world");
+ } catch (Exception failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
+ private static World irisWorld(String name) {
+ World world = mock(World.class);
+ when(world.getName()).thenReturn(name);
+ when(world.getGenerator()).thenReturn(mock(BukkitChunkGenerator.class));
+ return world;
+ }
+
+ private static LoadedMultiverseWorld multiverseWorld(String name, World bukkitWorld) {
+ LoadedMultiverseWorld multiverseWorld = mock(LoadedMultiverseWorld.class);
+ when(multiverseWorld.getName()).thenReturn(name);
+ when(multiverseWorld.asLoadedWorld()).thenReturn(Option.of(multiverseWorld));
+ when(multiverseWorld.getBukkitWorld()).thenReturn(Option.of(bukkitWorld));
+ return multiverseWorld;
+ }
+
+ private static void withServer(File levelRoot, java.util.function.Consumer body) {
+ withServer(levelRoot, null, body);
+ }
+
+ /**
+ * Multiverse is left disabled so the listener resolves everything from Bukkit and Iris storage; that is
+ * the shape the guard has to hold in whether or not the Multiverse API answers.
+ */
+ private static void withServer(
+ File levelRoot,
+ World namedWorld,
+ java.util.function.Consumer body
+ ) {
+ Server server = mock(Server.class);
+ when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
+ PluginManager pluginManager = mock(PluginManager.class);
+ when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(false);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getServer).thenReturn(server);
+ bukkit.when(Bukkit::getWorldContainer).thenReturn(levelRoot.getParentFile());
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ if (namedWorld != null) {
+ bukkit.when(() -> Bukkit.getWorld(namedWorld.getName())).thenReturn(namedWorld);
+ }
+ body.accept(new MultiverseGuardListener(new MultiverseCoreLink()));
+ }
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java b/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java
new file mode 100644
index 000000000..d580dedaf
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java
@@ -0,0 +1,116 @@
+package art.arcane.iris.core.service;
+
+import art.arcane.iris.core.link.MultiverseCoreLink;
+import art.arcane.iris.core.link.MultiverseGuardListener;
+import art.arcane.iris.platform.bukkit.BukkitPlatform;
+import art.arcane.iris.spi.IrisServices;
+import art.arcane.iris.util.common.plugin.VolmitPlugin;
+import org.bukkit.Bukkit;
+import org.bukkit.event.Listener;
+import org.bukkit.event.server.PluginDisableEvent;
+import org.bukkit.event.server.PluginEnableEvent;
+import org.bukkit.plugin.Plugin;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.mock;
+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 MultiverseSVCTest {
+ @After
+ public void unbindLink() {
+ IrisServices.remove(MultiverseCoreLink.class);
+ }
+
+ @Test
+ public void anAbsentMultiverseLeavesTheGuardUnregistered() {
+ MultiverseCoreLink link = bindLink(false);
+ VolmitPlugin plugin = mock(VolmitPlugin.class);
+
+ run(plugin, service -> service.onEnable());
+
+ verify(plugin, never()).registerListener(any());
+ verify(link, never()).reconcileOwnedWorlds();
+ }
+
+ @Test
+ public void anAlreadyEnabledMultiverseIsPickedUpAtServiceEnable() {
+ MultiverseCoreLink link = bindLink(true);
+ VolmitPlugin plugin = mock(VolmitPlugin.class);
+
+ run(plugin, service -> service.onEnable());
+
+ verify(plugin).registerListener(isA(MultiverseGuardListener.class));
+ verify(link).reconcileOwnedWorlds();
+ }
+
+ @Test
+ public void multiverseEnablingAfterIrisRegistersTheGuardExactlyOnce() {
+ MultiverseCoreLink link = bindLink(true);
+ VolmitPlugin plugin = mock(VolmitPlugin.class);
+
+ run(plugin, service -> {
+ service.onEnable();
+ service.onPluginEnable(new PluginEnableEvent(namedPlugin("Multiverse-Core")));
+ });
+
+ verify(plugin, times(1)).registerListener(any(Listener.class));
+ verify(link, times(1)).reconcileOwnedWorlds();
+ }
+
+ @Test
+ public void anUnrelatedPluginNeverTouchesTheMultiverseLink() {
+ MultiverseCoreLink link = bindLink(true);
+ VolmitPlugin plugin = mock(VolmitPlugin.class);
+
+ run(plugin, service -> {
+ service.onPluginEnable(new PluginEnableEvent(namedPlugin("WorldEdit")));
+ service.onPluginDisable(new PluginDisableEvent(namedPlugin("WorldEdit")));
+ });
+
+ verify(plugin, never()).registerListener(any());
+ verify(link, never()).reconcileOwnedWorlds();
+ }
+
+ @Test
+ public void multiverseDisablingTakesTheGuardBackDown() {
+ bindLink(true);
+ VolmitPlugin plugin = mock(VolmitPlugin.class);
+
+ run(plugin, service -> {
+ service.onEnable();
+ service.onPluginDisable(new PluginDisableEvent(namedPlugin("Multiverse-Core")));
+ });
+
+ verify(plugin).unregisterListener(isA(MultiverseGuardListener.class));
+ }
+
+ private static MultiverseCoreLink bindLink(boolean active) {
+ MultiverseCoreLink link = mock(MultiverseCoreLink.class);
+ when(link.isActive()).thenReturn(active);
+ IrisServices.register(MultiverseCoreLink.class, link);
+ return link;
+ }
+
+ private static Plugin namedPlugin(String name) {
+ Plugin plugin = mock(Plugin.class);
+ when(plugin.getName()).thenReturn(name);
+ return plugin;
+ }
+
+ private static void run(VolmitPlugin plugin, java.util.function.Consumer body) {
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic platform = mockStatic(BukkitPlatform.class)) {
+ bukkit.when(Bukkit::isPrimaryThread).thenReturn(true);
+ platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin);
+ body.accept(new MultiverseSVC());
+ }
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java
index 32434cc07..744e6227e 100644
--- a/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java
+++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java
@@ -1,5 +1,6 @@
package art.arcane.iris.engine;
+import art.arcane.iris.core.lifecycle.VanishedWorldStorage;
import art.arcane.iris.engine.object.IrisEngineData;
import com.google.gson.Gson;
import org.junit.Rule;
@@ -12,14 +13,81 @@ import java.nio.file.Files;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
public class IrisEngineDataPersistenceTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
+ @Test
+ public void atomicWriteRefusesToRebuildADeletedWorldFolder() throws Exception {
+ File worldFolder = temporaryFolder.newFolder("deleted-world");
+ File output = new File(worldFolder, "iris/engine-data/dimension.json");
+ assertTrue(worldFolder.delete());
+
+ assertThrows(IOException.class,
+ () -> EngineDataStore.writeEngineDataAtomically(output, new IrisEngineData()));
+
+ assertFalse("a save must never resurrect a world tree", worldFolder.exists());
+ }
+
+ @Test
+ public void vanishedWorldStorageIsDetectedAndReported() throws Exception {
+ VanishedWorldStorage.reset();
+ File worldFolder = temporaryFolder.newFolder("vanishing-world");
+
+ assertFalse(VanishedWorldStorage.vanished(worldFolder));
+ assertTrue(worldFolder.delete());
+ assertTrue(VanishedWorldStorage.vanished(worldFolder));
+ VanishedWorldStorage.reset();
+ }
+
+ /**
+ * A save-all after a hot delete writes the level's own data/*.dat files back, which recreates the world
+ * folder. That folder is not the world returning, and letting Iris resume writing into it rebuilds an
+ * iris/ directory that makes the next boot classify the husk as unusable on some runs and empty on
+ * others.
+ */
+ @Test
+ public void aWorldFolderTheServerRecreatesNeverReEnablesPersistence() throws Exception {
+ VanishedWorldStorage.reset();
+ File worldFolder = temporaryFolder.newFolder("recreated-world");
+
+ assertFalse(VanishedWorldStorage.vanished(worldFolder));
+ assertTrue(worldFolder.delete());
+ assertTrue(VanishedWorldStorage.vanished(worldFolder));
+ assertTrue(worldFolder.mkdirs());
+
+ assertTrue("a deleted world stays deleted for this JVM", VanishedWorldStorage.vanished(worldFolder));
+ VanishedWorldStorage.reset();
+ }
+
+ /**
+ * The world folder can be back before Iris ever looks at it, so the latch alone is not enough: a tree
+ * Iris has already written that is now missing is the same delete.
+ */
+ @Test
+ public void anEstablishedIrisTreeThatIsGoneCountsAsVanishedUnderARecreatedFolder() throws Exception {
+ VanishedWorldStorage.reset();
+ File worldFolder = temporaryFolder.newFolder("hot-deleted-world");
+ File engineData = new File(worldFolder, "iris/engine-data");
+ assertTrue(engineData.mkdirs());
+
+ assertFalse(VanishedWorldStorage.vanished(worldFolder, engineData));
+ assertTrue(engineData.delete());
+ assertTrue(new File(worldFolder, "iris").delete());
+
+ assertTrue(VanishedWorldStorage.vanished(worldFolder, engineData));
+ assertTrue("the verdict holds without the established tree too",
+ VanishedWorldStorage.vanished(worldFolder));
+ VanishedWorldStorage.reset();
+ }
+
@Test
public void atomicWriteCreatesAndReplacesEngineData() throws Exception {
- File folder = temporaryFolder.newFolder("engine-data");
+ File worldFolder = temporaryFolder.newFolder("live-world");
+ File folder = new File(worldFolder, "iris/engine-data");
File output = new File(folder, "dimension.json");
IrisEngineData first = new IrisEngineData();
first.getStatistics().setVersion(10);
diff --git a/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java b/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java
new file mode 100644
index 000000000..50aa29006
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java
@@ -0,0 +1,106 @@
+package art.arcane.iris.engine.data.cache;
+
+import org.junit.Test;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+
+public class AtomicCacheTest {
+ @Test
+ public void aquireOnceOrThrowCachesTheComputedValue() {
+ AtomicCache cache = new AtomicCache<>();
+ AtomicInteger calls = new AtomicInteger();
+
+ assertEquals("value", cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ return "value";
+ }));
+ assertEquals("value", cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ return "other";
+ }));
+ assertEquals(1, calls.get());
+ }
+
+ /**
+ * The retrying variant is what a caller that can repair the cause between attempts needs, and the
+ * world generator relies on it, so memoization must stay confined to aquireOnceOrThrow.
+ */
+ @Test
+ public void aquireOrThrowStillRetriesAFailedSupplier() {
+ AtomicCache cache = new AtomicCache<>();
+ AtomicInteger calls = new AtomicInteger();
+
+ assertThrows(IllegalStateException.class, () -> cache.aquireOrThrow(() -> {
+ calls.incrementAndGet();
+ throw new IllegalStateException("repairable");
+ }));
+ assertEquals("repaired", cache.aquireOrThrow(() -> {
+ calls.incrementAndGet();
+ return "repaired";
+ }));
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ public void aquireOnceOrThrowMemoizesTheSupplierFailureInsteadOfRerunningIt() {
+ AtomicCache cache = new AtomicCache<>();
+ AtomicInteger calls = new AtomicInteger();
+ IllegalStateException cause = new IllegalStateException("Frozen Iris pack snapshot is missing");
+
+ IllegalStateException first = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ throw cause;
+ }));
+ IllegalStateException second = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ return "recovered";
+ }));
+
+ assertSame(cause, first);
+ assertSame(cause, second);
+ assertEquals("the failure is stated once and replayed, never recomputed", 1, calls.get());
+ }
+
+ @Test
+ public void aquireOnceOrThrowRejectsANullValueAndRemembersThat() {
+ AtomicCache cache = new AtomicCache<>();
+ AtomicInteger calls = new AtomicInteger();
+
+ assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ return null;
+ }));
+ assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
+ calls.incrementAndGet();
+ return "recovered";
+ }));
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ public void resetClearsAMemoizedFailure() {
+ AtomicCache cache = new AtomicCache<>();
+
+ assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> {
+ throw new IllegalStateException("transient");
+ }));
+ cache.reset();
+
+ assertEquals("recovered", cache.aquireOnceOrThrow(() -> "recovered"));
+ }
+
+ @Test
+ public void aquireStillSwallowsFailuresForOptionalValues() {
+ AtomicCache cache = new AtomicCache<>();
+
+ assertNull(cache.aquire(() -> {
+ throw new IllegalStateException("optional value");
+ }));
+ assertEquals("later", cache.aquire(() -> "later"));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java
index 993ced0aa..8810cded5 100644
--- a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java
+++ b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java
@@ -4,65 +4,115 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
-import java.io.ByteArrayOutputStream;
-import java.io.PrintStream;
-import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
+/**
+ * Severity is stated by the caller and honoured by every adapter, so the levels {@link IrisLogging} offers
+ * decide what an operator sees in a WARN-level scan. The once helpers exist for conditions that are worth
+ * exactly one warning and would otherwise repeat per block, per sample or per chunk.
+ */
public class IrisLoggingTest {
+ private final List levels = new ArrayList<>();
+ private final List messages = new ArrayList<>();
+ private IrisPlatform previousPlatform;
+
@Before
- public void resetBinding() {
+ public void captureLog() {
+ previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
+ levels.clear();
+ messages.clear();
+ bindCapturingPlatform();
}
@After
- public void clearBinding() {
+ public void restorePlatform() {
IrisPlatforms.unbind();
- }
-
- @Test
- public void contextualReportPrintsFullStacktraceWithBoundPlatform() {
- IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS);
- IrisPlatforms.bind(platform);
- IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner"));
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- PrintStream originalErr = System.err;
- System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
- try {
- IrisLogging.reportError("Runtime world creation failed.", failure);
- } finally {
- System.setErr(originalErr);
+ if (previousPlatform != null) {
+ IrisPlatforms.bind(previousPlatform);
}
+ }
- verify(platform).log(LogLevel.ERROR, "Runtime world creation failed.");
- verify(platform).reportError("Runtime world creation failed.", failure);
- verify(platform).reportError(failure);
- String text = output.toString(StandardCharsets.UTF_8);
- assertTrue(text.contains("IllegalStateException"));
- assertTrue(text.contains("IllegalArgumentException"));
- assertTrue(text.contains("inner"));
+ /**
+ * A lifecycle line is not a warning, but it is what an operator reads logs/latest.log for. NOTICE is the
+ * level adapters route to their own logger at INFO instead of to the console sender.
+ */
+ @Test
+ public void noticeCarriesItsOwnLevel() {
+ IrisLogging.notice("Engine init: %s", "world");
+
+ assertEquals(List.of(LogLevel.NOTICE), levels);
+ assertEquals("Engine init: world", messages.getFirst());
}
@Test
- public void contextualReportHonorsPlatformOverrideSuppression() {
+ public void warnOnceStatesTheConditionOnceAndDropsRepeatsToDebug() {
+ String key = uniqueKey();
+
+ assertTrue(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
+ assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
+ assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block"));
+
+ assertEquals(List.of(LogLevel.WARN, LogLevel.DEBUG, LogLevel.DEBUG), levels);
+ assertTrue(messages.toString(), messages.stream().allMatch(m -> m.equals("Can't find block data for mod:block")));
+ }
+
+ @Test
+ public void aDistinctKeyIsItsOwnWarning() {
+ assertTrue(IrisLogging.warnOnce(uniqueKey(), "first"));
+ assertTrue(IrisLogging.warnOnce(uniqueKey(), "second"));
+
+ assertEquals(List.of(LogLevel.WARN, LogLevel.WARN), levels);
+ }
+
+ @Test
+ public void errorOnceKeepsTheSameContract() {
+ String key = uniqueKey();
+
+ assertTrue(IrisLogging.errorOnce(key, "Atomic cache supplier failed"));
+ assertFalse(IrisLogging.errorOnce(key, "Atomic cache supplier failed"));
+
+ assertEquals(List.of(LogLevel.ERROR, LogLevel.DEBUG), levels);
+ }
+
+ /**
+ * An operator fixes the pack and reloads. Holding the key for the life of the JVM would hide whether the
+ * fix worked, so unbinding the platform - which is what a reload does - clears them.
+ */
+ @Test
+ public void unbindingThePlatformClearsTheOnceKeys() {
+ String key = uniqueKey();
+ assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains"));
+
+ IrisPlatforms.unbind();
+ levels.clear();
+ bindCapturingPlatform();
+
+ assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains"));
+ assertEquals(List.of(LogLevel.WARN), levels);
+ }
+
+ private void bindCapturingPlatform() {
IrisPlatform platform = mock(IrisPlatform.class);
+ doAnswer(invocation -> {
+ levels.add(invocation.getArgument(0, LogLevel.class));
+ messages.add(invocation.getArgument(1, String.class));
+ return null;
+ }).when(platform).log(any(LogLevel.class), anyString());
IrisPlatforms.bind(platform);
- IllegalStateException failure = new IllegalStateException("suppressed");
- ByteArrayOutputStream output = new ByteArrayOutputStream();
- PrintStream originalErr = System.err;
- System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
- try {
- IrisLogging.reportError("Throttled failure.", failure);
- } finally {
- System.setErr(originalErr);
- }
+ }
- verify(platform).reportError("Throttled failure.", failure);
- assertFalse(output.toString(StandardCharsets.UTF_8).contains("suppressed"));
+ private static String uniqueKey() {
+ return "iris-logging-test:" + UUID.randomUUID();
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e51ecba29..5196c979c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,7 +48,7 @@ eco = "2026.27" # https://repo.auxilor.io/repository/maven-public/com/willfp/eco
mythic = "5.12.1"
mythic-crucible = "2.2.0"
kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml
-multiverseCore = "5.7.2"
+multiverseCore = "5.8.0"
craftengine = "26.7.3" # https://github.com/Xiao-MoMi/craft-engine/releases
# Fabric API (net.fabricmc.fabric-api) - each module is independently versioned
diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java
index 54bab13d3..25f7fd817 100644
--- a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java
+++ b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java
@@ -19,6 +19,8 @@
package art.arcane.iris.spi;
import java.util.IllegalFormatException;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/**
@@ -33,6 +35,8 @@ import java.util.regex.Pattern;
* Internal to Iris; not a published integration surface.
*/
public final class IrisLogging {
+ private static final int MAX_ONCE_KEYS = 512;
+ private static final Set ONCE_KEYS = ConcurrentHashMap.newKeySet();
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
private static final Pattern MINI_MESSAGE_TAG = Pattern.compile("(?i)?(?:reset|bold|b|italic|i|underlined?|u|strikethrough|st|obfuscated?|obf|black|dark_blue|dark_green|dark_aqua|dark_red|dark_purple|gold|gray|dark_gray|blue|green|aqua|red|light_purple|yellow|white|gradient|font|hover|click|rainbow)(?::[^>\\n]{0,96})?>|<#[0-9a-f]{6}>");
@@ -54,6 +58,14 @@ public final class IrisLogging {
emit(LogLevel.DEBUG, message);
}
+ /**
+ * Logs at {@link LogLevel#NOTICE}, applying {@link #format(String, Object...)} to the arguments. For the
+ * few lifecycle milestones per boot that have to survive into the server's own log file.
+ */
+ public static void notice(String format, Object... args) {
+ emit(LogLevel.NOTICE, format(format, args));
+ }
+
/**
* Logs at {@link LogLevel#WARN}, applying {@link #format(String, Object...)} to the arguments.
*/
@@ -68,6 +80,25 @@ public final class IrisLogging {
emit(LogLevel.ERROR, format(format, args));
}
+ /**
+ * Logs at {@link LogLevel#WARN} the first time {@code key} is seen and at {@link LogLevel#DEBUG} every
+ * time after, for a condition that is worth stating once but is reached per block, per sample or per
+ * chunk. Returns true when the warning was the one that got stated, so a caller can attach a stack trace
+ * or other one-off detail to it.
+ *
+ * @see #resetOnceKeys()
+ */
+ public static boolean warnOnce(String key, String format, Object... args) {
+ return once(LogLevel.WARN, key, format, args);
+ }
+
+ /**
+ * {@link #warnOnce(String, String, Object...)} at {@link LogLevel#ERROR}.
+ */
+ public static boolean errorOnce(String key, String format, Object... args) {
+ return once(LogLevel.ERROR, key, format, args);
+ }
+
/**
* Emits a player-facing message to the console, preserving colour markup when a platform is bound and
* stripping it via {@link #clean(String)} when one is not.
@@ -156,6 +187,38 @@ public final class IrisLogging {
return MINI_MESSAGE_TAG.matcher(legacyStripped).replaceAll("");
}
+ /**
+ * Forgets every key {@link #warnOnce(String, String, Object...)} and {@link #errorOnce} have claimed, so
+ * the next occurrence is stated again. Called when the platform unbinds: an operator who fixes a pack and
+ * reloads has to be able to see whether the fix took.
+ */
+ static void resetOnceKeys() {
+ ONCE_KEYS.clear();
+ }
+
+ private static boolean once(LogLevel level, String key, String format, Object... args) {
+ String message = format(format, args);
+ if (!claim(key)) {
+ emit(LogLevel.DEBUG, message);
+ return false;
+ }
+
+ emit(level, message);
+ return true;
+ }
+
+ /**
+ * The cap is what keeps a key space nobody bounded - a block name, a biome name, a mod id - from becoming
+ * a leak. Past it nothing new is claimed, which drops later conditions to debug rather than growing.
+ */
+ private static boolean claim(String key) {
+ if (ONCE_KEYS.size() >= MAX_ONCE_KEYS) {
+ return false;
+ }
+
+ return ONCE_KEYS.add(key == null ? "null" : key);
+ }
+
private static void emit(LogLevel level, String message) {
LogLevel target = level == null ? LogLevel.INFO : level;
if (IrisPlatforms.isBound()) {
diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java
index 2f8fb56db..2c5936200 100644
--- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java
+++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java
@@ -47,9 +47,14 @@ public final class IrisPlatforms {
/**
* Clears the binding. Safe to call when nothing is bound.
+ *
+ * Also forgets the keys {@link IrisLogging#warnOnce(String, String, Object...)} has claimed. Unbinding is
+ * what a reload does, and an operator who has just fixed a pack has to see whether the condition that was
+ * stated once is still there.
*/
public static synchronized void unbind() {
platform = null;
+ IrisLogging.resetOnceKeys();
}
/**
diff --git a/spi/src/main/java/art/arcane/iris/spi/LogLevel.java b/spi/src/main/java/art/arcane/iris/spi/LogLevel.java
index 460576b37..387d098b4 100644
--- a/spi/src/main/java/art/arcane/iris/spi/LogLevel.java
+++ b/spi/src/main/java/art/arcane/iris/spi/LogLevel.java
@@ -33,6 +33,12 @@ public enum LogLevel {
DEBUG,
/** Normal operational messages. */
INFO,
+ /**
+ * Lifecycle milestones an operator reads the server log to find. Not a problem, but adapters route it to
+ * the host logger rather than to a console sender, so it survives into the log file the server writes.
+ * Reserved for a handful of events per boot.
+ */
+ NOTICE,
/** Recoverable problems and misconfiguration. */
WARN,
/** Failures; usually paired with {@link IrisPlatform#reportError(Throwable)}. */