diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java
new file mode 100644
index 000000000..cc332873f
--- /dev/null
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java
@@ -0,0 +1,88 @@
+package art.arcane.iris;
+
+import art.arcane.iris.platform.bootstrap.ServerProperties;
+import art.arcane.iris.world.IrisStartupValidation;
+import art.arcane.iris.world.IrisWorldStorage;
+import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Keeps a failed enable from handing Iris worlds to the vanilla generator.
+ *
+ * Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled
+ * generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its
+ * runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the
+ * level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and
+ * logging, never a service the failed enable may not have created.
+ */
+final class FailedEnableLock {
+ private FailedEnableLock() {
+ }
+
+ /**
+ * @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
+ */
+ static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
+ if (!irisWorldsPresent) {
+ return false;
+ }
+ IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
+ List notice = notice(failure, startupWorldsCreated);
+ try {
+ for (String line : notice) {
+ Iris.error(line);
+ }
+ Iris.reportError(failure);
+ } catch (Throwable loggingFailure) {
+ notice.forEach(System.err::println);
+ failure.addSuppressed(loggingFailure);
+ failure.printStackTrace(System.err);
+ }
+ return true;
+ }
+
+ /**
+ * Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
+ * A failed enable may have neither, so it tears down at once even while the server stops.
+ */
+ static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
+ return !enableFailed && serverStopping;
+ }
+
+ /**
+ * @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
+ * already exist, so nothing stops
+ */
+ static List notice(Throwable failure, boolean startupWorldsCreated) {
+ return GenerationRefusalNotice.compose(
+ "Iris failed to enable",
+ GenerationRefusalNotice.causes(failure),
+ List.of(
+ "Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
+ startupWorldsCreated
+ ? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
+ + " and no chunks are written for them."
+ : "Every Iris world refuses to generate: server startup stops before the first one"
+ + " loads, and no chunks are written.",
+ "Player logins are refused until this is fixed and the server restarts."
+ ));
+ }
+
+ static boolean irisWorldsPresent() {
+ try {
+ return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML);
+ } catch (Throwable unreadable) {
+ Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable);
+ return true;
+ }
+ }
+
+ static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
+ return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
+ || BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration);
+ }
+}
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 aefe52ff1..25700400e 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
@@ -32,9 +32,9 @@ import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisStartupAdmissionListener;
import art.arcane.iris.world.BukkitWorldReconciler;
import art.arcane.iris.world.IrisWorldGeneratorResolver;
-import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager;
+import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
@@ -200,6 +200,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted;
+ private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
@@ -613,21 +614,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
}
- /**
- * @return false when the bootstrap was aborted (unsupported server version); the caller
- * must bail out of onEnable without touching any further setup.
- */
- private boolean enable() {
- if (!INMS.isBound()) {
- Throwable bindFailure = INMS.bindFailure();
- Iris.error("Iris cannot start: " + (bindFailure == null
- ? "no NMS binding is available for this server version."
- : bindFailure.getMessage()));
- // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
- // synchronously and the loader then continues registering the half-enabled plugin.
- J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
- return false;
- }
+ private void enable() {
EnableTimings timings = new EnableTimings();
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
@@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
});
timings.mark("startupTasks");
timings.report();
- return true;
}
private static final class EnableTimings {
@@ -910,20 +896,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onEnable() {
- IrisPlatforms.bind(new BukkitPlatform());
+ enableFailed = false;
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
- Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
- pendingWorldReplacements.registerPlatformEntryListener();
- boolean enabled;
try {
- enabled = enable();
+ generatorResolver.refusals().attach(this);
+ IrisPlatforms.bind(new BukkitPlatform());
+ Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
+ pendingWorldReplacements.registerPlatformEntryListener();
+ if (!INMS.isBound()) {
+ refuseUnsupportedServer();
+ return;
+ }
+ enable();
} catch (Throwable failure) {
- refuseVanillaFallback(failure);
- throw failure;
- }
- if (!enabled) {
- refuseVanillaFallback(null);
+ if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
+ generatorResolver.refusals().startupWorldsCreated())) {
+ throw failure;
+ }
+ enableFailed = true;
return;
}
BukkitGuiHost.install();
@@ -939,54 +930,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime();
}
+ private void refuseUnsupportedServer() {
+ Throwable bindFailure = INMS.bindFailure();
+ IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
+ ? "no NMS binding is available for this server version."
+ : bindFailure.getMessage()), bindFailure);
+ if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
+ generatorResolver.refusals().startupWorldsCreated())) {
+ enableFailed = true;
+ return;
+ }
+ Iris.error(failure.getMessage());
+ // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
+ // synchronously and the loader then continues registering the half-enabled plugin.
+ J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
+ }
+
private static void reportLockedRuntime() {
String denial = IrisStartupValidation.denialReason().orElse(null);
if (denial == null) {
return;
}
- boolean managedStorage;
- try {
- managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
- } catch (Throwable unavailable) {
- Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
- managedStorage = false;
- }
- for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
+ for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
Iris.error(line);
}
}
- /**
- * 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 BukkitDebugDump debugDump() {
return debugDump;
}
@@ -1014,11 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
- boolean serverStopping = IrisToolbelt.isServerStopping();
+ boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
- } else if (serverStopping) {
+ } else if (deferToServerStop) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
@@ -1036,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
- if (!serverStopping || restartingAtStartupBoundary) {
+ if (!deferToServerStop || restartingAtStartupBoundary) {
finishTerminalCleanup();
}
}
@@ -1457,6 +1426,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
}
+ public ChunkGenerator requireWorldGenerator(String worldName, String id) {
+ return generatorResolver.requireWorldGenerator(worldName, id);
+ }
+
+ public WorldRefusalReporter worldRefusals() {
+ return generatorResolver.refusals();
+ }
+
public void splash() {
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
printPacks();
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 1734ce4aa..0c40281bc 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
@@ -112,16 +112,16 @@ public final class IrisBootstrap implements PluginBootstrap {
* 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.
+ * whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
+ * it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
+ * after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
+ * 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.
+ * or a move that failed) owns nothing to lose. Both are only reported here.
*/
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
Path levelRoot = startupPaths.levelRoot();
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java
index 91bba32e6..dee36913c 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
return WorldIdentity.resolve(worldKey);
}
+ /**
+ * A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
+ * then fails this load only.
+ */
@Override
public CompletableFuture createWorld(NamespacedKey worldKey, String dimension, Long seed) {
+ String configuredWorldName = worldKey.toString();
+ ChunkGenerator generator;
+ IrisDimension irisDimension;
try {
- String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
- String configuredWorldName = configuredWorldName(worldKey);
- Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
- ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
- IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
+ configuredWorldName = configuredWorldName(worldKey);
+ Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
+ generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
+ irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
}
-
- Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
+ } catch (Throwable refusal) {
+ plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
+ return CompletableFuture.failedFuture(refusal);
+ }
+ try {
+ Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
+ + " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
.generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java
index 9309e4e60..734ecb37a 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java
@@ -18,6 +18,7 @@
package art.arcane.iris.world;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.World;
@@ -48,6 +49,20 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
+ "' remains locked: " + denialReason);
}
+ /**
+ * Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
+ * be summarized is named by its type instead.
+ */
+ static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
+ String reason;
+ try {
+ reason = GenerationRefusalNotice.summary(failure);
+ } catch (Throwable unreadable) {
+ reason = failure.getClass().getName();
+ }
+ return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
+ }
+
@Override
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java
index 6d7e88aae..b4f4aeb3c 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java
@@ -90,6 +90,7 @@ public final class IrisWorldGeneratorResolver {
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
private final Map pendingSnapshots = new ConcurrentHashMap<>();
+ private final WorldRefusalReporter refusals = new WorldRefusalReporter();
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin;
@@ -480,12 +481,28 @@ public final class IrisWorldGeneratorResolver {
return fallback.get();
}
+ /**
+ * The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every
+ * request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let
+ * the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null.
+ */
@Nullable
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Ignoring PlotSquared generator discovery probe");
return null;
}
+ try {
+ return requireWorldGenerator(worldName, id);
+ } catch (Throwable failure) {
+ return refuseWorld(worldName, failure);
+ }
+ }
+
+ /**
+ * Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer.
+ */
+ public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) {
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
@@ -505,21 +522,37 @@ public final class IrisWorldGeneratorResolver {
return startupLock;
}
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
- if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
- Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
+ String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
+ Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
+ return resolveFrozenWorldGenerator(worldName, dimension);
+ }
+ public WorldRefusalReporter refusals() {
+ return refusals;
+ }
+
+ private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
+ refusals.report(worldName, refusalKey(worldName), failure);
try {
- return resolveFrozenWorldGenerator(worldName, id);
- } catch (RuntimeException failure) {
- Iris.reportError("Refusing to load configured Iris world '" + worldName
- + "' because its frozen world-local pack snapshot could not be used.", failure);
- Bukkit.shutdown();
- throw failure;
+ Iris.reportError(failure);
+ } catch (Throwable loggingFailure) {
+ System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
+ + loggingFailure.getClass().getName());
+ }
+ return IrisFailClosedChunkGenerator.refused(worldName, failure);
+ }
+
+ @Nullable
+ private static NamespacedKey refusalKey(String worldName) {
+ try {
+ return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
+ } catch (Throwable unresolved) {
+ return null;
}
}
@@ -588,6 +621,14 @@ public final class IrisWorldGeneratorResolver {
return;
}
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
+ if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
+ // Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
+ // until that binding is gone.
+ throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ + ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
+ + " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run"
+ + " /iris replace " + messageKey + " type= to replace it with Iris on the next restart.");
+ }
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java
new file mode 100644
index 000000000..97620537e
--- /dev/null
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java
@@ -0,0 +1,122 @@
+/*
+ * Iris is a World Generator for Minecraft Servers
+ * Copyright (c) 2026 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.world;
+
+import art.arcane.iris.Iris;
+import art.arcane.iris.localization.C;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
+import art.arcane.volmlib.util.bukkit.WorldIdentity;
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.world.WorldLoadEvent;
+import org.bukkit.plugin.Plugin;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentSkipListMap;
+
+/**
+ * Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
+ * an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
+ */
+public final class WorldRefusalReporter implements Listener {
+ private static final String UNRESOLVED_KEY = "unresolved key";
+
+ private final Map operatorNotices = new ConcurrentSkipListMap<>();
+ private volatile boolean startupWorldsCreated;
+
+ /**
+ * Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
+ */
+ public void attach(Plugin plugin) {
+ if (!Bukkit.getWorlds().isEmpty()) {
+ startupWorldsCreated = true;
+ }
+ Bukkit.getPluginManager().registerEvents(this, plugin);
+ }
+
+ /**
+ * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
+ * A generator asked for after that belongs to a runtime create, which fails only its caller.
+ */
+ public boolean startupWorldsCreated() {
+ return startupWorldsCreated;
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onWorldLoad(WorldLoadEvent event) {
+ startupWorldsCreated = true;
+ try {
+ operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
+ } catch (RuntimeException unkeyed) {
+ Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onPlayerJoin(PlayerJoinEvent event) {
+ if (operatorNotices.isEmpty()) {
+ return;
+ }
+ Player player = event.getPlayer();
+ if (!player.isOp() && !player.hasPermission("iris.all")) {
+ return;
+ }
+ for (String notice : operatorNotices.values()) {
+ player.sendMessage(C.RED + notice);
+ }
+ }
+
+ /**
+ * Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
+ * failure must not turn either into something else.
+ */
+ public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
+ String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
+ try {
+ for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
+ Iris.error(line);
+ }
+ operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
+ + ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
+ } catch (Throwable reportingFailure) {
+ System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
+ + "); the refusal could not be reported: " + reportingFailure.getClass().getName());
+ }
+ }
+
+ static List notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
+ return GenerationRefusalNotice.compose(
+ "Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
+ GenerationRefusalNotice.causes(failure),
+ List.of(
+ "Iris does not generate this world and does not let vanilla or any other generator"
+ + " write it; no chunks are written.",
+ startupWorldsCreated
+ ? "The world does not load; the server keeps running. Fix the cause, then load it again."
+ : "Server startup stops before this world loads. Fix the cause, then start the server again."
+ ));
+ }
+}
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java
new file mode 100644
index 000000000..15039e517
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java
@@ -0,0 +1,240 @@
+package art.arcane.iris;
+
+import art.arcane.iris.platform.bukkit.nms.INMS;
+import art.arcane.iris.spi.IrisPlatform;
+import art.arcane.iris.spi.IrisPlatforms;
+import art.arcane.iris.world.IrisStartupAdmissionListener;
+import art.arcane.iris.world.IrisStartupValidation;
+import art.arcane.iris.world.IrisWorldGeneratorResolver;
+import art.arcane.iris.world.PendingWorldReplacementManager;
+import art.arcane.iris.world.task.J;
+import com.destroystokyo.paper.profile.PlayerProfile;
+import org.bukkit.Bukkit;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
+import org.bukkit.generator.ChunkGenerator;
+import org.bukkit.generator.WorldInfo;
+import org.bukkit.plugin.PluginManager;
+import org.junit.After;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.MockedStatic;
+
+import java.io.File;
+import java.lang.reflect.Field;
+import java.net.InetAddress;
+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.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+
+/**
+ * Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a
+ * disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has
+ * to leave Iris enabled and locked instead.
+ */
+public class FailedEnableLockTest {
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @After
+ public void disableValidation() {
+ IrisStartupValidation.disable();
+ IrisPlatforms.unbind();
+ }
+
+ @Test
+ public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception {
+ IrisStartupValidation.begin();
+ IllegalStateException failure = new IllegalStateException("service graph exploded");
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class)) {
+ assertTrue(FailedEnableLock.engage(failure, true, false));
+
+ String denial = IrisStartupValidation.denialReason().orElseThrow();
+ assertTrue(denial, denial.contains("service graph exploded"));
+
+ ChunkGenerator generator = new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world", "overworld");
+ assertNotNull("a null generator is the vanilla generator", generator);
+ IllegalStateException refusal = assertThrows(IllegalStateException.class,
+ () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded"));
+ bukkit.verify(Bukkit::shutdown, never());
+ }
+
+ AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent(
+ "LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class));
+ new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login);
+ assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult());
+ }
+
+ @Test
+ public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() {
+ IrisStartupValidation.begin();
+
+ try (MockedStatic iris = mockStatic(Iris.class)) {
+ assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
+ }
+
+ String denial = IrisStartupValidation.denialReason().orElseThrow();
+ assertFalse(denial, denial.contains("service graph exploded"));
+ }
+
+ @Test
+ public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
+ String notice = String.join("\n", FailedEnableLock.notice(
+ new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
+
+ assertTrue(notice, notice.contains("Iris failed to enable"));
+ assertTrue(notice, notice.contains("outer"));
+ assertTrue(notice, notice.contains("inner"));
+ assertTrue(notice, notice.contains("vanilla"));
+ assertTrue(notice, notice.contains("startup stops"));
+ assertTrue(notice, notice.contains("logins"));
+ }
+
+ /**
+ * A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
+ */
+ @Test
+ public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
+ String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
+
+ assertFalse(notice, notice.contains("startup stops"));
+ assertTrue(notice, notice.contains("keeps running"));
+ }
+
+ /**
+ * IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
+ * the guarded region, and the throw must not reach Paper.
+ */
+ @Test
+ public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
+ IrisPlatforms.bind(mock(IrisPlatform.class));
+ Iris plugin = plugin();
+
+ try (EnableFixture fixture = new EnableFixture(true)) {
+ plugin.onEnable();
+
+ String denial = IrisStartupValidation.denialReason().orElseThrow();
+ assertTrue(denial, denial.contains("already bound"));
+ ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld");
+ assertNotNull("a null generator is the vanilla generator", generator);
+ IllegalStateException refusal = assertThrows(IllegalStateException.class,
+ () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound"));
+ fixture.bukkit.verify(Bukkit::shutdown, never());
+ }
+ }
+
+ @Test
+ public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
+ IrisPlatforms.bind(mock(IrisPlatform.class));
+ Iris plugin = plugin();
+
+ try (EnableFixture fixture = new EnableFixture(false)) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
+ assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
+ }
+ }
+
+ @Test
+ public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
+ Iris plugin = plugin();
+ set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
+
+ try (EnableFixture fixture = new EnableFixture(true);
+ MockedStatic nms = mockStatic(INMS.class);
+ MockedStatic scheduler = mockStatic(J.class)) {
+ nms.when(INMS::isBound).thenReturn(false);
+ nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
+
+ plugin.onEnable();
+
+ String denial = IrisStartupValidation.denialReason().orElseThrow();
+ assertTrue(denial, denial.contains("no NMS binding for 26.9"));
+ scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
+ }
+ }
+
+ /**
+ * Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
+ * made, so a failed enable tears down at once even while the server stops.
+ */
+ @Test
+ public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
+ assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
+ assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
+ assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
+ assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
+ }
+
+ @Test
+ public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
+ File levelRoot = temporaryFolder.newFolder("level", "world");
+ File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
+
+ assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
+
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world.generator", "Iris:overworld");
+ yaml.save(bukkit);
+ assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
+
+ assertTrue(bukkit.delete());
+ assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
+ assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
+ }
+
+ /**
+ * Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
+ * set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
+ */
+ private static Iris plugin() throws Exception {
+ Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
+ set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
+ return plugin;
+ }
+
+ private static void set(Iris plugin, String name, Object value) throws Exception {
+ Field field = Iris.class.getDeclaredField(name);
+ field.setAccessible(true);
+ field.set(plugin, value);
+ }
+
+ /**
+ * irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
+ */
+ private static final class EnableFixture implements AutoCloseable {
+ private final MockedStatic bukkit = mockStatic(Bukkit.class);
+ private final MockedStatic iris = mockStatic(Iris.class);
+ private final MockedStatic lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
+
+ private EnableFixture(boolean irisWorlds) {
+ PluginManager plugins = mock(PluginManager.class);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
+ bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
+ lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
+ }
+
+ @Override
+ public void close() {
+ lock.close();
+ iris.close();
+ bukkit.close();
+ }
+ }
+}
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java
index bf4f7c9d3..abef86a46 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java
@@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.GenerationManifest;
+import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitWorldReconcilerRuntimeTest {
@@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest {
}
}
+ /**
+ * The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs
+ * the same banner and operator notice as one CraftServer asked for.
+ */
+ @Test
+ public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception {
+ NamespacedKey key = new NamespacedKey("iris", "probe");
+ IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe.");
+ Iris plugin = mock(Iris.class);
+ WorldRefusalReporter refusals = mock(WorldRefusalReporter.class);
+ when(plugin.worldRefusals()).thenReturn(refusals);
+ when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal);
+
+ try (MockedStatic iris = mockStatic(Iris.class);
+ MockedStatic storage = mockStatic(IrisWorldStorage.class)) {
+ storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world"));
+ storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe");
+ storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe");
+
+ CompletableFuture created = backend(plugin).createWorld(key, "overworld", 1337L);
+
+ ExecutionException failure = assertThrows(ExecutionException.class, created::get);
+ assertSame(refusal, failure.getCause());
+ verify(refusals).report("world_iris_probe", key, refusal);
+ }
+ }
+
private RuntimeFixture fixture() throws Exception {
Path container = temporaryFolder.newFolder().toPath();
Path root = container.resolve("world/dimensions/iris/probe");
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
when(dimension.getLoadKey()).thenReturn("overworld");
when(engine.getData()).thenReturn(data);
when(data.getDataFolder()).thenReturn(pack.toFile());
+ return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack);
+ }
+
+ private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception {
Class> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend");
Constructor> constructor = backendClass.getDeclaredConstructor(Iris.class);
constructor.setAccessible(true);
- BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class));
- return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
+ return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
}
private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key,
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java
new file mode 100644
index 000000000..f30538bb0
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java
@@ -0,0 +1,77 @@
+package art.arcane.iris.world;
+
+import org.bukkit.World;
+import org.bukkit.generator.ChunkGenerator;
+import org.bukkit.generator.WorldInfo;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Random;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and
+ * CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a
+ * broken Iris world from being created at all, so every fail-closed generator must keep throwing from it.
+ */
+public class IrisFailClosedChunkGeneratorTest {
+ @Test
+ public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() {
+ List generators = List.of(
+ IrisFailClosedChunkGenerator.discoveryProbe("world"),
+ IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"),
+ IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted")));
+
+ for (ChunkGenerator generator : generators) {
+ IllegalStateException refusal = assertThrows(
+ IllegalStateException.class,
+ () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
+ assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class)));
+ assertThrows(IllegalStateException.class,
+ () -> generator.getFixedSpawnLocation(mock(World.class), new Random()));
+ assertFalse(generator.shouldGenerateNoise());
+ }
+ }
+
+ @Test
+ public void refusalCarriesTheWholeCauseChain() {
+ IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
+ new IOException("Historical generated registry definition changed"));
+
+ IllegalStateException refusal = assertThrows(
+ IllegalStateException.class,
+ () -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure)
+ .getDefaultBiomeProvider(mock(WorldInfo.class)));
+
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
+ }
+
+ /**
+ * The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an
+ * unreadable cause still has to produce a generator.
+ */
+ @Test
+ public void aCauseWhoseMessageCannotBeReadStillRefuses() {
+ IllegalStateException unreadable = new IllegalStateException() {
+ @Override
+ public String getMessage() {
+ throw new UnsupportedOperationException("message unavailable");
+ }
+ };
+
+ ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable);
+
+ IllegalStateException refusal = assertThrows(
+ IllegalStateException.class,
+ () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
+ assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
+ }
+}
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java
index f7f073c44..59c2845c3 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java
@@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo;
import org.mockito.MockedStatic;
import java.io.File;
+import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic bukkit = mockStatic(Bukkit.class);
MockedStatic iris = mockStatic(Iris.class)) {
- Server server = mock(Server.class);
- when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
- bukkit.when(Bukkit::getServer).thenReturn(server);
- bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
- bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
- IllegalStateException failure = assertThrows(
- IllegalStateException.class,
- () -> new IrisWorldGeneratorResolver(null)
- .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
+ assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest {
}
@Test
- public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
+ public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("external-create");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic bukkit = mockStatic(Bukkit.class);
MockedStatic iris = mockStatic(Iris.class)) {
- Server server = mock(Server.class);
- when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
- bukkit.when(Bukkit::getServer).thenReturn(server);
- bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
- bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
- IllegalStateException failure = assertThrows(
- IllegalStateException.class,
- () -> new IrisWorldGeneratorResolver(null)
- .resolveDefaultWorldGenerator("mvtest", "overworld"));
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("mvtest", "overworld"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
+ assertTrue(refusal, refusal.contains("mvtest"));
+ assertTrue(refusal, refusal.contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
- public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
+ public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception {
File worldContainer = temporaryFolder.newFolder("owned-broken");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
@@ -415,42 +404,88 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic bukkit = mockStatic(Bukkit.class);
MockedStatic iris = mockStatic(Iris.class)) {
- Server server = mock(Server.class);
- when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
- bukkit.when(Bukkit::getServer).thenReturn(server);
- bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
- bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
- assertThrows(
- IllegalStateException.class,
- () -> new IrisWorldGeneratorResolver(null)
- .resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
- bukkit.verify(Bukkit::shutdown);
+ assertTrue(refusal, refusal.contains("world_iris_moon"));
+ bukkit.verify(Bukkit::shutdown, never());
+ }
+ }
+
+ /**
+ * The tester's failure: a world whose generation history no longer matches the live registries. CraftServer
+ * turns a throw here into the vanilla generator, so the history failure has to come back as a generator that
+ * refuses to create the level.
+ */
+ @Test
+ public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception {
+ File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile();
+ File levelRoot = new File(worldContainer, "world");
+ File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
+ assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class);
+ MockedStatic worlds = mockStatic(IrisWorlds.class)) {
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
+ worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
+
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("moon", "overworld"));
+
+ assertTrue(refusal, refusal.contains("'moon'"));
+ assertTrue(refusal, refusal.contains("generation history is unusable"));
+ bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
- public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception {
+ public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception {
+ File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile();
+ File levelRoot = new File(worldContainer, "world");
+ File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
+ assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class);
+ MockedStatic worlds = mockStatic(IrisWorlds.class)) {
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
+ worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld"));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable"));
+ bukkit.verify(Bukkit::shutdown, never());
+ iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never());
+ }
+ }
+
+ /**
+ * A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
+ * binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
+ */
+ @Test
+ public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
File levelRoot = new File(worldContainer, "world");
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
try (MockedStatic bukkit = mockStatic(Bukkit.class);
MockedStatic iris = mockStatic(Iris.class)) {
- Server server = mock(Server.class);
- when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
- bukkit.when(Bukkit::getServer).thenReturn(server);
- bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
- bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
- IllegalStateException failure = assertThrows(
- IllegalStateException.class,
- () -> new IrisWorldGeneratorResolver(null)
- .resolveDefaultWorldGenerator("world_nether", "overworld"));
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world_nether", "overworld"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
+ assertTrue(refusal, refusal.contains("minecraft:the_nether"));
+ assertTrue(refusal, refusal.contains("worlds.world_nether.generator"));
+ assertTrue(refusal, refusal.contains("bukkit.yml"));
+ assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type="));
+ assertFalse(refusal, refusal.contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@@ -468,23 +503,39 @@ public class IrisWorldGeneratorResolverTest {
try (MockedStatic bukkit = mockStatic(Bukkit.class);
MockedStatic iris = mockStatic(Iris.class)) {
- Server server = mock(Server.class);
- when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
- bukkit.when(Bukkit::getServer).thenReturn(server);
- bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
- bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded));
+ mockServer(bukkit, worldContainer, levelRoot, List.of(loaded));
- IllegalStateException failure = assertThrows(
- IllegalStateException.class,
- () -> new IrisWorldGeneratorResolver(null)
- .resolveDefaultWorldGenerator("irisworld", "overworld"));
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("irisworld", "overworld"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
- assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
+ assertTrue(refusal, refusal.contains("iris:irisworld"));
+ assertTrue(refusal, refusal.contains("world_iris_irisworld"));
bukkit.verify(Bukkit::shutdown, never());
}
}
+ private static void mockServer(MockedStatic bukkit, File worldContainer, File levelRoot, List worlds) {
+ Server server = mock(Server.class);
+ when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
+ bukkit.when(Bukkit::getServer).thenReturn(server);
+ bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
+ bukkit.when(Bukkit::getWorlds).thenReturn(worlds);
+ }
+
+ /**
+ * Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper
+ * calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created.
+ */
+ private static String assertFailsClosed(Supplier resolution) {
+ ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
+ ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback);
+ assertNotSame(vanillaFallback, selected);
+ IllegalStateException refusal = assertThrows(
+ IllegalStateException.class,
+ () -> selected.getDefaultBiomeProvider(mock(WorldInfo.class)));
+ return refusal.getMessage();
+ }
+
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java
new file mode 100644
index 000000000..7efd537a2
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java
@@ -0,0 +1,145 @@
+package art.arcane.iris.world;
+
+import art.arcane.iris.Iris;
+import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
+import org.bukkit.NamespacedKey;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.world.WorldLoadEvent;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.PluginManager;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+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;
+
+/**
+ * Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the
+ * server, a runtime create only fails its caller. The banner must not claim the wrong one.
+ */
+public class WorldRefusalReporterTest {
+ private static final NamespacedKey MOON = new NamespacedKey("iris", "moon");
+
+ @Test
+ public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() {
+ String banner = reportAndCapture(new WorldRefusalReporter());
+
+ assertTrue(banner, banner.contains("'moon'"));
+ assertTrue(banner, banner.contains("iris:moon"));
+ assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon."));
+ assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc"));
+ assertTrue(banner, banner.contains("no chunks"));
+ assertTrue(banner, banner.contains("startup stops"));
+ }
+
+ /**
+ * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
+ * A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps
+ * running when that create fails.
+ */
+ @Test
+ public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() {
+ WorldRefusalReporter reporter = new WorldRefusalReporter();
+ reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld"))));
+
+ String banner = reportAndCapture(reporter);
+
+ assertTrue(banner, banner.contains("keeps running"));
+ assertFalse(banner, banner.contains("startup stops"));
+ }
+
+ @Test
+ public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() {
+ WorldRefusalReporter reporter = new WorldRefusalReporter();
+ Plugin plugin = mock(Plugin.class);
+ PluginManager plugins = mock(PluginManager.class);
+ World overworld = world(NamespacedKey.minecraft("overworld"));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld));
+ bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
+
+ reporter.attach(plugin);
+ }
+
+ assertTrue(reporter.startupWorldsCreated());
+ verify(plugins).registerEvents(reporter, plugin);
+ String banner = reportAndCapture(reporter);
+ assertTrue(banner, banner.contains("keeps running"));
+ }
+
+ @Test
+ public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() {
+ WorldRefusalReporter reporter = new WorldRefusalReporter();
+ reportAndCapture(reporter);
+ Player operator = player(true);
+ Player member = player(false);
+
+ reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty()));
+ reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty()));
+
+ verify(operator).sendMessage(contains("iris:moon"));
+ verify(member, never()).sendMessage(anyString());
+
+ reporter.onWorldLoad(new WorldLoadEvent(world(MOON)));
+ Player later = player(true);
+ reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty()));
+ verify(later, never()).sendMessage(anyString());
+ }
+
+ @Test
+ public void aFailureWhileReportingNeverEscapes() {
+ Throwable unreadable = new IllegalStateException() {
+ @Override
+ public String getMessage() {
+ throw new UnsupportedOperationException("message unavailable");
+ }
+ };
+
+ try (MockedStatic iris = mockStatic(Iris.class)) {
+ new WorldRefusalReporter().report("moon", MOON, unreadable);
+ }
+ }
+
+ private static String reportAndCapture(WorldRefusalReporter reporter) {
+ IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
+ new IOException("Historical generated registry definition changed for iris:biomes/abc"));
+ List lines = new ArrayList<>();
+ try (MockedStatic iris = mockStatic(Iris.class)) {
+ iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> {
+ lines.add(invocation.getArgument(0, String.class));
+ return null;
+ });
+ reporter.report("moon", MOON, failure);
+ }
+ return String.join("\n", lines);
+ }
+
+ private static World world(NamespacedKey key) {
+ World world = mock(World.class);
+ when(world.getKey()).thenReturn(key);
+ when(world.getName()).thenReturn(key.getKey());
+ return world;
+ }
+
+ private static Player player(boolean operator) {
+ Player player = mock(Player.class);
+ when(player.isOp()).thenReturn(operator);
+ return player;
+ }
+}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java
index 33d77d171..f47c8f235 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java
@@ -59,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -454,11 +455,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
private Engine bindEngine(NativeWorld level) {
requireBindingAllowed();
+ // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
+ // history verification on every chunk call and fail the same way.
+ engineBinding.throwIfFailed(dimensionKey);
try {
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
} catch (RuntimeException error) {
- engineBinding.fail(error);
+ recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves.
@@ -500,7 +504,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
engineBinding.complete(created);
return created;
} catch (Throwable error) {
- engineBinding.fail(error);
+ recordBindFailure(level, error);
if (error instanceof RuntimeException runtimeException) {
throw runtimeException;
}
@@ -512,6 +516,25 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
+ private void recordBindFailure(NativeWorld level, Throwable error) {
+ engineBinding.fail(error);
+ for (String line : bindFailureNotice(level.name(), dimensionKey, error)) {
+ ModdedIrisLog.error(line);
+ }
+ }
+
+ static List bindFailureNotice(String levelName, String dimensionKey, Throwable failure) {
+ return GenerationRefusalNotice.compose(
+ "Iris refused to generate " + levelName + " (generator " + dimensionKey + ")",
+ GenerationRefusalNotice.causes(failure),
+ List.of(
+ "Iris does not generate this level and does not let vanilla or any other generator"
+ + " write it; no chunks are written.",
+ "A startup level stops the server; a runtime dimension stays unloaded."
+ + " Fix the cause, then start the server again."
+ ));
+ }
+
private void requireCompletedShutdown(Engine current) {
if (current == null || !current.isClosing() || current.isClosed()) {
return;
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java
index 0f0ab2b27..5658c9dad 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java
@@ -18,12 +18,15 @@
package art.arcane.iris.modded;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -36,6 +39,8 @@ public final class ModdedPrimaryWorldRouter {
private static final Set routed = ConcurrentHashMap.newKeySet();
private static final Set inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0;
+ private static volatile String loginRefusal;
+ private static volatile String absentPrimary;
private ModdedPrimaryWorldRouter() {
}
@@ -43,6 +48,8 @@ public final class ModdedPrimaryWorldRouter {
public static void clear() {
routed.clear();
inFlight.clear();
+ loginRefusal = null;
+ absentPrimary = null;
}
/**
@@ -56,6 +63,20 @@ public final class ModdedPrimaryWorldRouter {
}
}
+ /**
+ * Disconnects a joining player while the configured primary world is missing.
+ *
+ * @return true when the player was refused
+ */
+ public static boolean refuseIfUnavailable(NativeProtocolPlayer player) {
+ String refusal = loginRefusal;
+ if (refusal == null || player == null) {
+ return false;
+ }
+ player.disconnect(refusal);
+ return true;
+ }
+
public static void tick(NativeModdedServer server) {
if (server == null) {
return;
@@ -68,17 +89,23 @@ public final class ModdedPrimaryWorldRouter {
ModdedModConfig config = ModdedModConfig.get();
if (!config.routePlayersToPrimaryWorld()) {
+ loginRefusal = null;
return;
}
String primary = config.primaryWorld();
if (primary.isBlank()) {
+ loginRefusal = null;
return;
}
NativeWorld target = ModdedDimensionManager.level(server, primary);
if (target == null) {
+ if (ModdedStartup.dimensionsRestored()) {
+ judgeMissingPrimary(server, primary);
+ }
return;
}
+ loginRefusal = null;
NativeWorld overworld = server.overworld();
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
return;
@@ -115,4 +142,62 @@ public final class ModdedPrimaryWorldRouter {
}
});
}
+
+ /**
+ * primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists
+ * it expected it here, so only that save has a primary world that failed to load.
+ */
+ private static void judgeMissingPrimary(NativeModdedServer server, String primary) {
+ if (loginRefusal == null && primary.equals(absentPrimary)) {
+ return;
+ }
+ if (loginRefusal == null && !expectedInThisSave(server, primary)) {
+ absentPrimary = primary;
+ ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not"
+ + " exist in this save, so players are not routed. Create it here with /iris world replace-overworld"
+ + " or clear primaryWorld.");
+ return;
+ }
+ refuseLogins(server, primary);
+ }
+
+ private static boolean expectedInThisSave(NativeModdedServer server, String primary) {
+ try {
+ return ModdedDimensionRegistryStore.get(server, primary) != null;
+ } catch (RuntimeException unreadable) {
+ ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '"
+ + primary + "'", unreadable);
+ return true;
+ }
+ }
+
+ /**
+ * Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a
+ * primary world this save expected and did not load refuses every player instead.
+ */
+ private static void refuseLogins(NativeModdedServer server, String primary) {
+ String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
+ + " An operator has to check the server console.";
+ if (loginRefusal == null) {
+ for (String line : GenerationRefusalNotice.compose(
+ "Iris refused player logins: primary world '" + primary + "' is not loaded",
+ List.of("routePlayersToPrimaryWorld sends players to '" + primary
+ + "', which this save's dimension registry expects but did not load at startup."),
+ List.of(
+ "Players are disconnected instead of playing in the overworld, so no chunks are"
+ + " written there.",
+ "Fix the dimension (see the Iris errors above) or clear primaryWorld in"
+ + " config/irisworldgen/modded.json, then start the server again."
+ ))) {
+ ModdedIrisLog.error(line);
+ }
+ }
+ loginRefusal = refusal;
+ List online = new ArrayList<>();
+ // Disconnecting removes the player from the list being walked, so collect first.
+ server.forEachPlayer(online::add);
+ for (NativeProtocolPlayer player : online) {
+ player.disconnect(refusal);
+ }
+ }
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java
index 06e425ab2..f7219ff51 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java
@@ -123,11 +123,11 @@ public final class ModdedProtocolHandler {
}
public static void onPlayerJoin(NativeProtocolPlayer player) {
- if (player == null) {
+ if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
return;
}
String sessionId = sessionId(player);
- ModdedStartup.warnPackFailuresTo(player);
+ ModdedStartup.warnStartupFailuresTo(player);
IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) {
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java
index 80483b617..1f530cbbf 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java
@@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.spi.IrisPlatforms;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
@@ -36,6 +37,7 @@ import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
@@ -43,6 +45,7 @@ public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
+ private static final List DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
private ModdedStartup() {
}
@@ -50,6 +53,7 @@ public final class ModdedStartup {
public static void reset() {
PREPARED.set(false);
STARTED.set(false);
+ DIMENSION_FAILURES.clear();
}
public static void prepareForStartup() {
@@ -208,7 +212,7 @@ public final class ModdedStartup {
}
}
- private static void reinjectPersistentDimensions(NativeModdedServer server) {
+ static void reinjectPersistentDimensions(NativeModdedServer server) {
List dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
if (dimensions.isEmpty()) {
@@ -233,7 +237,7 @@ public final class ModdedStartup {
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
System.currentTimeMillis() - dimensionStartedAt);
} catch (Throwable e) {
- ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
+ reportDimensionFailure(dimension, e);
if (e instanceof OutOfMemoryError outOfMemory) {
throw outOfMemory;
}
@@ -243,6 +247,25 @@ public final class ModdedStartup {
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
}
+ private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
+ List notice = GenerationRefusalNotice.compose(
+ "Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack()
+ + " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")",
+ GenerationRefusalNotice.causes(failure),
+ List.of(
+ "Iris does not generate this dimension and does not let vanilla or any other generator"
+ + " write it; no chunks are written.",
+ "It stays unloaded. Fix the cause, then start the server again."
+ ));
+ for (String line : notice) {
+ ModdedIrisLog.error(line);
+ }
+ ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})",
+ dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure);
+ DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: "
+ + GenerationRefusalNotice.summary(failure));
+ }
+
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream walk = Files.walk(root)) {
@@ -261,13 +284,24 @@ public final class ModdedStartup {
}
/**
- * SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
- * can actually act on it when they join.
+ * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
+ * dimension that is not loaded yet is still on its way.
*/
- public static void warnPackFailuresTo(NativeProtocolPlayer player) {
+ public static boolean dimensionsRestored() {
+ return STARTED.get();
+ }
+
+ /**
+ * SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in
+ * the console. Tell the operators who can actually act on it when they join.
+ */
+ public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) {
return;
}
+ for (String failure : DIMENSION_FAILURES) {
+ player.sendMessage(failure);
+ }
for (Map.Entry entry : PackValidationRegistry.snapshot().entrySet()) {
PackValidationResult result = entry.getValue();
if (result == null || result.isLoadable()) {
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java
new file mode 100644
index 000000000..1a4ca2ccf
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java
@@ -0,0 +1,105 @@
+package art.arcane.iris.modded;
+
+import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.terrain.NativeWorld;
+import net.minecraft.SharedConstants;
+import net.minecraft.server.Bootstrap;
+import net.minecraft.server.level.ServerChunkCache;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.level.chunk.ChunkGenerator;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+/**
+ * A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator,
+ * and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind
+ * (history verification included) on every worker thread.
+ */
+public class ModdedBindFailureTest {
+ @BeforeClass
+ public static void bootstrap() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ }
+
+ @Test
+ public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception {
+ IrisModdedChunkGenerator generator = generator("overworld:overworld");
+ NativeWorld level = level(generator, "minecraft:overworld");
+ field("boundLevel").set(generator, level);
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ when(server.generateStructures()).thenReturn(false);
+
+ try (MockedStatic servers = mockStatic(NativeModdedServer.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
+
+ IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
+ IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
+ IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
+
+ assertSame(recorded, second.getCause());
+ assertSame(recorded, third.getCause());
+ servers.verify(() -> NativeModdedServer.forWorld(level), times(1));
+ log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
+ && line.contains("generate-structures=false"))), times(1));
+ }
+ }
+
+ @Test
+ public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() {
+ String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice(
+ "minecraft:overworld",
+ "overworld:overworld",
+ new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'",
+ new IOException("Historical generated registry definition changed"))));
+
+ assertTrue(notice, notice.contains("minecraft:overworld"));
+ assertTrue(notice, notice.contains("overworld:overworld"));
+ assertTrue(notice, notice.contains("generation history is unusable"));
+ assertTrue(notice, notice.contains("registry definition changed"));
+ assertTrue(notice, notice.contains("no chunks"));
+ }
+
+ private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException {
+ IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
+ field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS));
+ field("engineBinding").set(generator, new ModdedEngineBinding(1L, TimeUnit.SECONDS));
+ field("dimensionKey").set(generator, dimensionKey);
+ return generator;
+ }
+
+ private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException {
+ NativeWorld level = mock(NativeWorld.class);
+ ServerLevel handle = mock(ServerLevel.class);
+ ServerChunkCache chunkSource = mock(ServerChunkCache.class);
+ when(level.nativeHandle()).thenReturn(handle);
+ when(level.name()).thenReturn(name);
+ when(handle.getChunkSource()).thenReturn(chunkSource);
+ when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator));
+ return level;
+ }
+
+ private static Field field(String name) throws NoSuchFieldException {
+ Field field = IrisModdedChunkGenerator.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return field;
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java
new file mode 100644
index 000000000..03ec17cfc
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java
@@ -0,0 +1,209 @@
+package art.arcane.iris.modded;
+
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
+import art.arcane.volmlib.nativelib.terrain.NativeWorld;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import java.util.function.Consumer;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.doAnswer;
+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;
+
+/**
+ * A configured primary Iris world that did not load used to be skipped silently, leaving every player in the
+ * overworld generating terrain Iris was configured to replace.
+ */
+public class ModdedPrimaryWorldRouterTest {
+ private static final int TICK_INTERVAL = 20;
+ private static final String PRIMARY = "iris:primary";
+
+ @After
+ public void clearRouter() {
+ ModdedPrimaryWorldRouter.clear();
+ }
+
+ @Test
+ public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
+ onlinePlayers(server, online);
+ ModdedModConfig routed = config(true, PRIMARY);
+
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class);
+ MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
+ registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
+ startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
+
+ evaluate(server);
+ evaluate(server);
+
+ verify(online, times(2)).disconnect(contains(PRIMARY));
+ log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
+ && line.contains(PRIMARY))), times(1));
+ NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class);
+ assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining));
+ verify(joining).disconnect(contains(PRIMARY));
+ }
+ }
+
+ /**
+ * primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another
+ * save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused.
+ */
+ @Test
+ public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
+ onlinePlayers(server, online);
+ ModdedModConfig routed = config(true, PRIMARY);
+
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class);
+ MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
+ registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null);
+ startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
+
+ evaluate(server);
+ evaluate(server);
+
+ verify(online, never()).disconnect(anyString());
+ assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+ log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1));
+ log.verify(() -> ModdedIrisLog.error(anyString()), never());
+ }
+ }
+
+ /**
+ * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
+ * missing then is still loading, not broken.
+ */
+ @Test
+ public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
+ onlinePlayers(server, online);
+ ModdedModConfig routed = config(true, PRIMARY);
+
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
+ startup.when(ModdedStartup::dimensionsRestored).thenReturn(false);
+
+ evaluate(server);
+
+ verify(online, never()).disconnect(anyString());
+ assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+ }
+ }
+
+ @Test
+ public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeWorld overworld = mock(NativeWorld.class);
+ when(server.overworld()).thenReturn(overworld);
+ onlinePlayers(server, mock(NativeProtocolPlayer.class));
+ ModdedModConfig routed = config(true, PRIMARY);
+ ModdedModConfig unconfigured = config(true, "");
+
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class);
+ MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
+ startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ evaluate(server);
+ assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+
+ configs.when(ModdedModConfig::get).thenReturn(unconfigured);
+ evaluate(server);
+ assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ evaluate(server);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld);
+ evaluate(server);
+ assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+ }
+ }
+
+ /**
+ * A registry that cannot be read cannot prove the primary world was never here, so it counts as expected.
+ */
+ @Test
+ public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
+ onlinePlayers(server, online);
+ ModdedModConfig routed = config(true, PRIMARY);
+
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class);
+ MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
+ registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY))
+ .thenThrow(new IllegalStateException("registry could not be read"));
+ startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
+
+ evaluate(server);
+
+ verify(online).disconnect(contains(PRIMARY));
+ assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
+ }
+ }
+
+ private static ModdedDimensionRegistryStore.PersistentDimension persisted() {
+ return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
+ }
+
+ private static void evaluate(NativeModdedServer server) {
+ for (int tick = 0; tick < TICK_INTERVAL; tick++) {
+ ModdedPrimaryWorldRouter.tick(server);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) {
+ doAnswer(invocation -> {
+ invocation.getArgument(0, Consumer.class).accept(player);
+ return null;
+ }).when(server).forEachPlayer(any());
+ }
+
+ private static ModdedModConfig config(boolean route, String primary) {
+ ModdedModConfig config = mock(ModdedModConfig.class);
+ when(config.routePlayersToPrimaryWorld()).thenReturn(route);
+ when(config.primaryWorld()).thenReturn(primary);
+ return config;
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java
new file mode 100644
index 000000000..15dc419a4
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java
@@ -0,0 +1,71 @@
+package art.arcane.iris.modded;
+
+import art.arcane.iris.pack.PackValidationRegistry;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+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;
+
+/**
+ * A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be
+ * one console line; it has to be a banner and has to reach the operators who can fix it.
+ */
+public class ModdedStartupReinjectFailureTest {
+ @After
+ public void resetStartup() {
+ ModdedStartup.reset();
+ PackValidationRegistry.clear();
+ }
+
+ @Test
+ public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ ModdedDimensionRegistryStore.PersistentDimension moon =
+ new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L);
+ IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'");
+
+ try (MockedStatic store = mockStatic(ModdedDimensionRegistryStore.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon));
+ manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L))
+ .thenThrow(failure);
+
+ ModdedStartup.reinjectPersistentDimensions(server);
+
+ log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon")
+ && line.startsWith("Iris refused"))), times(1));
+ log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
+ && line.contains("generation history is unusable"))), times(1));
+ }
+
+ NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class);
+ when(operator.isGameMaster()).thenReturn(true);
+ NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
+ ModdedStartup.warnStartupFailuresTo(operator);
+ ModdedStartup.warnStartupFailuresTo(player);
+
+ verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
+ && message.contains("generation history is unusable")));
+ verify(player, never()).sendMessage(anyString());
+
+ ModdedStartup.reset();
+ NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class);
+ when(laterOperator.isGameMaster()).thenReturn(true);
+ ModdedStartup.warnStartupFailuresTo(laterOperator);
+ verify(laterOperator, never()).sendMessage(any());
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java
index 25196cae8..a34f8feda 100644
--- a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java
+++ b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java
@@ -372,8 +372,7 @@ public final class BukkitWorldConfiguration {
+ "\" is not a string.");
}
String configuredGenerator = generator.trim();
- if (!configuredGenerator.equalsIgnoreCase("Iris")
- && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
+ if (!isIrisGenerator(configuredGenerator)) {
continue;
}
@@ -395,6 +394,35 @@ public final class BukkitWorldConfiguration {
return candidates;
}
+ /**
+ * True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin
+ * for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
+ */
+ public static boolean configuresIrisGenerator(File configurationFile) throws IOException {
+ Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
+ if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ synchronized (MUTATION_LOCK) {
+ ConfigurationSection worlds = load(configurationFile).getConfigurationSection("worlds");
+ if (worlds == null) {
+ return false;
+ }
+ for (String worldName : worlds.getKeys(false)) {
+ if (worlds.get(worldName + ".generator") instanceof String generator
+ && isIrisGenerator(generator.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private static boolean isIrisGenerator(String configuredGenerator) {
+ return configuredGenerator.equalsIgnoreCase("Iris")
+ || configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
+ }
+
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java
index 36d3557e4..173b53e2d 100644
--- a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java
+++ b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java
@@ -61,7 +61,7 @@ public final class MissingWorldStorageLog {
}
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),
+ + " data; Iris refuses to generate it and no other generator gets it.", worldName, worldStorage),
IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
worldName)
});
diff --git a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java
new file mode 100644
index 000000000..d182d42f1
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java
@@ -0,0 +1,58 @@
+package art.arcane.iris.world.safeguard;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The framed console block Iris prints when it refuses to generate a world. Every platform prints the same shape
+ * so a refusal is found the same way in any server log.
+ */
+public final class GenerationRefusalNotice {
+ private static final String RULE = "=".repeat(78);
+
+ private GenerationRefusalNotice() {
+ }
+
+ public static List compose(String headline, List causes, List consequences) {
+ List lines = new ArrayList<>(causes.size() + consequences.size() + 3);
+ lines.add(RULE);
+ lines.add(Objects.requireNonNull(headline, "headline"));
+ for (int index = 0; index < causes.size(); index++) {
+ lines.add((index == 0 ? "Cause: " : "Caused by: ") + causes.get(index));
+ }
+ lines.addAll(consequences);
+ lines.add(RULE);
+ return List.copyOf(lines);
+ }
+
+ /**
+ * Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause
+ * (CompletionException, a rethrow with the same text) adds nothing and is skipped.
+ */
+ public static List causes(Throwable failure) {
+ List causes = new ArrayList<>();
+ Set seen = Collections.newSetFromMap(new IdentityHashMap<>());
+ Throwable current = failure;
+ while (current != null && seen.add(current)) {
+ Throwable cause = current.getCause();
+ String message = current.getMessage();
+ boolean repeatsCause = cause != null && cause.toString().equals(message);
+ if (!repeatsCause) {
+ String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim();
+ if (causes.isEmpty() || !causes.getLast().equals(text)) {
+ causes.add(text);
+ }
+ }
+ current = cause;
+ }
+ return List.copyOf(causes);
+ }
+
+ public static String summary(Throwable failure) {
+ return String.join("; ", causes(failure));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java
index f28dc9715..31fc03985 100644
--- a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java
+++ b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java
@@ -53,6 +53,33 @@ public class BukkitWorldConfigurationTest {
)), bindings);
}
+ /**
+ * CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla
+ * slot included - is one a disabled Iris would hand to the vanilla generator.
+ */
+ @Test
+ public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception {
+ File overworld = temporaryFolder.newFile("iris-overworld.yml");
+ YamlConfiguration yaml = new YamlConfiguration();
+ yaml.set("worlds.world.generator", "iris:overworld");
+ yaml.set("worlds.world_nether.generator", "Other:generator");
+ yaml.save(overworld);
+ File bare = temporaryFolder.newFile("iris-bare.yml");
+ yaml = new YamlConfiguration();
+ yaml.set("worlds.world_the_end.generator", "Iris");
+ yaml.save(bare);
+ File other = temporaryFolder.newFile("other-only.yml");
+ yaml = new YamlConfiguration();
+ yaml.set("worlds.world.generator", "Irish:overworld");
+ yaml.set("worlds.world_nether.seed", 1337L);
+ yaml.save(other);
+
+ assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld));
+ assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare));
+ assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other));
+ assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml")));
+ }
+
@Test
public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception {
File configuration = temporaryFolder.newFile("missing-binding-dimension.yml");
diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java
new file mode 100644
index 000000000..fed73554b
--- /dev/null
+++ b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java
@@ -0,0 +1,68 @@
+package art.arcane.iris.world.safeguard;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.CompletionException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+public class GenerationRefusalNoticeTest {
+ @Test
+ public void causesListEveryDistinctMessageOutermostFirst() {
+ IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
+ new IOException("Historical generated registry definition changed",
+ new IllegalArgumentException()));
+
+ assertEquals(List.of(
+ "Iris generation history is unusable at /srv/moon.",
+ "Historical generated registry definition changed",
+ "IllegalArgumentException"
+ ), GenerationRefusalNotice.causes(failure));
+ }
+
+ @Test
+ public void wrappersThatOnlyRepeatTheirCauseAreSkipped() {
+ IllegalStateException root = new IllegalStateException("history drifted");
+ CompletionException wrapper = new CompletionException(root);
+
+ assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(wrapper));
+ assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(
+ new IllegalStateException("history drifted", root)));
+ }
+
+ @Test
+ public void aCyclicCauseChainTerminates() {
+ IllegalStateException first = new IllegalStateException("first");
+ IllegalStateException second = new IllegalStateException("second", first);
+ first.initCause(second);
+
+ assertEquals(List.of("first", "second"), GenerationRefusalNotice.causes(first));
+ }
+
+ @Test
+ public void summaryJoinsTheCausesOnOneLine() {
+ assertEquals("outer; inner", GenerationRefusalNotice.summary(
+ new IllegalStateException("outer", new IOException("inner"))));
+ }
+
+ @Test
+ public void noticeIsFramedAndNamesCauseChainAndConsequences() {
+ List lines = GenerationRefusalNotice.compose(
+ "Iris refused to generate world 'moon' (iris:moon)",
+ List.of("outer", "inner"),
+ List.of("No chunks are written to it.", "Server startup stops before this world loads."));
+
+ assertEquals(lines.getFirst(), lines.getLast());
+ assertTrue(lines.getFirst(), lines.getFirst().chars().allMatch(character -> character == '='));
+ assertEquals(List.of(
+ "Iris refused to generate world 'moon' (iris:moon)",
+ "Cause: outer",
+ "Caused by: inner",
+ "No chunks are written to it.",
+ "Server startup stops before this world loads."
+ ), lines.subList(1, lines.size() - 1));
+ }
+}
diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java
index ebf7bc19f..0838427e8 100644
--- a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java
+++ b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java
@@ -10,8 +10,8 @@ import static org.junit.Assert.assertTrue;
/**
* A boot that ends in Danger Mode used to say so only in the banner. The server then ran on with every
* configured Iris world bound to a generator that throws on the first chunk, and nothing after the banner
- * said that out loud. {@code refuseVanillaFallback} never covers this case because it only runs when enable
- * itself fails.
+ * said that out loud. The failed-enable lock never covers this case because it only runs when enable itself
+ * fails.
*/
public class RuntimeLockNoticeTest {
@Test