diff --git a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java
index 59e4d1374..942228c58 100644
--- a/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java
+++ b/adapters/bukkit/nms/v26_3_R1/src/offline/java/art/arcane/iris/probe/HeadlessNativePlatform.java
@@ -15,7 +15,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockResolver;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeGenerationRegistryImpl;
-import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
import art.arcane.volmlib.nativelib.terrain.NativeBlockProperty;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
@@ -49,7 +48,7 @@ final class HeadlessNativePlatform implements IrisPlatform, PlatformRegistries,
return access;
}
throw new IllegalStateException("Generation registry contract requires loaded native registries");
- }), "bukkit-generation-registry-v1", new NativeWorldGenerationImpl().generationRendererIdentity());
+ }), "bukkit-generation-registry-v1");
nativeAccess = new NativeRegistryAccess(new NativeRegistryAccess.Configuration(registries,
() -> { throw new UnsupportedOperationException("Headless reloadable registries are not loaded"); },
message -> { throw new IllegalStateException("Unavailable native registry: " + message); }));
diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java
new file mode 100644
index 000000000..2adc1b3e7
--- /dev/null
+++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/BareMobSpawnHistoryTest.java
@@ -0,0 +1,156 @@
+package art.arcane.iris.probe;
+
+import art.arcane.iris.generation.biome.IrisBiomeCustom;
+import art.arcane.iris.generation.biome.IrisBiomeCustomSpawn;
+import art.arcane.iris.generation.biome.IrisBiomeCustomSpawnType;
+import art.arcane.iris.pack.datapack.v263.DataFixerV263;
+import art.arcane.iris.spi.PlatformGenerationRegistry;
+import art.arcane.iris.world.history.GenerationRegistryContract;
+import art.arcane.iris.world.history.GenerationRegistryContractFactory;
+import art.arcane.volmlib.util.collection.KList;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.bukkit.Bukkit;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+public final class BareMobSpawnHistoryTest {
+ private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
+ private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
+ private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
+ new GenerationRegistryContract.PhysicalResourceKey(
+ GenerationRegistryContractFactory.BIOME_REGISTRY,
+ "iris:biomes/" + "c".repeat(64)
+ );
+ private static HeadlessNativeRegistries loaded;
+ private static PlatformGenerationRegistry registry;
+
+ @BeforeClass
+ public static void installOverlayRendering() throws IOException {
+ assertNull(Bukkit.getServer());
+ loaded = HeadlessNativeRegistries.load(Map.of(
+ "data/iris/worldgen/biome/biomes/" + "c".repeat(64) + ".json",
+ spawningBiome().generateJson(new DataFixerV263())
+ ));
+ registry = new HeadlessNativePlatform(new File("unused-native-platform"), loaded::registries)
+ .generationRegistry();
+ }
+
+ @AfterClass
+ public static void closeRegistries() throws IOException {
+ if (loaded != null) {
+ loaded.close();
+ }
+ }
+
+ @Test
+ public void bareMobSpawnHistoryValidatesAgainstTheInstalledOverlayRendering() throws IOException {
+ GenerationRegistryContract legacy = legacyContract(INSTALLED, spawningBiome());
+ GenerationRegistryContract.GeneratedSource source = legacy.generatedSources().get(INSTALLED);
+ assertEquals(GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE, legacy.fingerprintSchema());
+ assertEquals(LEGACY_RENDERER_IDENTITY, source.rendererIdentity());
+ assertNotEquals(
+ JsonParser.parseString(source.sourceJson()),
+ JsonParser.parseString(registry.generatedDefinition(
+ INSTALLED.registryKey(),
+ INSTALLED.resourceKey()
+ ).value())
+ );
+
+ GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
+ Set.of(legacy),
+ new DataFixerV263(),
+ registry
+ );
+
+ legacy.requireDefinitionsAvailableIn(available);
+ }
+
+ @Test
+ public void bareMobSpawnHistoryStillRejectsDifferentSemantics() throws IOException {
+ GenerationRegistryContract changed = legacyContract(INSTALLED, spawningBiome().setTemperature(0.2D));
+
+ IOException failure = assertThrows(IOException.class,
+ () -> GenerationRegistryContractFactory.captureRequiredDefinitions(
+ Set.of(changed),
+ new DataFixerV263(),
+ registry
+ ));
+
+ assertEquals("Historical generated registry definition changed for minecraft:worldgen/biome / "
+ + INSTALLED.resourceKey() + ".", failure.getMessage());
+ }
+
+ @Test
+ public void bareMobSpawnHistoryStillRejectsAMissingDefinition() throws IOException {
+ GenerationRegistryContract missing = legacyContract(new GenerationRegistryContract.PhysicalResourceKey(
+ GenerationRegistryContractFactory.BIOME_REGISTRY,
+ "iris:biomes/" + "d".repeat(64)
+ ), spawningBiome());
+
+ assertThrows(IOException.class, () -> GenerationRegistryContractFactory.captureRequiredDefinitions(
+ Set.of(missing),
+ new DataFixerV263(),
+ registry
+ ));
+ }
+
+ private static GenerationRegistryContract legacyContract(
+ GenerationRegistryContract.PhysicalResourceKey key,
+ IrisBiomeCustom biome
+ ) {
+ JsonObject rendered = JsonParser.parseString(biome.generateJson(new DataFixerV263())).getAsJsonObject();
+ JsonObject attributes = rendered.getAsJsonObject("attributes");
+ attributes.add(NATURAL_MOB_SPAWNS, attributes.getAsJsonObject(NATURAL_MOB_SPAWNS).get("argument"));
+ String captured = registry.canonicalDefinition(key.registryKey(), key.resourceKey(), rendered.toString())
+ .value();
+ String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(biome, null);
+ GenerationRegistryContract.GeneratedSource source = new GenerationRegistryContract.GeneratedSource(
+ GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
+ GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(semantic),
+ semantic,
+ LEGACY_RENDERER_IDENTITY,
+ GenerationRegistryContractFactory.fingerprintDefinition(
+ key,
+ PlatformGenerationRegistry.Definition.exactJson(captured),
+ "generated"
+ ),
+ captured
+ );
+ return GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
+ Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, source)),
+ Map.of(key, source)
+ );
+ }
+
+ private static IrisBiomeCustom spawningBiome() {
+ return new IrisBiomeCustom()
+ .setId("tester")
+ .setSpawnRarity(2)
+ .setSpawns(new KList<>(
+ new IrisBiomeCustomSpawn()
+ .setType("minecraft:zombie")
+ .setGroup(IrisBiomeCustomSpawnType.MONSTER)
+ .setMinCount(2)
+ .setMaxCount(4)
+ .setWeight(7),
+ new IrisBiomeCustomSpawn()
+ .setType("minecraft:cow")
+ .setGroup(IrisBiomeCustomSpawnType.CREATURE)
+ .setMinCount(3)
+ .setMaxCount(3)
+ .setWeight(5)
+ ));
+ }
+}
diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java
index 5ee4ddab4..e8ecedcb7 100644
--- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java
+++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/probe/HeadlessGenerationRegistryTest.java
@@ -1,7 +1,6 @@
package art.arcane.iris.probe;
import art.arcane.iris.spi.PlatformGenerationRegistry;
-import art.arcane.volmlib.nativelib.v26_3_R1.terrain.NativeWorldGenerationImpl;
import net.minecraft.data.registries.VanillaRegistries;
import org.bukkit.Bukkit;
import org.junit.Test;
@@ -22,8 +21,6 @@ public final class HeadlessGenerationRegistryTest {
HeadlessNativeTestRegistries::get);
PlatformGenerationRegistry registry = platform.generationRegistry();
assertEquals("bukkit-generation-registry-v1", registry.runtimeIdentity());
- assertEquals(new NativeWorldGenerationImpl().generationRendererIdentity(),
- registry.generatedDefinitionRendererIdentity());
assertEquals(registry.canonicalDefinition("minecraft:worldgen/biome", "iris:native_test", biome),
registry.generatedDefinition("minecraft:worldgen/biome", "iris:native_test"));
assertEquals(registry.canonicalDefinition("minecraft:dimension_type", "iris:native_test", dimension),
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..426ac062d
--- /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.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
+ }
+}
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..642b0d235 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
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
import io.papermc.paper.ServerBuildInfo;
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
import art.arcane.iris.platform.bootstrap.SlimJar;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -112,16 +114,18 @@ 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. An Iris world in a
+ * vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
+ * with the vanilla generator without ever asking Iris.
*
* 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();
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
levelRoot
);
StringBuilder unusable = new StringBuilder();
+ StringBuilder unbound = new StringBuilder();
for (IrisWorldStorageEntry entry : entries) {
switch (entry.state()) {
case PRESENT -> {
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
.append(": ")
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
.append(')');
+ case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
+ .append(entry.configuredWorldName())
+ .append(" (")
+ .append(entry.storagePath())
+ .append("): set worlds.")
+ .append(entry.configuredWorldName())
+ .append(".generator to Iris: in bukkit.yml, or move the folder aside to start a new"
+ + " vanilla world there");
}
}
- if (unusable.isEmpty()) {
- return;
+ List refusals = new ArrayList<>(2);
+ if (!unusable.isEmpty()) {
+ refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
+ + unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
+ + " folder is reported and skipped, and the server starts.");
+ }
+ if (!unbound.isEmpty()) {
+ refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
+ + " terrain into them: " + unbound + ".");
+ }
+ if (!refusals.isEmpty()) {
+ throw new IllegalStateException(String.join(" ", refusals));
}
- throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
- + " terrain over it: " + unusable
- + ". Restore each world folder from a backup, or move it aside; a world with no folder is"
- + " reported and skipped, and the server starts.");
}
static void armStartupFailure(BootstrapContext context, Throwable failure) {
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
context.getLifecycleManager().registerEventHandler(eventType, event -> {
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
});
+ List notice = startupRefusalNotice(failure);
try {
- context.getLogger().error(
- "Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
- failure
- );
+ for (String line : notice) {
+ context.getLogger().error(line);
+ }
+ context.getLogger().warn("Iris bootstrap failure", failure);
} catch (Throwable loggingFailure) {
+ notice.forEach(System.err::println);
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
}
+ static List startupRefusalNotice(Throwable failure) {
+ return GenerationRefusalNotice.compose(
+ "Iris stopped server startup before any world loads",
+ GenerationRefusalNotice.causes(failure),
+ List.of(
+ "Startup stops at datapack discovery: no world loads and no chunks are written.",
+ "Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
+ + " Iris worlds must not load without Iris."
+ ));
+ }
+
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
try {
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java
index f3c15c99b..1c09dfd01 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java
@@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor {
return;
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
- IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
+ // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
+ // system failure, so it starts only once no player is left to request one.
+ AtomicBoolean maintenanceEntered = new AtomicBoolean(false);
try {
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture sequence = IrisToolbelt.evacuateAsync(world)
@@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor {
if (!Boolean.TRUE.equals(evacuated)) {
return CompletableFuture.completedFuture(false);
}
+ IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
+ maintenanceEntered.set(true);
return WorldLifecycleService.get().unloadAsync(world, true);
})
.thenCompose(unloaded -> {
@@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor {
});
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
.whenComplete((unloaded, throwable) -> {
- IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
+ if (maintenanceEntered.get()) {
+ IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
+ }
lease.close();
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
@@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor {
J.s(response);
});
} catch (Exception e) {
- IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
+ if (maintenanceEntered.get()) {
+ IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
+ }
lease.close();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
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..9fe77c6a3 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
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
+import java.util.stream.Stream;
/**
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
@@ -90,6 +91,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 +482,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 +523,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,12 +622,56 @@ public final class IrisWorldGeneratorResolver {
return;
}
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
+ if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
+ if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
+ throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
+ + " generation history is missing from "
+ + GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
+ + ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
+ + ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
+ }
+ // 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"
+ + leftoverLevelDataRemoval(levelRoot)
+ + ", 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)
+ " type=; Iris registers them with Multiverse itself.");
}
+ /**
+ * A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
+ * from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
+ */
+ private static String leftoverLevelDataRemoval(File levelRoot) {
+ Path level = levelRoot.toPath();
+ Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
+ if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
+ LinkOption.NOFOLLOW_LINKS)
+ || hasEntries(overworld.resolve("region"))
+ || hasEntries(level.resolve("region"))) {
+ return "";
+ }
+ return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
+ }
+
+ private static boolean hasEntries(Path directory) {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ try (Stream entries = Files.list(directory)) {
+ return entries.findAny().isPresent();
+ } catch (IOException unreadable) {
+ return true;
+ }
+ }
+
private static boolean hasGenerationStorage(File dimensionRoot) {
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
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/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java
index c1559165a..8933cdc93 100644
--- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java
+++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/runtime/BukkitEnginePlatformHooks.java
@@ -189,7 +189,7 @@ public final class BukkitEnginePlatformHooks implements EnginePlatformHooks {
}
@Override
- public boolean shouldBypassMantleStages(Engine engine) {
+ public boolean shouldRefuseGeneration(Engine engine) {
if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) {
return false;
}
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..25eaf2d03
--- /dev/null
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java
@@ -0,0 +1,244 @@
+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 irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() 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.removed_iris_world.generator", "Iris:overworld");
+ yaml.save(bukkit);
+ assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
+
+ 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/IrisBootstrapFailureContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java
index ea0e6a038..8e58e530c 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.IOException;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisBootstrapFailureContractTest {
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
assertSame(cause, failure.getCause());
}
+ @Test
+ public void bootstrapFailureIsReportedWithTheRefusalBanner() {
+ BootstrapContext context = mock(BootstrapContext.class);
+ ComponentLogger logger = mock(ComponentLogger.class);
+ when(context.getLogger()).thenReturn(logger);
+ when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
+ IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
+ new IOException("Invalid generation manifest: manifest.json"));
+
+ IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
+
+ ArgumentCaptor lines = ArgumentCaptor.forClass(String.class);
+ verify(logger, atLeastOnce()).error(lines.capture());
+ String banner = String.join("\n", lines.getAllValues());
+ assertTrue(banner, banner.startsWith("=".repeat(78)));
+ assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
+ assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
+ assertTrue(banner, banner.contains("no chunks are written"));
+ assertTrue(banner, banner.contains("--safeMode"));
+ }
+
@SuppressWarnings("unchecked")
private static LifecycleEventType lifecycleEventType() {
return (LifecycleEventType) mock(LifecycleEventType.class);
diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java
index 101e43b1d..78919ab63 100644
--- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java
+++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
}
+ /**
+ * A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
+ * history stops startup before the level exists instead of reaching the level-load refusal.
+ */
+ @Test
+ public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
+ Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
+ Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world");
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+
+ assertTrue(failure.getMessage(),
+ failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
+ .toString()));
+ assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
+ assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
+ }
+
+ @Test
+ public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
+ Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
+ Files.createDirectories(generation);
+ Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot, "world");
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
+ assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
+ }
+
+ /**
+ * Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
+ * which writes vanilla terrain into it.
+ */
+ @Test
+ public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot);
+
+ IllegalStateException failure = assertThrows(
+ IllegalStateException.class,
+ () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
+
+ assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
+ assertTrue(failure.getMessage(),
+ failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
+ .toString()));
+ }
+
+ @Test
+ public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
+ Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
+ Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
+ Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
+ Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
+ Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
+ writeServerProperties(serverRoot);
+ writeBukkitWorlds(serverRoot);
+
+ IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
+ }
+
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
}
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..c945dcbbe 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,46 +404,162 @@ 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());
}
}
+ /**
+ * The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is
+ * gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it.
+ */
+ @Test
+ public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception {
+ File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot");
+ File levelRoot = new File(worldContainer, "world");
+ File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld");
+ assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs());
+ assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs());
+ assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile());
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class)) {
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
+
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world", "overworld"));
+
+ assertTrue(refusal, refusal.contains("minecraft:overworld"));
+ assertTrue(refusal, refusal.contains("is an Iris world"));
+ assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath()));
+ assertFalse(refusal, refusal.contains("Remove worlds.world.generator"));
+ assertFalse(refusal, refusal.contains("/iris replace"));
+ }
+ }
+
+ /**
+ * A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a
+ * level.dat that has no world generation settings next to it.
+ */
+ @Test
+ public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception {
+ File worldContainer = temporaryFolder.newFolder("never-created");
+ File levelRoot = new File(worldContainer, "world");
+ assertTrue(levelRoot.mkdirs());
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class)) {
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
+
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world", "overworld"));
+
+ assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
+ assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath()));
+ }
+ }
+
+ @Test
+ public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception {
+ File worldContainer = temporaryFolder.newFolder("existing-vanilla");
+ File levelRoot = new File(worldContainer, "world");
+ File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat");
+ assertTrue(settings.getParentFile().mkdirs());
+ assertTrue(settings.createNewFile());
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class);
+ MockedStatic iris = mockStatic(Iris.class)) {
+ mockServer(bukkit, worldContainer, levelRoot, List.of());
+
+ String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
+ .resolveDefaultWorldGenerator("world", "overworld"));
+
+ assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
+ assertFalse(refusal, refusal.contains("level.dat"));
+ }
+ }
+
@Test
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
File worldContainer = temporaryFolder.newFolder("duplicate-key");
@@ -468,23 +573,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/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java
index ed8a29270..2357ced88 100644
--- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java
+++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java
@@ -17,6 +17,7 @@ public final class IrisFabricBootstrap implements ModInitializer {
public void onInitialize() {
NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions(
"irisworldgen", "treefeller", ModdedTreeFellerService::runBreakProbe));
+ loader.captureOriginalBiomes();
NativeChunkGeneratorDefinition generator = IrisModdedChunkGenerator.DEFINITION;
NativeFabricPackSources.bind(ModdedForcedDatapack::repositorySource);
ModdedEngineBootstrap.bootCommon(loader, "Fabric", () -> NativeFabricBootstrap.registerGenerator(generator));
diff --git a/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java b/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java
new file mode 100644
index 000000000..85fd92a73
--- /dev/null
+++ b/adapters/fabric/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/fabric/NativeFabricBiomeDefinitionTest.java
@@ -0,0 +1,108 @@
+package art.arcane.volmlib.nativelib.minecraft26_2.fabric;
+
+import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
+import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
+import com.google.gson.JsonParser;
+import com.mojang.serialization.JsonOps;
+import com.mojang.serialization.Lifecycle;
+import net.minecraft.SharedConstants;
+import net.minecraft.core.Holder;
+import net.minecraft.core.HolderSet;
+import net.minecraft.core.MappedRegistry;
+import net.minecraft.core.RegistrationInfo;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.Identifier;
+import net.minecraft.resources.RegistryOps;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.Bootstrap;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.BiomeGenerationSettings;
+import net.minecraft.world.level.levelgen.GenerationStep;
+import net.minecraft.world.level.levelgen.carver.WorldCarver;
+import net.minecraft.world.level.levelgen.feature.Feature;
+import net.minecraft.world.level.levelgen.placement.PlacedFeature;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+public class NativeFabricBiomeDefinitionTest {
+ private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
+ private static final String BIOME_KEY = "iris:biomes/fabric_modified";
+ private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
+ + "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
+
+ @BeforeClass
+ public static void bootstrap() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ }
+
+ @Test
+ public void generatedBiomeDefinitionExcludesFabricBiomeModifications() throws Exception {
+ MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
+ Holder.Reference ore = features.register(
+ ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
+ new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
+ RegistrationInfo.BUILT_IN);
+ features.freeze();
+ MappedRegistry carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
+ carvers.freeze();
+ MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
+ RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
+ Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
+ JsonParser.parseString(SOURCE_JSON)).getOrThrow();
+ NativeFabricLoader loader = new NativeFabricLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
+ BooleanSupplier::getAsBoolean));
+ loader.recordOriginalBiome(source);
+ Holder.Reference biome = biomes.register(
+ ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
+ biomes.freeze();
+ addFeatureInPlace(biome.value().getGenerationSettings(), ore);
+ warmInPlace(biome.value());
+ NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access, loader::unmodifiedBiome);
+
+ assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
+ assertTrue(biome.value().getGenerationSettings().features()
+ .get(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()).contains(ore));
+ assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
+ JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
+ }
+
+ /**
+ * What a Fabric biome modification that adds an ore does: it replaces the feature list of the registered biome's
+ * own generation settings.
+ */
+ private static void addFeatureInPlace(BiomeGenerationSettings settings, Holder feature)
+ throws ReflectiveOperationException {
+ List> steps = new ArrayList<>(settings.features());
+ while (steps.size() <= GenerationStep.Decoration.UNDERGROUND_ORES.ordinal()) {
+ steps.add(HolderSet.direct());
+ }
+ steps.set(GenerationStep.Decoration.UNDERGROUND_ORES.ordinal(), HolderSet.direct(feature));
+ Field features = BiomeGenerationSettings.class.getDeclaredField("features");
+ features.setAccessible(true);
+ features.set(settings, List.copyOf(steps));
+ }
+
+ /**
+ * What a Fabric weather modification does: it replaces the registered biome's climate settings.
+ */
+ private static void warmInPlace(Biome biome) throws ReflectiveOperationException {
+ Constructor climate = Biome.ClimateSettings.class.getDeclaredConstructor(
+ boolean.class, float.class, Biome.TemperatureModifier.class, float.class);
+ climate.setAccessible(true);
+ Field climateSettings = Biome.class.getDeclaredField("climateSettings");
+ climateSettings.setAccessible(true);
+ climateSettings.set(biome, climate.newInstance(true, 0.1F, Biome.TemperatureModifier.NONE, 0.4F));
+ }
+}
diff --git a/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java b/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java
new file mode 100644
index 000000000..d2273b95c
--- /dev/null
+++ b/adapters/forge/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/forge/NativeForgeBiomeDefinitionTest.java
@@ -0,0 +1,100 @@
+package art.arcane.volmlib.nativelib.minecraft26_2.forge;
+
+import art.arcane.volmlib.nativelib.minecraft26_2.terrain.NativeRegistryDefinitions;
+import art.arcane.volmlib.nativelib.modded.NativeLoaderOptions;
+import com.google.gson.JsonParser;
+import com.mojang.serialization.JsonOps;
+import com.mojang.serialization.Lifecycle;
+import com.mojang.serialization.MapCodec;
+import net.minecraft.SharedConstants;
+import net.minecraft.core.Holder;
+import net.minecraft.core.MappedRegistry;
+import net.minecraft.core.RegistrationInfo;
+import net.minecraft.core.RegistryAccess;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.Identifier;
+import net.minecraft.resources.RegistryOps;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.Bootstrap;
+import net.minecraft.world.attribute.EnvironmentAttributes;
+import net.minecraft.world.attribute.modifier.MobSpawnSettingsModifier;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.level.biome.Biome;
+import net.minecraft.world.level.biome.MobSpawnSettings;
+import net.minecraft.world.level.levelgen.GenerationStep;
+import net.minecraft.world.level.levelgen.carver.WorldCarver;
+import net.minecraft.world.level.levelgen.feature.Feature;
+import net.minecraft.world.level.levelgen.placement.PlacedFeature;
+import net.minecraftforge.common.world.BiomeModifier;
+import net.minecraftforge.common.world.ModifiableBiomeInfo;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.function.BooleanSupplier;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+
+public class NativeForgeBiomeDefinitionTest {
+ private static final String BIOME_REGISTRY = "minecraft:worldgen/biome";
+ private static final String BIOME_KEY = "iris:biomes/forge_modified";
+ private static final String SOURCE_JSON = "{\"has_precipitation\":true,\"temperature\":0.8,\"downfall\":0.4,"
+ + "\"effects\":{\"water_color\":\"#3f76e4\"},\"carvers\":[],\"features\":[]}";
+
+ @BeforeClass
+ public static void bootstrap() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ }
+
+ @Test
+ public void generatedBiomeDefinitionExcludesForgeBiomeModifierOutput() {
+ MappedRegistry features = new MappedRegistry<>(Registries.PLACED_FEATURE, Lifecycle.stable());
+ Holder.Reference ore = features.register(
+ ResourceKey.create(Registries.PLACED_FEATURE, Identifier.parse("iris:modded_ore")),
+ new PlacedFeature(Holder.direct(mock(Feature.class)), List.of()),
+ RegistrationInfo.BUILT_IN);
+ features.freeze();
+ MappedRegistry carvers = new MappedRegistry<>(Registries.CARVER, Lifecycle.stable());
+ carvers.freeze();
+ MappedRegistry biomes = new MappedRegistry<>(Registries.BIOME, Lifecycle.stable());
+ RegistryAccess access = new RegistryAccess.ImmutableRegistryAccess(List.of(biomes, features, carvers));
+ Biome source = Biome.DIRECT_CODEC.parse(RegistryOps.create(JsonOps.INSTANCE, access),
+ JsonParser.parseString(SOURCE_JSON)).getOrThrow();
+ Holder.Reference biome = biomes.register(
+ ResourceKey.create(Registries.BIOME, Identifier.parse(BIOME_KEY)), source, RegistrationInfo.BUILT_IN);
+ biomes.freeze();
+ biome.value().modifiableBiomeInfo().applyBiomeModifiers(biome, List.of(new ModdedContent(ore)));
+ NativeRegistryDefinitions definitions = new NativeRegistryDefinitions(() -> access,
+ new NativeForgeLoader(new NativeLoaderOptions("irisworldgen", "treefeller",
+ BooleanSupplier::getAsBoolean))::unmodifiedBiome);
+
+ assertEquals(0.1F, biome.value().getBaseTemperature(), 0.0F);
+ assertEquals(0x102030, biome.value().getWaterColor() & 0xFFFFFF);
+ assertTrue(biome.value().getGenerationSettings().hasFeature(ore.value()));
+ assertTrue(biome.value().getAttributes().contains(EnvironmentAttributes.NATURAL_MOB_SPAWNS));
+ assertEquals(JsonParser.parseString(definitions.canonicalDefinition(BIOME_REGISTRY, BIOME_KEY, SOURCE_JSON).value()),
+ JsonParser.parseString(definitions.generatedDefinition(BIOME_REGISTRY, BIOME_KEY).value()));
+ }
+
+ private record ModdedContent(Holder ore) implements BiomeModifier {
+ @Override
+ public void modify(Holder biome, Phase phase, ModifiableBiomeInfo.BiomeInfo.Builder builder) {
+ if (phase != Phase.ADD) {
+ return;
+ }
+ builder.climateSettings().setTemperature(0.1F);
+ builder.effects().waterColor(0x102030);
+ builder.attributes().modify(EnvironmentAttributes.NATURAL_MOB_SPAWNS, MobSpawnSettingsModifier.overlay(),
+ new MobSpawnSettings.Builder().addSpawn(EntityTypes.ZOMBIE, 5, 1, 2).build());
+ builder.generationSettings().addFeature(GenerationStep.Decoration.UNDERGROUND_ORES, ore);
+ }
+
+ @Override
+ public MapCodec extends BiomeModifier> codec() {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
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..647aafd92 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;
@@ -72,6 +73,7 @@ import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
@@ -454,11 +456,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 +505,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 +517,31 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
+ private void recordBindFailure(NativeWorld level, Throwable error) {
+ // A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
+ if (GenerationFailures.isShutdownFailure(error)) {
+ ModdedIrisLog.warn("Iris bind of {} ({}) stopped with its runtime: {}", level.name(), dimensionKey,
+ error.toString());
+ return;
+ }
+ 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;
@@ -565,21 +595,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
- Engine engineOrNull() {
- requireBindingAllowed();
- Engine cached = engine;
- requireCompletedShutdown(cached);
- if (cached != null && !cached.isClosed()) {
- return cached;
- }
- try {
- return engine();
- } catch (Throwable ignored) {
- return null;
- }
- }
-
- Engine structureEngineOrNull() {
+ /**
+ * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
+ * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
+ */
+ Engine boundEngineOrNull() {
requireBindingAllowed();
engineBinding.throwIfFailed(dimensionKey);
Engine cached = engine;
@@ -984,7 +1004,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
@Override
public NativeModdedBiomePolicy biomePolicy(NativeBiomeSourceAccess source) {
ModdedBiomePolicy policy = new ModdedBiomePolicy<>(source);
- policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::structureEngineOrNull, this::awaitStructureEngine,
+ policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::boundEngineOrNull, this::awaitStructureEngine,
this::allowsGenerationHistoryBypass, this::configuredStructureBiomeKeys,
IrisModdedChunkGenerator::retainedBiomeKeys, this::visibleBiomeSeed));
return policy;
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java
index 83d69e3c3..7cba27969 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java
@@ -266,7 +266,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
@Override
public H getNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return access.serializedNoise(quartX, quartY, quartZ, sampler);
}
@@ -288,7 +288,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
@Override
public NativeBiomeSourcePolicy.BiomeLocation findClosestBiome3d(ClosestQuery query) {
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return query.unboundFallback().get();
}
@@ -355,7 +355,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
}
public H getVisibleNoiseBiome(int quartX, int quartY, int quartZ, S sampler) {
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return access.serializedNoise(quartX, quartY, quartZ, sampler);
}
@@ -411,7 +411,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
}
public H getVisibleSurfaceBiome(int blockX, int blockZ) {
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return null;
}
@@ -420,15 +420,15 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
try (historyScope) {
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome");
if (lease == null) {
- return null;
+ throw new IllegalStateException("Iris surface spawn biome lookup was rejected during an engine transition");
}
try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
if (!isReady(engine)) {
- return null;
+ throw new IllegalStateException("Iris surface spawn biome lookup has no active engine runtime");
}
NativeBiomeSourceAccess.RegistryView registry = biomeRegistry();
if (registry == null) {
- return null;
+ throw new IllegalStateException("Iris surface spawn biome lookup has no biome registry");
}
int quartX = ((blockX) >> 2);
int quartZ = ((blockZ) >> 2);
@@ -463,7 +463,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
}
public boolean isStructureReachable(Iterable biomes) {
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return isStructureReachable(biomes, possibleStructureBiomeKeys());
}
@@ -494,7 +494,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
int radius = query.radius();
S sampler = query.sampler();
int minQuartY = ((y - radius) >> 2);
- Engine engine = engineOrNull();
+ Engine engine = boundEngineOrNull();
if (engine == null) {
return query.fallback().get();
}
@@ -874,7 +874,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy {
return engine != null && !engine.isClosed() && engine.getComplex() != null;
}
- private Engine engineOrNull() {
+ private Engine boundEngineOrNull() {
RuntimeCallbacks current = runtime;
return current == null ? null : current.currentEngine().get();
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java
index b6bd3726e..0936c651e 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java
@@ -79,6 +79,8 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.generation.concurrent.MultiBurst;
import java.util.ArrayDeque;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedEngineBootstrap {
private static final String[] CORE_SELF_TEST_CLASSES = {
@@ -87,12 +89,17 @@ public final class ModdedEngineBootstrap {
"art.arcane.iris.pack.loading.IrisData"
};
private static final Object LOCK = new Object();
+ private static final Object STOP_LOCK = new Object();
+ private static final AtomicBoolean EXIT_FLUSH_REGISTERED = new AtomicBoolean();
+ private static final long EXIT_FLUSH_SERVER_WAIT_SECONDS = 30L;
private static final ModdedServiceManager UNBOUND_SERVICE_MANAGER = new ModdedServiceManager();
private static volatile NativeModdedLoader loader;
private static volatile BoundRuntime runtime;
private static volatile NativeModdedServer currentServer;
private static volatile NativeModdedServer spawnCaptureServer;
private static volatile boolean initialSpawnWasDefault;
+ private static volatile boolean stoppedPending;
+ private static volatile Thread stoppingThread;
private ModdedEngineBootstrap() {
}
@@ -119,10 +126,12 @@ public final class ModdedEngineBootstrap {
}
public static void start(NativeModdedServer server) {
+ finishPreviousServer("server start");
+ registerExitFlush();
captureInitialSpawn(server);
currentServer = server;
bind();
- // Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once
+ // Pair of the stopped() burst-pools stage. Load-bearing on integrated servers: once
// closed, MultiBurst falls back to a same-thread executor, so a second world load
// without reopen() would silently run every burst inline.
MultiBurst.burst.reopen();
@@ -150,6 +159,7 @@ public final class ModdedEngineBootstrap {
}
public static void serverAboutToStart(NativeModdedServer server) {
+ finishPreviousServer("server start");
captureInitialSpawn(server);
ModdedStartup.prepareForStartup();
}
@@ -180,20 +190,16 @@ public final class ModdedEngineBootstrap {
try {
generator.unbindEngine(world);
} catch (Throwable exception) {
+ // Never propagate into the loader's unload loop: a throw there skips closing every later level. A
+ // runtime dimension removal retries the eviction itself and fails with the retained mapping.
ModdedIrisLog.error("Iris engine unload failed for {}", world.name(), exception);
- if (exception instanceof RuntimeException runtimeException) {
- throw runtimeException;
- }
- if (exception instanceof Error fatalError) {
- throw fatalError;
- }
- throw new IllegalStateException("Iris engine unload failed for "
- + world.name(), exception);
}
}
public static void stop() {
NativeModdedServer stoppingServer = currentServer;
+ stoppingThread = Thread.currentThread();
+ stoppedPending = true;
Throwable failure = null;
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
@@ -205,6 +211,62 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
+ reportStopFailure("stopping", failure);
+ }
+
+ /**
+ * The loaders still drain queued chunk generation after server stopping and only close levels afterwards, so
+ * engines, their mantle and generation history, and the generation pools stay open until the server stopped.
+ * Engines of unloaded levels are already closed by {@link #levelUnloaded}; this closes whatever remains.
+ * Fabric fires it at the tail of stopServer, so a throw there skips it: the next server start or the JVM
+ * exit runs it instead.
+ */
+ public static void stopped() {
+ synchronized (STOP_LOCK) {
+ stoppedPending = false;
+ stoppingThread = null;
+ closeStoppedServer();
+ }
+ }
+
+ private static void finishPreviousServer(String trigger) {
+ synchronized (STOP_LOCK) {
+ if (!stoppedPending) {
+ return;
+ }
+ ModdedIrisLog.warn("Iris closing the engines the previous server left open ({})", trigger);
+ stopped();
+ }
+ }
+
+ private static void registerExitFlush() {
+ if (EXIT_FLUSH_REGISTERED.compareAndSet(false, true)) {
+ Runtime.getRuntime().addShutdownHook(new Thread(ModdedEngineBootstrap::flushAtExit, "Iris Exit Flush"));
+ }
+ }
+
+ private static void flushAtExit() {
+ if (!stoppedPending) {
+ return;
+ }
+ Thread serverThread = stoppingThread;
+ if (serverThread != null && serverThread != Thread.currentThread()) {
+ try {
+ serverThread.join(TimeUnit.SECONDS.toMillis(EXIT_FLUSH_SERVER_WAIT_SECONDS));
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ if (serverThread.isAlive()) {
+ ModdedIrisLog.warn("Iris skipped its exit flush: the server thread is still stopping");
+ return;
+ }
+ }
+ finishPreviousServer("JVM exit");
+ }
+
+ private static void closeStoppedServer() {
+ Throwable failure = null;
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
@@ -226,10 +288,14 @@ public final class ModdedEngineBootstrap {
spawnCaptureServer = null;
initialSpawnWasDefault = false;
});
+ reportStopFailure("stopped", failure);
+ }
+
+ private static void reportStopFailure(String phase, Throwable failure) {
if (failure != null) {
// The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and
// can leave the level unsaved. Every stage already logged its own failure.
- ModdedIrisLog.error("Iris modded shutdown completed with failures", failure);
+ ModdedIrisLog.error("Iris modded shutdown ({}) completed with failures", phase, failure);
}
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
index ff4d78ce0..93f887393 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
@@ -314,12 +314,13 @@ public final class ModdedForcedDatapack {
} catch (NoSuchAlgorithmException missing) {
throw new IllegalStateException("SHA-256 is unavailable", missing);
}
+ DataVersion dataVersion = DataVersion.getRuntime();
digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName()
- + '|' + DataVersion.V26_3.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
+ + '|' + dataVersion.getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8));
String inputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
compilationPackRoots(),
List.of(),
- DataVersion.V26_3.get(),
+ dataVersion.get(),
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
@@ -328,7 +329,7 @@ public final class ModdedForcedDatapack {
private static void writeStagedPack(Path stagingDirectory) throws IOException {
Map> seenBiomes = new LinkedHashMap<>();
- IDataFixer fixer = DataVersion.V26_3.get();
+ IDataFixer fixer = DataVersion.getRuntime().get();
int packCount = 0;
KList presetIds = new KList<>();
@@ -679,7 +680,7 @@ public final class ModdedForcedDatapack {
}
static void writePackMeta(Path packDirectory) throws IOException {
- int packFormat = DataVersion.V26_3.getPackFormat();
+ int packFormat = DataVersion.getRuntime().getPackFormat();
String json = "{\n"
+ " \"pack\": {\n"
+ " \"description\": \"Iris world generation biomes and dimension types for installed packs.\",\n"
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java
index 050d4b984..976e6bdfa 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationRegistry.java
@@ -9,12 +9,10 @@ import java.util.Objects;
final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
private final NativeGenerationRegistry nativeRegistry;
private final String runtimeIdentity;
- private final String rendererIdentity;
- ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, Identity identity) {
+ ModdedGenerationRegistry(NativeGenerationRegistry nativeRegistry, String runtimeIdentity) {
this.nativeRegistry = Objects.requireNonNull(nativeRegistry, "nativeRegistry");
- this.runtimeIdentity = requireText(identity.runtime(), "runtimeIdentity");
- this.rendererIdentity = requireText(identity.renderer(), "rendererIdentity");
+ this.runtimeIdentity = requireText(runtimeIdentity, "runtimeIdentity");
}
@Override
@@ -22,11 +20,6 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
return runtimeIdentity;
}
- @Override
- public String generatedDefinitionRendererIdentity() {
- return rendererIdentity;
- }
-
@Override
public String customBiomeResourceKey(String identitySha256) {
return PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(identitySha256);
@@ -75,7 +68,4 @@ final class ModdedGenerationRegistry implements PlatformGenerationRegistry {
}
return required;
}
-
- record Identity(String runtime, String renderer) {
- }
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java
index 0763209cc..0c73b4fed 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java
@@ -25,6 +25,8 @@ import art.arcane.volmlib.nativelib.terrain.feature.NativeFeatureTable;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedImportedFeatures;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
@@ -95,10 +97,12 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
* the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome
* whose datapack JSON declares none by design. Real registry biomes pass straight through.
*
- * With {@code importedFeatures} disabled there is no table and this is vanilla's default getter.
+ *
With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. So is an
+ * unbound generator, which only world creation and codec validation ask. A bound generator whose engine is
+ * refused or stopping throws: BiomeFilter would otherwise drop every imported feature of a custom biome.
*/
public NativeFeatureTable currentTable() {
- Engine engine = generator == null ? null : generator.engineOrNull();
+ Engine engine = generator == null ? null : generator.boundEngineOrNull();
return engine == null ? null : featureTables.get(engine.getCacheID());
}
@@ -111,8 +115,15 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
* the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen
* thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose
* cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor.
+ * A sealed runtime keeps the table it already settled but cannot build one, and decorating without it
+ * would persist a chunk with no imported features, so it fails the chunk instead.
*/
public void prepare(Engine engine) {
+ if ((engine.isClosed() || engine.isClosing())
+ && !settled(engine.getCacheID(), biomeSource.packGeneration())) {
+ throw new GenerationClosedException("Iris imported features cannot prepare runtime "
+ + engine.getCacheID() + " while its engine is closing.");
+ }
prepare(engine, true);
}
@@ -127,7 +138,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
}
private void prepare(Engine engine, boolean waitForBuild) {
- if (engine == null || engine.isClosed() || engine.isClosing()) {
+ if (engine.isClosed() || engine.isClosing()) {
return;
}
int runtimeIdentity = engine.getCacheID();
@@ -163,6 +174,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
try {
control = NativeFeatureGenerationPolicy.control(engine);
} catch (RuntimeException error) {
+ GenerationFailures.rethrowEngineFailure(error);
ModdedIrisLog.error("Iris could not read importedFeatures for this dimension; features off: {}",
error.toString(), error);
markInert(runtimeIdentity, generation);
@@ -177,6 +189,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
built = nativeFeatures.buildTable(new ModdedImportedFeaturePolicy(engine), control, generation);
} catch (Throwable error) {
+ GenerationFailures.rethrowEngineFailure(error);
ModdedIrisLog.error("Iris importedFeatures is off for {}: feature table construction failed: {}",
dimensionKey(engine), error.toString(), error);
markInert(runtimeIdentity, generation);
@@ -201,17 +214,24 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe
inertGenerations.put(runtimeIdentity, generation);
}
+ /**
+ * Null only for a runtime whose features are settled off. A table retired or superseded since this chunk's
+ * prepare fails the chunk: skipping the pass would persist it without imported features.
+ */
public NativeFeatureTable placementTable(Engine engine) {
int runtimeIdentity = engine.getCacheID();
+ long generation = biomeSource.packGeneration();
NativeFeatureTable table = featureTables.get(runtimeIdentity);
- if (table == null) {
+ if (table == null && Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity))) {
WorldCheckFeaturePlacement.recordFeaturesOff();
return null;
}
- if (table.generation() != biomeSource.packGeneration()) {
- featureTables.remove(runtimeIdentity, table);
- inertGenerations.remove(runtimeIdentity);
- return null;
+ if (table == null || table.generation() != generation) {
+ if (table != null) {
+ featureTables.remove(runtimeIdentity, table);
+ }
+ throw new GenerationClosedException("Iris imported feature table for runtime " + runtimeIdentity
+ + " was retired before decoration.");
}
return generator == null ? null : table;
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java
index 84c8d12b4..ba497052f 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLifecycleCallbacks.java
@@ -16,6 +16,7 @@ public final class ModdedLifecycleCallbacks {
ModdedEngineBootstrap::start,
ModdedEngineBootstrap::serverStarted,
ModdedEngineBootstrap::stop,
+ ModdedEngineBootstrap::stopped,
ModdedEngineBootstrap::levelLoaded,
ModdedEngineBootstrap::levelUnloaded,
ModdedProtocolHandler::onPlayerJoin,
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java
index 0e41da5f3..85fa84155 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java
@@ -40,29 +40,31 @@ import java.util.function.BooleanSupplier;
* references and is safe on a dedicated server.
*/
public final class ModdedMixinAudit {
+ private static final String ENTITY_CONFIG = "volmlib.entity.mixins.json";
+ private static final String CLIENT_CONFIG = "volmlib.client.mixins.json";
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
- private static final List EXPECTED = List.of(
- new ExpectedMixin("EntityPersistenceMixin", "entity",
+ static final List EXPECTED = List.of(
+ new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan),
- new ExpectedMixin("LivingEntityLootMixin", "entity",
+ new ExpectedMixin(ENTITY_CONFIG, "LivingEntityLootMixin",
NativeMixinTarget.LIVING_ENTITY, "iris$replaceBaseLoot",
false, NativeMixinFlags::livingEntityLootRan),
- new ExpectedMixin("MobAwarenessMixin", "entity",
+ new ExpectedMixin(ENTITY_CONFIG, "MobAwarenessMixin",
NativeMixinTarget.MOB, "iris$tickUnawareMob",
false, NativeMixinFlags::mobAwarenessRan),
- new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common",
+ new ExpectedMixin(ENTITY_CONFIG, "StructureTemplatePaletteConcurrencyMixin",
NativeMixinTarget.STRUCTURE_PALETTE,
"iris$installConcurrentBlockCache",
false, NativeMixinFlags::structureTemplatePaletteRan),
- new ExpectedMixin("NativeWorldOpenFlowsMixin", "client",
+ new ExpectedMixin(CLIENT_CONFIG, "NativeWorldOpenFlowsMixin",
NativeMixinTarget.WORLD_OPEN_FLOWS,
- "iris$openWorldCheckWorldStemCompatibility",
+ "volmlib$openWorldCheckWorldStemCompatibility",
true, NativeMixinFlags::worldOpenFlowsRan),
- new ExpectedMixin("NativeWorldTypeEntryMixin", "client",
+ new ExpectedMixin(CLIENT_CONFIG, "NativeWorldTypeEntryMixin",
NativeMixinTarget.WORLD_TYPE_ENTRY,
- "iris$describePreset",
+ "volmlib$describePreset",
true, NativeMixinFlags::worldTypeEntryRan));
private ModdedMixinAudit() {
@@ -90,7 +92,7 @@ public final class ModdedMixinAudit {
if (isApplied(expected)) {
applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)"));
} else {
- missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass());
+ missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass().className());
}
}
if (missing.isEmpty()) {
@@ -119,7 +121,7 @@ public final class ModdedMixinAudit {
}
}
- private record ExpectedMixin(String mixinName, String config, NativeMixinTarget targetClass, String handlerMethod,
- boolean clientOnly, BooleanSupplier ran) {
+ record ExpectedMixin(String config, String mixinName, NativeMixinTarget targetClass, String handlerMethod,
+ boolean clientOnly, BooleanSupplier ran) {
}
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java
index 3d795bfd5..be4de0d5e 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java
@@ -61,7 +61,7 @@ public final class ModdedPlatform implements IrisPlatform {
this.loader = loader;
this.registries = new ModdedRegistries(
NativeModdedServer.registryAccess(ModdedEngineBootstrap::currentServer, ModdedRegistries::warnNotReady),
- ModdedPlatform::generationRegistry);
+ () -> generationRegistry(loader));
this.scheduler = new ModdedScheduler();
this.structureHooks = new ModdedStructureHooks(ModdedEngineBootstrap::currentServer);
this.biomeWriter = new ModdedBiomeWriter(new NativeBiomeRegistry(ModdedEngineBootstrap::currentServer, "minecraft:plains"));
@@ -352,13 +352,12 @@ public final class ModdedPlatform implements IrisPlatform {
}
}
- private static PlatformGenerationRegistry generationRegistry() {
+ private static PlatformGenerationRegistry generationRegistry(NativeModdedLoader loader) {
if (ModdedEngineBootstrap.currentServer() == null) {
throw new IllegalStateException("Minecraft server is not ready for generation registry capture.");
}
- return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer),
- new ModdedGenerationRegistry.Identity("modded-generation-registry-v1",
- "modded-generated-registry-json-v1|minecraft:" + BuildConstants.MINECRAFT_VERSION));
+ return new ModdedGenerationRegistry(NativeModdedServer.registryDefinitions(ModdedEngineBootstrap::currentServer, loader),
+ "modded-generation-registry-v1");
}
}
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..861f73b37 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,15 +18,17 @@
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;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
@@ -36,6 +38,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 +47,8 @@ public final class ModdedPrimaryWorldRouter {
public static void clear() {
routed.clear();
inFlight.clear();
+ loginRefusal = null;
+ absentPrimary = null;
}
/**
@@ -56,6 +62,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 +88,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;
@@ -97,7 +123,7 @@ public final class ModdedPrimaryWorldRouter {
try {
CompletableFuture teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
- System.nanoTime() + TimeUnit.SECONDS.toNanos(10)));
+ ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
inFlight.remove(id);
if (Boolean.TRUE.equals(success) && failure == null) {
@@ -115,4 +141,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/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java
index e49d6e537..6c7919bdf 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java
@@ -195,7 +195,8 @@ public final class ModdedScheduler implements PlatformScheduler {
mainQueue.clear();
delayedQueue.clear();
mainThread = null;
- // Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it.
+ // Runs from ModdedEngineBootstrap.stopped, or its fallback at the next server start or JVM exit when the
+ // loader skipped it: release the level snapshot with it.
ModdedServerLevels.forget();
}
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/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java
new file mode 100644
index 000000000..e4cba1d4f
--- /dev/null
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTeleportDeadline.java
@@ -0,0 +1,37 @@
+/*
+ * 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.modded;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Deadline for a teleport that waits on its destination chunk. The first chunk of a cold dimension also loads the
+ * pack and binds the engine; the deadline only bounds a destination whose chunk never completes (a refusing
+ * generator or a removed dimension).
+ */
+public final class ModdedTeleportDeadline {
+ private static final long DEADLINE_SECONDS = 120L;
+
+ private ModdedTeleportDeadline() {
+ }
+
+ public static long fromNow() {
+ return System.nanoTime() + TimeUnit.SECONDS.toNanos(DEADLINE_SECONDS);
+ }
+}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java
index 30bd305a4..dbc613a2e 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java
@@ -426,9 +426,9 @@ public final class ModdedWorldEngines {
Engine engine = entry.getValue();
try {
// Latch the generator's unloading flag BEFORE closing (unbindEngine sets it,
- // then evicts): chunk-system drain work running after this stage would
- // otherwise see a closed engine and silently rebuild a fresh engine + Mantle
- // that no teardown stage ever closes, writing plates after the final save.
+ // then evicts): late chunk work would otherwise see a closed engine and
+ // silently rebuild a fresh engine + Mantle that no teardown stage ever
+ // closes, writing plates after the final save.
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class);
if (generator != null) {
generator.unbindEngine(level);
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java
index 1ee890883..22a70e57d 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java
@@ -55,7 +55,7 @@ final class WorldCheckDimensionContract {
}
static Dimension expectedDimensionContract(IrisDimension dimension) {
- JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.V26_3.get()));
+ JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
return new Dimension(
json.getInt("min_y"),
json.getInt("height"),
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java
index 110654bb5..1be62bae4 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java
@@ -36,6 +36,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler;
+import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.modded.ModdedWorldgenIds;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KMap;
@@ -60,6 +61,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import art.arcane.volmlib.util.localization.MessageArgument;
@@ -133,7 +135,7 @@ public final class IrisModdedCommands {
NativeModdedServer server = source.server();
CompletableFuture teleport = NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(
server, ModdedDimensionManager.level(server, dimensionId), 8.5D, Double.MIN_VALUE, 8.5D,
- System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
+ ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
if (Boolean.TRUE.equals(success) && failure == null) {
return;
@@ -142,14 +144,42 @@ public final class IrisModdedCommands {
ModdedIrisLog.error("Iris teleport into '{}' failed for {}",
dimensionId, player.id(), failure);
}
- server.execute(() -> fail(source, IrisLanguage.plain(
- ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
- MessageArgument.untrusted("dimensionId", dimensionId))));
+ server.execute(() -> fail(source, teleportFailure(dimensionId, player.name(), failure,
+ ModdedDimensionManager.level(server, dimensionId) != null, server.player(player.id()) != null)));
});
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.name()), MessageArgument.untrusted("dimensionId", dimensionId)));
return 1;
}
+ static String teleportFailure(String dimensionId, String playerName, Throwable failure,
+ boolean dimensionLoaded, boolean playerOnline) {
+ if (failure != null) {
+ return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
+ MessageArgument.untrusted("dimensionId", dimensionId),
+ MessageArgument.untrusted("reason", failureReason(failure)));
+ }
+ if (!dimensionLoaded) {
+ return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
+ MessageArgument.untrusted("dimensionId", dimensionId));
+ }
+ if (!playerOnline) {
+ return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
+ MessageArgument.untrusted("dimensionId", dimensionId),
+ MessageArgument.untrusted("value", playerName));
+ }
+ return IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
+ MessageArgument.untrusted("dimensionId", dimensionId));
+ }
+
+ private static String failureReason(Throwable failure) {
+ Throwable cause = failure;
+ while (cause instanceof CompletionException && cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ String message = cause.getMessage();
+ return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message;
+ }
+
static int evacuate(NativeCommandSource source, NativeWorld target) {
NativeModdedServer server = source.server();
NativeWorld level = target != null ? target : source.world();
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java
index f58722f51..0d9df4119 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java
@@ -165,7 +165,7 @@ public final class ModdedDatapackCommands {
IrisDimension dimension = engine.getDimension();
String json;
try {
- json = dimension.getDimensionType().toJson(DataVersion.V26_3.get());
+ json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
} catch (Throwable e) {
ModdedIrisLog.error("Iris dimension type generation failed for {}", dimension.getLoadKey(), e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_DIMENSION_TYPE_GENERATION_FAILED, MessageArgument.untrusted("value", level.name()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage()))));
@@ -187,7 +187,7 @@ public final class ModdedDatapackCommands {
}
File mcmeta = new File(worldDatapacksFolder(server), WORLD_PACK_NAME + "/pack.mcmeta");
- int packFormat = DataVersion.V26_3.getPackFormat();
+ int packFormat = DataVersion.getRuntime().getPackFormat();
String meta = "{\n"
+ " \"pack\": {\n"
+ " \"description\": \"Iris dimension types derived from the installed Iris packs.\",\n"
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java
index 8b8af635f..64460392a 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java
@@ -25,6 +25,7 @@ import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
+import art.arcane.iris.modded.ModdedTeleportDeadline;
import java.awt.Desktop;
import java.io.File;
@@ -32,7 +33,6 @@ import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
-import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -63,8 +63,7 @@ public final class ModdedVisionOverlay implements GuiOverlay {
world.execute(() -> {
int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
Optional operation = world.teleport(opener,
- new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D,
- System.nanoTime() + TimeUnit.SECONDS.toNanos(10L)));
+ new WorldView.Destination(blockX + 0.5D, surfaceY, blockZ + 0.5D, ModdedTeleportDeadline.fromNow()));
if (operation.isEmpty()) {
return;
}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java
index e25c83ce8..f6b6779cb 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java
@@ -71,6 +71,18 @@ public final class ModdedCommandMessages {
"iris.modded.irismoddedcommands.teleport_failed_dimension_is_not_loaded",
"Teleport failed: dimension " + "{dimensionId}" + " is not loaded."
);
+ public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON = TextKey.of(
+ "iris.modded.irismoddedcommands.teleport_failed_reason",
+ "Teleport into " + "{dimensionId}" + " failed: " + "{reason}"
+ );
+ public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE = TextKey.of(
+ "iris.modded.irismoddedcommands.teleport_cancelled_player_offline",
+ "Teleport into " + "{dimensionId}" + " cancelled: " + "{value}" + " is no longer online."
+ );
+ public static final TextKey IRIS_MODDED_COMMANDS_TELEPORT_REFUSED = TextKey.of(
+ "iris.modded.irismoddedcommands.teleport_refused",
+ "Teleport into " + "{dimensionId}" + " was refused by the server."
+ );
public static final TextKey IRIS_MODDED_COMMANDS_TELEPORTING = TextKey.of(
"iris.modded.irismoddedcommands.teleporting",
"Teleporting " + "{value}" + " to " + "{dimensionId}" + "..."
@@ -1266,6 +1278,9 @@ public final class ModdedCommandMessages {
IRIS_MODDED_COMMANDS_CONSOLE_MUST_NAME_PLAYER_IRIS_TP_DIMENSION_PLAYER,
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS,
IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
+ IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
+ IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
+ IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
IRIS_MODDED_COMMANDS_TELEPORTING,
IRIS_MODDED_COMMANDS_IS_NOT_GENERATED_BY_IRIS_2,
IRIS_MODDED_COMMANDS_CANNOT_EVACUATE_PRIMARY_WORLD_THERE_IS_NOWHERE_SEND_PLAYERS,
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java
index e29deb1c7..5fe28ac2d 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java
@@ -30,6 +30,7 @@ import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.integration.Identifier;
import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.block.TileData;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedBlockResolution;
@@ -38,6 +39,7 @@ import art.arcane.iris.modded.ModdedTileData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockPlacement;
import art.arcane.iris.spi.IrisLogging;
+import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.storage.matter.TileWrapper;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
@@ -47,6 +49,7 @@ import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import it.unimi.dsi.fastutil.ints.IntArrayList;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -149,7 +152,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
int centerZ = player.z() >> 4;
for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) {
for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) {
- updateChunk(engine, level, centerX + dx, centerZ + dz);
+ updatePassChunk(engine, level, centerX + dx, centerZ + dz);
}
}
});
@@ -157,15 +160,29 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
private void updateForcedChunks(Engine engine, NativeWorld level) {
new NativeWorldMaintenance(level).forEachForcedChunk(chunkKey ->
- updateChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
+ updatePassChunk(engine, level, (int) chunkKey, (int) (chunkKey >> 32)));
}
public void updateRegeneratedChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
updateChunk(engine, level, chunkX, chunkZ, false);
}
- private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
- updateChunk(engine, level, chunkX, chunkZ, true);
+ /**
+ * One chunk of a level pass. A failed chunk keeps its flags and retries on a later pass without stopping the
+ * chunks after it; only a closed engine ends the level's pass.
+ */
+ private void updatePassChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ) {
+ try {
+ updateChunk(engine, level, chunkX, chunkZ, true);
+ } catch (Throwable failure) {
+ if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
+ throw failure;
+ }
+ if (IrisLogging.errorOnce("chunk-update:" + level.name() + ":" + chunkX + "," + chunkZ + ":" + failure,
+ "Iris chunk update failed at " + chunkX + "," + chunkZ + " in " + level.name() + ", retrying each pass: " + failure)) {
+ IrisLogging.reportError(failure);
+ }
+ }
}
private void updateChunk(Engine engine, NativeWorld level, int chunkX, int chunkZ, boolean requireNeighbors) {
@@ -195,6 +212,11 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, () -> runCustomPass(engine, level, chunkX, chunkZ, chunk));
chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk));
});
+ } catch (SavedBiomeUnavailableException unavailable) {
+ if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
+ throw unavailable;
+ }
+ IrisLogging.debug("Iris chunk update deferred at " + chunkX + "," + chunkZ + ": saved biomes loading");
} finally {
chunk.release();
}
@@ -315,15 +337,40 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
}
- chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
- if (v != null && v.isUpdate()) {
- update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
- }
- });
+ IntArrayList completed = new IntArrayList();
+ try {
+ chunk.iterate(MatterUpdate.class, (Integer x, Integer yf, Integer z, MatterUpdate v) -> {
+ if (v != null && v.isUpdate()) {
+ update(engine, level, x, yf + minHeight, z, baseX, baseZ, chunk);
+ completed.add((yf << 8) | ((x & 15) << 4) | (z & 15));
+ }
+ });
+ } catch (RuntimeException | Error failure) {
+ forgetCompletedUpdates(chunk, completed, failure);
+ throw failure;
+ }
chunk.deleteSlices(MatterUpdate.class);
engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds());
}
+ private static void forgetCompletedUpdates(MantleChunk chunk, IntArrayList completed, Throwable failure) {
+ try {
+ for (int index = 0; index < completed.size(); index++) {
+ int position = completed.getInt(index);
+ int y = position >>> 8;
+ Matter section = chunk.get(y >> 4);
+ if (section != null && section.hasSlice(MatterUpdate.class)) {
+ section.getSlice(MatterUpdate.class)
+ .set((position >> 4) & 15, y & 15, position & 15, null);
+ }
+ }
+ } catch (RuntimeException | Error cleanupFailure) {
+ if (cleanupFailure != failure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ }
+ }
+
private void update(Engine engine, NativeWorld level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk) {
if (y < level.minHeight() || y >= level.maxHeight() - 1) {
return;
@@ -337,7 +384,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
try {
ModdedLootApplier.apply(engine, level, pos, state, chunk);
+ } catch (SavedBiomeUnavailableException unavailable) {
+ GenerationFailures.rethrowEngineFailure(unavailable);
+ IrisLogging.warnOnce("chunk-update-loot:" + level.name() + ":" + unavailable.getMessage(),
+ "Iris left the container at " + pos + " in " + level.name() + " without loot: " + unavailable.getMessage());
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
} else {
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..40b207410
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java
@@ -0,0 +1,170 @@
+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 art.arcane.volmlib.util.mantle.MantleClosedException;
+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 java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.doNothing;
+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.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));
+ }
+ }
+
+ /**
+ * A bind that fails because the runtime is stopping (level unload, quit to title, server stop) says nothing about
+ * the world: it fails the chunk that asked, but the next bind tries again and no refusal is announced.
+ */
+ @Test
+ public void aShutdownFailureDuringBindIsNeitherRememberedNorAnnouncedAsARefusal() 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(true);
+ MantleClosedException closed = new MantleClosedException("Tectonic Plate is closed!");
+
+ try (MockedStatic servers = mockStatic(NativeModdedServer.class);
+ MockedStatic engines = mockStatic(ModdedWorldEngines.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
+ engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any())).thenThrow(closed);
+
+ assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
+ assertSame(closed, assertThrows(MantleClosedException.class, generator::engine));
+
+ engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2));
+ log.verify(() -> ModdedIrisLog.error(anyString()), never());
+ }
+ }
+
+ /**
+ * Unloading a level resets its binding, so a refusal recorded in one load never carries into the next load of the
+ * same generator (a runtime dimension removal that rolls back, or a level loaded again in the same JVM).
+ */
+ @Test
+ public void aRecordedRefusalDoesNotOutliveTheUnloadOfItsLevel() throws Exception {
+ IrisModdedChunkGenerator generator = generator("overworld:overworld");
+ NativeWorld level = level(generator, "minecraft:overworld");
+ doNothing().when((NativeModdedChunkGenerator, ?, ?>) field("nativeGenerator").get(generator)).clearCaches();
+ field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class));
+ field("announced").set(generator, new AtomicBoolean());
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ when(server.generateStructures()).thenReturn(false);
+
+ try (MockedStatic servers = mockStatic(NativeModdedServer.class);
+ MockedStatic engines = mockStatic(ModdedWorldEngines.class);
+ MockedStatic log = mockStatic(ModdedIrisLog.class)) {
+ servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
+
+ IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
+ generator.unbindEngine(level);
+ IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
+
+ assertNotSame(first, second.getCause());
+ servers.verify(() -> NativeModdedServer.forWorld(level), times(2));
+ engines.verify(() -> ModdedWorldEngines.evictOrThrow(level));
+ }
+ }
+
+ @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/ModdedBiomePolicyTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java
index 644e24040..4511d8a3a 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java
@@ -2,6 +2,7 @@ package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess;
@@ -84,6 +85,29 @@ public class ModdedBiomePolicyTest {
assertSame(forest, policy.requiredStructureBiome(6000, -30, -6000, null));
}
+ /**
+ * Stacked initial mob spawning asks for the visible surface biome. A null answer made it spawn from the chunk's
+ * top biome instead, so a sealed or unready engine has to fail the chunk.
+ */
+ @Test
+ public void surfaceSpawnBiomeRefusesAnEngineInTransitionInsteadOfAnsweringNothing() throws Exception {
+ Engine sealed = mock(Engine.class);
+ when(sealed.isClosing()).thenReturn(true);
+ when(sealed.acquireGenerationLease(anyString())).thenThrow(new GenerationSessionException("sealed", true));
+ Engine unready = mock(Engine.class);
+ when(unready.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
+
+ assertThrows(IllegalStateException.class, () -> boundTo(sealed).getVisibleSurfaceBiome(8, 8));
+ assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8));
+ }
+
+ private static ModdedBiomePolicy boundTo(Engine engine) {
+ ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>()));
+ policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true,
+ Set::of, bound -> Set.of(), () -> 0L));
+ return policy;
+ }
+
private static ModdedBiomePolicy policy(List entries,
AtomicReference> configured) {
ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(entries));
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java
new file mode 100644
index 000000000..72134991d
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineBootstrapShutdownTest.java
@@ -0,0 +1,111 @@
+package art.arcane.iris.modded;
+
+import art.arcane.iris.configuration.IrisSettings;
+import art.arcane.iris.generation.concurrent.MultiBurst;
+import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.modded.command.IrisModdedCommands;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
+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.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class ModdedEngineBootstrapShutdownTest {
+ private IrisSettings previousSettings;
+
+ @BeforeClass
+ public static void bootstrap() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ }
+
+ @Before
+ public void openRuntime() {
+ previousSettings = IrisSettings.settings;
+ IrisSettings.settings = new IrisSettings();
+ reopenBurstPools();
+ }
+
+ @After
+ public void restoreRuntime() {
+ reopenBurstPools();
+ IrisModdedCommands.openDownloadAdmission();
+ IrisSettings.settings = previousSettings;
+ }
+
+ @Test
+ public void serverStoppingKeepsEnginesAndWorkersOpenUntilServerStopped() throws Exception {
+ NativeWorld level = level();
+ Engine engine = mock(Engine.class);
+ ModdedWorldEngines.installReplacement(level, engine);
+ Callable worker = Thread::currentThread;
+
+ ModdedEngineBootstrap.stop();
+
+ verify(engine, never()).close();
+ assertTrue(ModdedWorldEngines.activeEngines().contains(engine));
+ assertNotSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
+
+ ModdedEngineBootstrap.stopped();
+
+ verify(engine).close();
+ assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
+ assertSame(Thread.currentThread(), MultiBurst.burst.submit(worker).get(10L, TimeUnit.SECONDS));
+ }
+
+ @Test
+ public void nextServerStartClosesTheEnginesOfAServerThatNeverReachedStopped() {
+ NativeWorld level = level();
+ Engine engine = mock(Engine.class);
+ ModdedWorldEngines.installReplacement(level, engine);
+ ModdedEngineBootstrap.stop();
+
+ try (MockedStatic startup = mockStatic(ModdedStartup.class);
+ MockedConstruction spawn = mockConstruction(NativeServerSpawn.class)) {
+ ModdedEngineBootstrap.serverAboutToStart(mock(NativeModdedServer.class));
+ }
+
+ verify(engine).close();
+ assertFalse(ModdedWorldEngines.activeEngines().contains(engine));
+ }
+
+ private static NativeWorld level() {
+ ServerLevel serverLevel = mock(ServerLevel.class);
+ ServerChunkCache chunks = mock(ServerChunkCache.class);
+ when(serverLevel.getChunkSource()).thenReturn(chunks);
+ when(chunks.getGenerator()).thenReturn(mock(ChunkGenerator.class));
+ NativeWorld level = mock(NativeWorld.class);
+ when(level.nativeHandle()).thenReturn(serverLevel);
+ when(level.name()).thenReturn("example:shutdown");
+ return level;
+ }
+
+ private static void reopenBurstPools() {
+ MultiBurst.burst.reopen();
+ MultiBurst.hydrology.reopen();
+ MultiBurst.ioBurst.reopen();
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
index ad1af8e24..8a016e11a 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
@@ -20,6 +20,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.json.JSONObject;
+import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.v1217.DataFixerV1217;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationRegistryContract;
@@ -27,6 +28,8 @@ import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionType;
+import art.arcane.iris.spi.IrisPlatform;
+import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformGenerationRegistry;
import java.lang.reflect.Constructor;
import java.util.Set;
@@ -48,6 +51,8 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
public class ModdedForcedDatapackTest {
@Test
@@ -65,6 +70,29 @@ public class ModdedForcedDatapackTest {
}
}
+ @Test
+ public void metadataFollowsTheRuntimeDataVersionThatValidationUses() throws IOException {
+ IrisPlatform previous = IrisPlatforms.getOrNull();
+ IrisPlatform runtime = mock(IrisPlatform.class);
+ when(runtime.minecraftVersion()).thenReturn("26.2");
+ Path packDirectory = Files.createTempDirectory("iris-modded-runtime-pack-format");
+ IrisPlatforms.unbind();
+ IrisPlatforms.bind(runtime);
+ try {
+ ModdedForcedDatapack.writePackMeta(packDirectory);
+ JSONObject pack = new JSONObject(Files.readString(packDirectory.resolve("pack.mcmeta")))
+ .getJSONObject("pack");
+ assertEquals(DataVersion.getRuntime().getPackFormat(), pack.getInt("pack_format"));
+ assertEquals(DataVersion.V26_2.getPackFormat(), pack.getInt("pack_format"));
+ } finally {
+ IrisPlatforms.unbind();
+ if (previous != null) {
+ IrisPlatforms.bind(previous);
+ }
+ deleteTree(packDirectory);
+ }
+ }
+
@Test
public void retainedDimensionSourceRendersBeforeNativeRegistryExists() throws Exception {
DataFixerV1217 fixer = new DataFixerV1217();
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java
index 5bb349ddf..8b0759862 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java
@@ -3,6 +3,7 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSource;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
import org.junit.Test;
@@ -11,6 +12,9 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@@ -56,17 +60,78 @@ public class ModdedImportedFeatureStageTest {
}
@Test
- public void closingRuntimeDoesNotBuildFeatureTables() {
+ public void aSealedRuntimeWithoutASettledTableRefusesDecorationInsteadOfSkippingFeatures() {
NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
+ when(source.packGeneration()).thenReturn(1L);
Engine engine = mock(Engine.class);
+ when(engine.getCacheID()).thenReturn(7);
when(engine.isClosing()).thenReturn(true);
ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
- stage.prepare(engine);
stage.prepareWithoutWaiting(engine);
+ assertThrows(GenerationClosedException.class, () -> stage.prepare(engine));
- verify(source, never()).packGeneration();
verify(engine, never()).getDimension();
assertFalse(stage.active());
}
+
+ @Test
+ public void aSealedRuntimeKeepsDecoratingWithTheTableItAlreadySettled() {
+ NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
+ when(source.packGeneration()).thenReturn(1L);
+ Engine engine = disabledFeatures(7);
+ ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
+ stage.bind(mock(IrisModdedChunkGenerator.class));
+ stage.prepare(engine);
+
+ when(engine.isClosing()).thenReturn(true);
+ stage.prepare(engine);
+
+ assertNull(stage.placementTable(engine));
+ }
+
+ @Test
+ public void aTableRetiredBetweenPrepareAndPlacementFailsTheChunk() {
+ NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class);
+ when(source.packGeneration()).thenReturn(1L);
+ Engine engine = disabledFeatures(7);
+ ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source);
+ stage.bind(mock(IrisModdedChunkGenerator.class));
+
+ stage.prepare(engine);
+ stage.evictRuntime(7);
+ assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
+
+ stage.prepare(engine);
+ when(source.packGeneration()).thenReturn(2L);
+ assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine));
+ }
+
+ @Test
+ public void anUnboundGeneratorAnswersVanillaGenerationSettings() {
+ IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
+ when(generator.boundEngineOrNull()).thenReturn(null);
+ ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
+ stage.bind(generator);
+
+ assertNull(stage.currentTable());
+ }
+
+ @Test
+ public void aFailedBindFailsTheGenerationSettingsInsteadOfDroppingImportedFeatures() {
+ IllegalStateException refused = new IllegalStateException("Iris generator 'overworld:overworld' failed to bind");
+ IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
+ when(generator.boundEngineOrNull()).thenThrow(refused);
+ ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class));
+ stage.bind(generator);
+
+ assertSame(refused, assertThrows(IllegalStateException.class, stage::currentTable));
+ }
+
+ private static Engine disabledFeatures(int runtimeIdentity) {
+ Engine engine = mock(Engine.class);
+ when(engine.getDimension()).thenReturn(new IrisDimension().setImportedFeatures(new IrisImportedFeatureControl()));
+ when(engine.getCacheID()).thenReturn(runtimeIdentity);
+ return engine;
+ }
}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java
new file mode 100644
index 000000000..97ef1c08c
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedMixinAuditTest.java
@@ -0,0 +1,194 @@
+package art.arcane.iris.modded;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.Assume;
+import org.junit.Test;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class ModdedMixinAuditTest {
+ private static final String MIXIN_ANNOTATION = "Lorg/spongepowered/asm/mixin/Mixin;";
+ private static final Set INJECTOR_ANNOTATIONS = Set.of(
+ "Lorg/spongepowered/asm/mixin/injection/Inject;",
+ "Lorg/spongepowered/asm/mixin/injection/Redirect;",
+ "Lorg/spongepowered/asm/mixin/injection/ModifyArg;",
+ "Lorg/spongepowered/asm/mixin/injection/ModifyArgs;",
+ "Lorg/spongepowered/asm/mixin/injection/ModifyVariable;",
+ "Lorg/spongepowered/asm/mixin/injection/ModifyConstant;");
+ private static final Pattern NEOFORGE_MIXIN_CONFIG = Pattern.compile(
+ "\\[\\[mixins]]\\s*config\\s*=\\s*\"([^\"]+)\"");
+ // One applied mixin per config proves the config was registered, which is all the audit detects.
+ private static final Set UNAUDITED = Set.of(
+ "volmlib.entity.mixins.json/WorldGenerationWriteGuardMixin",
+ "volmlib.entity.mixins.json/ChunkMapTerrainReceiptAccess",
+ "volmlib.entity.mixins.json/ChunkTerrainReceiptMixin",
+ "volmlib.entity.mixins.json/LevelChunkTerrainReceiptMixin",
+ "volmlib.entity.mixins.json/SerializableTerrainReceiptMixin",
+ "volmlib.fabric.mixins.json/BlockItemMixin",
+ "volmlib.fabric.mixins.json/BlockMixin",
+ "volmlib.fabric.mixins.json/PackRepositoryMixin");
+
+ @Test
+ public void everyExpectedHandlerIsAnInjectorDeclaredByItsMixin() throws IOException {
+ for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
+ MixinConfig config = MixinConfig.read(expected.config());
+ String section = config.sections().get(expected.mixinName());
+ assertNotNull(expected.config() + " does not list " + expected.mixinName(), section);
+ assertEquals(expected.mixinName() + " client-only flag disagrees with its config section",
+ "client".equals(section), expected.clientOnly());
+
+ MixinClass mixin = MixinClass.read(config.packageName() + '.' + expected.mixinName());
+ assertTrue(expected.mixinName() + " does not target " + expected.targetClass().className()
+ + " (targets " + mixin.targets() + ")",
+ mixin.targets().contains(expected.targetClass().className()));
+ assertTrue(expected.mixinName() + " declares no injector handler named " + expected.handlerMethod()
+ + " (injectors " + mixin.injectors() + ")",
+ mixin.injectors().contains(expected.handlerMethod()));
+ }
+ }
+
+ @Test
+ public void everyRegisteredMixinIsAuditedOrDeliberatelyUnaudited() throws IOException {
+ Set registered = registeredConfigs();
+ Assume.assumeFalse("loader metadata on this classpath declares no mixin configs", registered.isEmpty());
+ Set audited = new LinkedHashSet<>();
+ for (ModdedMixinAudit.ExpectedMixin expected : ModdedMixinAudit.EXPECTED) {
+ audited.add(expected.config() + '/' + expected.mixinName());
+ }
+
+ Set listed = new TreeSet<>();
+ for (String configName : registered) {
+ for (String mixinName : MixinConfig.read(configName).sections().keySet()) {
+ listed.add(configName + '/' + mixinName);
+ }
+ }
+
+ Set uncovered = new TreeSet<>(listed);
+ uncovered.removeAll(audited);
+ uncovered.removeAll(UNAUDITED);
+ assertEquals("mixins neither audited nor deliberately unaudited", Set.of(), uncovered);
+
+ Set unregisteredAudits = new TreeSet<>(audited);
+ unregisteredAudits.removeAll(listed);
+ assertEquals("audited mixins missing from the registered configs", Set.of(), unregisteredAudits);
+ }
+
+ private static Set registeredConfigs() throws IOException {
+ Set configs = new LinkedHashSet<>();
+ String fabric = readResource("fabric.mod.json");
+ if (fabric != null) {
+ for (JsonElement entry : JsonParser.parseString(fabric).getAsJsonObject().getAsJsonArray("mixins")) {
+ configs.add(entry.isJsonObject()
+ ? entry.getAsJsonObject().get("config").getAsString()
+ : entry.getAsString());
+ }
+ }
+ String neoforge = readResource("META-INF/neoforge.mods.toml");
+ if (neoforge != null) {
+ Matcher matcher = NEOFORGE_MIXIN_CONFIG.matcher(neoforge);
+ while (matcher.find()) {
+ configs.add(matcher.group(1));
+ }
+ }
+ return configs;
+ }
+
+ private static String readResource(String name) throws IOException {
+ try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(name)) {
+ return input == null ? null : new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private record MixinConfig(String packageName, Map sections) {
+ static MixinConfig read(String name) throws IOException {
+ String json = readResource(name);
+ assertNotNull("Missing mixin config resource " + name, json);
+ JsonObject root = JsonParser.parseString(json).getAsJsonObject();
+ Map sections = new LinkedHashMap<>();
+ for (String section : new String[]{"mixins", "client", "server"}) {
+ JsonArray entries = root.getAsJsonArray(section);
+ if (entries == null) {
+ continue;
+ }
+ for (JsonElement entry : entries) {
+ sections.put(entry.getAsString(), section);
+ }
+ }
+ return new MixinConfig(root.get("package").getAsString(), sections);
+ }
+ }
+
+ private record MixinClass(Set targets, Set injectors) {
+ static MixinClass read(String className) throws IOException {
+ String resource = className.replace('.', '/') + ".class";
+ byte[] bytes;
+ try (InputStream input = ModdedMixinAuditTest.class.getClassLoader().getResourceAsStream(resource)) {
+ assertNotNull("Missing mixin class " + className, input);
+ bytes = input.readAllBytes();
+ }
+ Set targets = new TreeSet<>();
+ Set injectors = new TreeSet<>();
+ new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (!MIXIN_ANNOTATION.equals(descriptor)) {
+ return null;
+ }
+ return new AnnotationVisitor(Opcodes.ASM9) {
+ @Override
+ public AnnotationVisitor visitArray(String name) {
+ return new AnnotationVisitor(Opcodes.ASM9) {
+ @Override
+ public void visit(String ignored, Object value) {
+ if (value instanceof Type type) {
+ targets.add(type.getClassName());
+ } else if (value instanceof String target) {
+ targets.add(target.replace('/', '.'));
+ }
+ }
+ };
+ }
+ };
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
+ String[] exceptions) {
+ return new MethodVisitor(Opcodes.ASM9) {
+ @Override
+ public AnnotationVisitor visitAnnotation(String annotation, boolean visible) {
+ if (INJECTOR_ANNOTATIONS.contains(annotation)) {
+ injectors.add(name);
+ }
+ return null;
+ }
+ };
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ return new MixinClass(targets, injectors);
+ }
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java
index c46775ab3..8fc4445a9 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPlatformPathsTest.java
@@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntitySpawnReason;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.level.ServerLevelAccessor;
+import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.state.BlockState;
import org.junit.Rule;
import org.junit.Test;
@@ -91,6 +92,11 @@ public class ModdedPlatformPathsTest {
public boolean checkSpawnPosition(Mob mob, ServerLevelAccessor level, EntitySpawnReason reason) {
return false;
}
+
+ @Override
+ public Biome unmodifiedBiome(Biome biome) {
+ return biome;
+ }
});
}
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..72c128b6b
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java
@@ -0,0 +1,256 @@
+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.minecraft26_2.modded.NativeWorldTeleport;
+import art.arcane.volmlib.nativelib.terrain.NativeWorld;
+import org.junit.After;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.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)));
+ }
+ }
+
+ /**
+ * Routing waits on the primary world's first chunk, which on a cold world also loads the pack and binds the
+ * engine, so it gets the same bounded deadline as /iris tp instead of timing out and retrying every second.
+ */
+ @Test
+ public void routingWaitsForAColdPrimaryWorldWithinTheTeleportDeadline() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeWorld overworld = mock(NativeWorld.class);
+ when(overworld.nativeHandle()).thenReturn(new Object());
+ NativeWorld primary = mock(NativeWorld.class);
+ when(primary.nativeHandle()).thenReturn(new Object());
+ when(server.overworld()).thenReturn(overworld);
+ NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
+ when(player.id()).thenReturn(UUID.randomUUID());
+ when(player.isInWorld(overworld)).thenReturn(true);
+ onlinePlayers(server, player);
+ ModdedModConfig routed = config(true, PRIMARY);
+ AtomicReference requested = new AtomicReference<>();
+
+ long before = System.nanoTime();
+ try (MockedStatic configs = mockStatic(ModdedModConfig.class);
+ MockedStatic manager = mockStatic(ModdedDimensionManager.class);
+ MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) {
+ configs.when(ModdedModConfig::get).thenReturn(routed);
+ manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
+ teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
+ requested.set(invocation.getArgument(1));
+ return new CompletableFuture();
+ });
+
+ evaluate(server);
+ }
+ long after = System.nanoTime();
+
+ assertSame(primary, requested.get().world());
+ long deadline = requested.get().deadlineNanos();
+ assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
+ assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
+ }
+
+ 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/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java
new file mode 100644
index 000000000..a6abc2183
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java
@@ -0,0 +1,145 @@
+package art.arcane.iris.modded.command;
+
+import art.arcane.iris.localization.IrisLanguage;
+import art.arcane.iris.modded.IrisModdedChunkGenerator;
+import art.arcane.iris.modded.ModdedDimensionManager;
+import art.arcane.iris.modded.localization.ModdedCommandMessages;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
+import art.arcane.volmlib.nativelib.terrain.NativeWorld;
+import art.arcane.volmlib.util.localization.MessageArgument;
+import net.minecraft.SharedConstants;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.Identifier;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.Bootstrap;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerChunkCache;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.level.Level;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+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.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+public class IrisModdedCommandsTeleportTest {
+ private static final String DIMENSION = "irisworldgen:1";
+
+ @BeforeClass
+ public static void bootstrap() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ }
+
+ @Test
+ public void teleportWaitsForAColdDestinationWithinABoundedDeadline() {
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ NativeCommandSource source = mock(NativeCommandSource.class);
+ when(source.server()).thenReturn(server);
+ NativeWorld level = mock(NativeWorld.class);
+ when(level.name()).thenReturn(DIMENSION);
+ NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
+ when(player.name()).thenReturn("Tester");
+ AtomicReference requested = new AtomicReference<>();
+
+ long before = System.nanoTime();
+ try (MockedStatic generators = mockStatic(NativeWorldGenerators.class);
+ MockedStatic dimensions = mockStatic(ModdedDimensionManager.class);
+ MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) {
+ generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class))
+ .thenReturn(mock(IrisModdedChunkGenerator.class));
+ dimensions.when(() -> ModdedDimensionManager.level(server, DIMENSION)).thenReturn(level);
+ teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenAnswer((InvocationOnMock invocation) -> {
+ requested.set(invocation.getArgument(1));
+ return new CompletableFuture();
+ });
+
+ assertEquals(1, IrisModdedCommands.tp(source, level, player));
+ }
+ long after = System.nanoTime();
+
+ long deadline = requested.get().deadlineNanos();
+ assertTrue("deadline must outlast a cold first chunk", deadline - before >= TimeUnit.SECONDS.toNanos(60L));
+ assertTrue("deadline must be bounded", deadline - after <= TimeUnit.SECONDS.toNanos(120L));
+ }
+
+ @Test(timeout = 15_000L)
+ public void expiredTeleportReportsTheCallersDeadline() throws Exception {
+ MinecraftServer server = mock(MinecraftServer.class);
+ when(server.isSameThread()).thenReturn(true);
+ ResourceKey dimension = ResourceKey.create(Registries.DIMENSION, Identifier.parse(DIMENSION));
+ ServerChunkCache chunks = mock(ServerChunkCache.class);
+ when(chunks.addTicketAndLoadWithRadius(any(), any(), anyInt())).thenReturn(new CompletableFuture<>());
+ ServerLevel serverLevel = mock(ServerLevel.class);
+ when(serverLevel.getServer()).thenReturn(server);
+ when(serverLevel.dimension()).thenReturn(dimension);
+ when(serverLevel.getChunkSource()).thenReturn(chunks);
+ when(server.getLevel(dimension)).thenReturn(serverLevel);
+ ServerPlayer serverPlayer = mock(ServerPlayer.class);
+ when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
+ NativeWorld level = mock(NativeWorld.class);
+ when(level.nativeHandle()).thenReturn(serverLevel);
+
+ CompletableFuture teleport = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(serverPlayer),
+ new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), level, 8.5D,
+ Double.MIN_VALUE, 8.5D, System.nanoTime() + TimeUnit.SECONDS.toNanos(2L)));
+
+ ExecutionException failure = assertThrows(ExecutionException.class, () -> teleport.get(10L, TimeUnit.SECONDS));
+ assertTrue(failure.getCause() instanceof TimeoutException);
+ assertTrue(failure.getCause().getMessage(), failure.getCause().getMessage().contains("within 2 seconds"));
+ }
+
+ @Test
+ public void failureReportsItsRootCauseInsteadOfAnUnloadedDimension() {
+ String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester",
+ new CompletionException(new IllegalStateException("Exception generating new chunk")), true, true);
+
+ assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
+ MessageArgument.untrusted("dimensionId", DIMENSION),
+ MessageArgument.untrusted("reason", "Exception generating new chunk")), message);
+ }
+
+ @Test
+ public void failureWithoutMessageNamesTheFailureType() {
+ String message = IrisModdedCommands.teleportFailure(DIMENSION, "Tester", new TimeoutException(), true, true);
+
+ assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_REASON,
+ MessageArgument.untrusted("dimensionId", DIMENSION),
+ MessageArgument.untrusted("reason", "TimeoutException")), message);
+ }
+
+ @Test
+ public void unloadedDimensionIsReportedOnlyWhenItIsGone() {
+ assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
+ MessageArgument.untrusted("dimensionId", DIMENSION)),
+ IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, false, true));
+ assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_CANCELLED_PLAYER_OFFLINE,
+ MessageArgument.untrusted("dimensionId", DIMENSION),
+ MessageArgument.untrusted("value", "Tester")),
+ IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, false));
+ assertEquals(IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_REFUSED,
+ MessageArgument.untrusted("dimensionId", DIMENSION)),
+ IrisModdedCommands.teleportFailure(DIMENSION, "Tester", null, true, true));
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java
new file mode 100644
index 000000000..43cb8e65d
--- /dev/null
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedVisionOverlayTest.java
@@ -0,0 +1,56 @@
+package art.arcane.iris.modded.command;
+
+import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.volmlib.nativelib.view.WorldView;
+import org.junit.Test;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ModdedVisionOverlayTest {
+ /**
+ * A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
+ * bounded deadline as /iris tp.
+ */
+ @Test
+ public void teleportWaitsForAColdDestinationWithinTheTeleportDeadline() {
+ WorldView world = mock(WorldView.class);
+ doAnswer((InvocationOnMock invocation) -> {
+ invocation.getArgument(0, Runnable.class).run();
+ return null;
+ }).when(world).execute(any());
+ UUID opener = UUID.randomUUID();
+ AtomicReference requested = new AtomicReference<>();
+ when(world.teleport(eq(opener), any())).thenAnswer((InvocationOnMock invocation) -> {
+ requested.set(invocation.getArgument(1));
+ return Optional.empty();
+ });
+ Engine engine = mock(Engine.class);
+ when(engine.getMinHeight()).thenReturn(-64);
+ when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(130);
+
+ long before = System.nanoTime();
+ new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener)).teleport(-3.5D, 17.25D);
+ long after = System.nanoTime();
+
+ WorldView.Destination destination = requested.get();
+ assertEquals(-3.5D, destination.x(), 0D);
+ assertEquals(68D, destination.y(), 0D);
+ assertEquals(17.5D, destination.z(), 0D);
+ assertTrue("deadline must outlast a cold first chunk",
+ destination.deadlineNanos() - before >= TimeUnit.SECONDS.toNanos(60L));
+ assertTrue("deadline must be bounded", destination.deadlineNanos() - after <= TimeUnit.SECONDS.toNanos(120L));
+ }
+}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java
index 0e0279392..dfc35ba19 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java
@@ -2,15 +2,30 @@ package art.arcane.iris.modded.service;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedPlatformWorld;
+import art.arcane.iris.configuration.IrisSettings;
+import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineMetrics;
+import art.arcane.iris.modded.IrisModdedChunkGenerator;
+import art.arcane.iris.modded.ModdedLootApplier;
+import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.IrisWorld;
+import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.world.storage.matter.TileWrapper;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
+import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldMaintenance;
+import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
+import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
+import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.function.Consumer4;
+import art.arcane.volmlib.util.mantle.flag.MantleFlag;
+import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
+import art.arcane.volmlib.util.matter.MatterSlice;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
import net.minecraft.SharedConstants;
@@ -24,19 +39,33 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FlowingFluid;
import org.junit.Test;
import org.mockito.InOrder;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
+import org.mockito.invocation.InvocationOnMock;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.LongConsumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest {
@@ -70,6 +99,148 @@ public class ModdedChunkUpdateServiceTest {
assertTrue(chunk.deleted());
}
+ @Test
+ public void loadingSavedBiomeDefersThePassAndForgetsOnlyCompletedLoot() {
+ SharedConstants.tryDetectVersion();
+ Bootstrap.bootStrap();
+ IrisWorld world = mock(IrisWorld.class);
+ when(world.minHeight()).thenReturn(-64);
+ Engine engine = mock(Engine.class);
+ when(engine.getWorld()).thenReturn(world);
+ when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
+ ServerLevel level = mock(ServerLevel.class);
+ when(level.getMinY()).thenReturn(-64);
+ when(level.getMaxY()).thenReturn(319);
+ when(level.getHeight()).thenReturn(384);
+ when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.CHEST.defaultBlockState());
+ ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
+ SavedBiomeUnavailableException loading = new SavedBiomeUnavailableException(
+ "Saved biome information is loading at chunk 0,0. Try again shortly.", true);
+
+ try (MockedStatic loot = mockStatic(ModdedLootApplier.class)) {
+ loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
+ NativeBlockPoint position = invocation.getArgument(2);
+ if (position.x() == 2) {
+ throw loading;
+ }
+ return null;
+ });
+
+ SavedBiomeUnavailableException thrown = assertThrows(SavedBiomeUnavailableException.class,
+ () -> new ModdedChunkUpdateService().runUpdatePass(
+ engine, new ModdedPlatformWorld(level), 0, 0, chunk));
+
+ assertSame(loading, thrown);
+ }
+ assertFalse(chunk.deleted());
+ verify(chunk.slice).set(1, 5, 1, null);
+ verify(chunk.slice, never()).set(2, 5, 2, null);
+ }
+
+ @Test
+ public void loadingSavedBiomeLeavesTheChunkUnetchedWithoutAnErrorAndRetriesOnTheNextPass() {
+ ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
+ Engine engine = updateEngine(chunk);
+ NativeWorld level = chestLevel();
+ AtomicInteger loadingAttempts = new AtomicInteger();
+ ModdedChunkUpdateService service = new ModdedChunkUpdateService();
+
+ try (MockedStatic loot = mockStatic(ModdedLootApplier.class);
+ MockedStatic logging = mockStatic(IrisLogging.class)) {
+ loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
+ NativeBlockPoint position = invocation.getArgument(2);
+ if (position.x() == 2 && loadingAttempts.getAndIncrement() == 0) {
+ throw new SavedBiomeUnavailableException("Saved biome information is loading at chunk 0,0.", true);
+ }
+ return null;
+ });
+
+ service.updateRegeneratedChunk(engine, level, 0, 0);
+
+ assertFalse(chunk.isFlagged(MantleFlag.ETCHED));
+ assertFalse(chunk.isFlagged(MantleFlag.UPDATE));
+ assertFalse(chunk.deleted());
+ logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
+ logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
+
+ service.updateRegeneratedChunk(engine, level, 0, 0);
+
+ assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
+ assertTrue(chunk.deleted());
+ }
+ }
+
+ @Test
+ public void permanentSavedBiomeUnavailabilityCompletesThePassWithAWarning() {
+ ChestUpdateMantleChunk chunk = new ChestUpdateMantleChunk();
+ Engine engine = updateEngine(chunk);
+ NativeWorld level = chestLevel();
+ String reason = "This chunk has no exact saved Iris biome assignment for generation 3.";
+
+ try (MockedStatic loot = mockStatic(ModdedLootApplier.class);
+ MockedStatic logging = mockStatic(IrisLogging.class)) {
+ loot.when(() -> ModdedLootApplier.apply(any(), any(), any(), any(), any())).thenAnswer((InvocationOnMock invocation) -> {
+ NativeBlockPoint position = invocation.getArgument(2);
+ if (position.x() == 2) {
+ throw new SavedBiomeUnavailableException(reason, false);
+ }
+ return null;
+ });
+
+ new ModdedChunkUpdateService().updateRegeneratedChunk(engine, level, 0, 0);
+
+ assertTrue(chunk.isFlagged(MantleFlag.ETCHED));
+ assertTrue(chunk.deleted());
+ logging.verify(() -> IrisLogging.warnOnce(anyString(), contains(reason)));
+ logging.verify(() -> IrisLogging.reportError(any(Throwable.class)), never());
+ logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
+ }
+ }
+
+ @Test
+ public void failedChunkDoesNotStopTheLevelPassForItsOtherChunks() {
+ IrisSettings previousSettings = IrisSettings.settings;
+ IrisSettings.settings = new IrisSettings();
+ FailingMantleChunk failing = new FailingMantleChunk();
+ ChestUpdateMantleChunk healthy = new ChestUpdateMantleChunk();
+ @SuppressWarnings("unchecked")
+ Mantle mantle = mock(Mantle.class);
+ when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
+ when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> failing.use());
+ when(mantle.useChunk(4, 0)).thenAnswer((InvocationOnMock invocation) -> healthy.use());
+ Engine engine = engineWith(mantle);
+ NativeWorld level = chestLevel();
+ NativeModdedServer server = mock(NativeModdedServer.class);
+ when(server.worlds()).thenReturn(List.of(level));
+ IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
+ when(generator.engineIfBound()).thenReturn(engine);
+
+ try (MockedStatic generators = mockStatic(NativeWorldGenerators.class);
+ MockedStatic loot = mockStatic(ModdedLootApplier.class);
+ MockedStatic logging = mockStatic(IrisLogging.class);
+ MockedConstruction maintenance = mockConstruction(NativeWorldMaintenance.class,
+ (NativeWorldMaintenance mocked, MockedConstruction.Context context) -> {
+ when(mocked.hasForcedChunks()).thenReturn(true);
+ doAnswer((InvocationOnMock invocation) -> {
+ LongConsumer action = invocation.getArgument(0);
+ action.accept(0L);
+ action.accept(4L);
+ return null;
+ }).when(mocked).forEachForcedChunk(any(LongConsumer.class));
+ })) {
+ generators.when(() -> NativeWorldGenerators.find(level, IrisModdedChunkGenerator.class)).thenReturn(generator);
+
+ new ModdedChunkUpdateService().onServerTick(server);
+
+ assertFalse(failing.isFlagged(MantleFlag.ETCHED));
+ assertTrue(healthy.isFlagged(MantleFlag.ETCHED));
+ assertTrue(healthy.deleted());
+ logging.verify(() -> IrisLogging.errorOnce(anyString(), anyString()));
+ } finally {
+ IrisSettings.settings = previousSettings;
+ }
+ }
+
@Test
public void scansWhenPlayersArePresent() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
@@ -166,6 +337,94 @@ public class ModdedChunkUpdateServiceTest {
}
}
+ private static final class ChestUpdateMantleChunk extends MantleChunk {
+ @SuppressWarnings("unchecked")
+ private final MatterSlice slice = mock(MatterSlice.class);
+ private final Matter section = mock(Matter.class);
+ private boolean deleted;
+
+ private ChestUpdateMantleChunk() {
+ super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
+ when(section.hasSlice(MatterUpdate.class)).thenReturn(true);
+ when(section.getSlice(MatterUpdate.class)).thenReturn(slice);
+ }
+
+ @Override
+ public Matter get(int index) {
+ return section;
+ }
+
+ @Override
+ public void iterate(
+ Class type,
+ Consumer4 iterator
+ ) {
+ if (type == MatterUpdate.class) {
+ iterator.accept(1, 5, 1, type.cast(UpdateMatter.ON));
+ iterator.accept(2, 5, 2, type.cast(UpdateMatter.ON));
+ }
+ }
+
+ @Override
+ public void deleteSlices(Class> type) {
+ if (type == MatterUpdate.class) {
+ deleted = true;
+ }
+ }
+
+ private boolean deleted() {
+ return deleted;
+ }
+ }
+
+ private static Engine updateEngine(ChestUpdateMantleChunk chunk) {
+ @SuppressWarnings("unchecked")
+ Mantle mantle = mock(Mantle.class);
+ when(mantle.isChunkLoaded(0, 0)).thenReturn(true);
+ when(mantle.hasFlag(0, 0, MantleFlag.ETCHED)).thenAnswer((InvocationOnMock invocation) -> chunk.isFlagged(MantleFlag.ETCHED));
+ when(mantle.useChunk(0, 0)).thenAnswer((InvocationOnMock invocation) -> chunk.use());
+ return engineWith(mantle);
+ }
+
+ private static Engine engineWith(Mantle mantle) {
+ IrisWorld world = mock(IrisWorld.class);
+ when(world.minHeight()).thenReturn(-64);
+ EngineMantle engineMantle = mock(EngineMantle.class);
+ when(engineMantle.getMantle()).thenReturn(mantle);
+ Engine engine = mock(Engine.class);
+ when(engine.getWorld()).thenReturn(world);
+ when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
+ when(engine.getMantle()).thenReturn(engineMantle);
+ return engine;
+ }
+
+ private static NativeWorld chestLevel() {
+ NativeBlockState chest = mock(NativeBlockState.class);
+ when(chest.isStorage()).thenReturn(true);
+ when(chest.isStorageChest()).thenReturn(true);
+ NativeWorld level = mock(NativeWorld.class);
+ when(level.name()).thenReturn("example:updates");
+ when(level.minHeight()).thenReturn(-64);
+ when(level.maxHeight()).thenReturn(320);
+ when(level.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
+ when(level.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(chest);
+ return level;
+ }
+
+ private static final class FailingMantleChunk extends MantleChunk {
+ private FailingMantleChunk() {
+ super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
+ }
+
+ @Override
+ public void iterate(
+ Class type,
+ Consumer4 iterator
+ ) {
+ throw new IllegalStateException("corrupt " + type.getSimpleName() + " slice");
+ }
+ }
+
@SuppressWarnings("unchecked")
private static MantleDataAdapter emptyAdapter() {
return (MantleDataAdapter) Proxy.newProxyInstance(
diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java
index afc475c96..e871bc6b4 100644
--- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java
+++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java
@@ -24,6 +24,7 @@ import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.structure.object.IrisObjectPlacement;
import art.arcane.iris.pack.loading.IrisData;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.spi.IrisLogging;
@@ -233,6 +234,7 @@ final class IrisBiomeLayerGenerator {
data.add(layer.get(random, layerIndex + offset,
(wx + offset) / zoom, offset, (wz - offset) / zoom, rdata));
} catch (Throwable error) {
+ GenerationFailures.rethrowEngineFailure(error);
IrisLogging.reportError(error);
}
}
@@ -330,6 +332,7 @@ final class IrisBiomeLayerGenerator {
blocks.add(layer.get(random, layerIndex + offset,
(x + offset) / zoom, offset, (z - offset) / zoom, data));
} catch (Throwable error) {
+ GenerationFailures.rethrowEngineFailure(error);
IrisLogging.reportError(error);
}
}
diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java
index 6c408f217..1a372cd96 100644
--- a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java
+++ b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java
@@ -18,6 +18,7 @@
package art.arcane.iris.generation.decoration;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.biome.IrisBiome;
@@ -89,6 +90,7 @@ final class DecoratorCore {
}
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
@@ -163,6 +165,7 @@ final class DecoratorCore {
data.set(x, lowerY, z, lower);
data.set(x, upperY, z, upper);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
return;
@@ -356,6 +359,7 @@ final class DecoratorCore {
data.set(xf, lowerY, zf, lower);
data.set(xf, upperY, zf, upper);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
return;
diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java
index ffccce3ab..bc8178418 100644
--- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java
+++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.hydrology;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.stream.ProvisionalSampling;
@@ -713,7 +714,7 @@ public final class HydrologyTileCache implements AutoCloseable {
public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) {
if (closed.get()) {
- throw new IllegalStateException("Hydrology tile cache is closed.");
+ throw new GenerationClosedException("Hydrology tile cache is closed.");
}
sharedCacheScope = Objects.requireNonNull(scope, "scope");
persistentStore = persistentRoot == null
diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java
index bd7929e59..172478b39 100644
--- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java
+++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleFloatingObjectComponent.java
@@ -20,6 +20,7 @@ package art.arcane.iris.generation.mantle;
import java.io.UncheckedIOException;
import art.arcane.iris.pack.loading.IrisData;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.cache.Cache;
import art.arcane.iris.generation.biome.FloatingIslandSample;
@@ -231,6 +232,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
throw failure;
} catch (Throwable e) {
transaction.discard();
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
@@ -326,6 +328,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
throw failure;
} catch (Throwable e) {
transaction.discard();
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
@@ -438,6 +441,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
throw failure;
} catch (Throwable e) {
transaction.discard();
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
@@ -611,11 +615,13 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
maxObjectExtent = Math.max(maxObjectExtent, computePlacementRadius(entry.resolveBottomObjects(target), data, sizeCache, warnedLargeObjects));
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
return maxObjectExtent;
@@ -649,6 +655,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
}
radius = Math.max(radius, reach);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java
index 32f3478c2..ca4dad733 100644
--- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java
+++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java
@@ -22,6 +22,7 @@ package art.arcane.iris.generation.mantle;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.cache.Cache;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.UpperDimensionContext;
import art.arcane.iris.generation.runtime.Engine;
@@ -475,6 +476,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
nullObjects += result.nullObjects();
errors += result.errors();
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Failed to place objects in the following biome: " + surfaceBiome.getName());
@@ -508,6 +510,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
nullObjects += result.nullObjects();
errors += result.errors();
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Failed to place cave objects in the following biome: " + caveBiome.getName());
@@ -538,6 +541,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
nullObjects += result.nullObjects();
errors += result.errors();
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Failed to place objects in the following region: " + region.getName());
@@ -571,6 +575,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
nullObjects += result.nullObjects();
errors += result.errors();
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Failed to place cave objects in the following region: " + region.getName());
@@ -760,6 +765,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
+ " commit=" + commitResult);
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
IrisLogging.error("Failed to place procedural object '" + p.getName() + "' in " + scope);
}
@@ -988,6 +994,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
+ " density=" + density);
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Regen object placement exception: chunk=" + chunkX + "," + chunkZ
@@ -1192,6 +1199,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
+ " density=" + density);
}
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
errors++;
IrisLogging.reportError(e);
IrisLogging.error("Regen cave object placement exception: chunk=" + metricChunkX + "," + metricChunkZ
@@ -1251,6 +1259,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
try {
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-biome-surface");
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
IrisLogging.error("Failed to place upper-dimension objects in biome " + upperBiome.getName()
+ ": " + i.getPlace().toString(", ") + " (" + e.getClass().getSimpleName() + ")");
@@ -1266,6 +1275,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
try {
placeUpperObject(writer, rng, chunkX, chunkZ, i, upperCtx, complex, forcePlace, traceRegen, "upper-region-surface");
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
IrisLogging.error("Failed to place upper-dimension objects in region " + upperRegion.getName()
+ ": " + i.getPlace().toString(", ") + " (" + e.getClass().getSimpleName() + ")");
@@ -1790,6 +1800,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
}
radius = Math.max(radius, reach);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
}
}
diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java
index b0d73e233..90b3ac9fb 100644
--- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java
+++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java
@@ -29,6 +29,7 @@ import art.arcane.iris.integration.Identifier;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.iris.world.history.TransitionGenerationPlan;
@@ -1495,6 +1496,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable {
try {
setData(pos.getX(), pos.getY(), pos.getZ(), data);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
// Reached per position while an object writes past the edge of the writer window, which is
// how a large object is clipped. Nothing here is actionable from outside the engine.
IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString());
diff --git a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java
index d0fe437c9..61216e507 100644
--- a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java
+++ b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java
@@ -23,7 +23,6 @@ import art.arcane.iris.generation.stage.IrisDecorantActuator;
import art.arcane.iris.generation.stage.IrisTerrainNormalActuator;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineMode;
-import art.arcane.iris.generation.runtime.EnginePlatformHooks;
import art.arcane.iris.generation.runtime.EngineStage;
import art.arcane.iris.generation.runtime.IrisEngineMode;
import art.arcane.iris.generation.stage.IrisCarveModifier;
@@ -32,17 +31,10 @@ import art.arcane.iris.generation.stage.IrisDepositModifier;
import art.arcane.iris.generation.stage.IrisFloatingChildBiomeModifier;
import art.arcane.iris.generation.stage.IrisPerfectionModifier;
import art.arcane.iris.generation.stage.IrisPostModifier;
-import art.arcane.iris.spi.IrisLogging;
-
-import java.util.concurrent.atomic.AtomicLong;
public class ModeOverworld extends IrisEngineMode implements EngineMode {
- private static final AtomicLong lastMaintenanceBypassLog = new AtomicLong(0L);
- private final EnginePlatformHooks platformHooks;
-
public ModeOverworld(Engine engine) {
super(engine);
- platformHooks = engine.getPlatformHooks();
IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine());
IrisBiomeActuator biome = new IrisBiomeActuator(getEngine());
IrisDecorantActuator decorant = new IrisDecorantActuator(getEngine());
@@ -53,51 +45,17 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode {
IrisCustomModifier custom = new IrisCustomModifier(getEngine());
IrisFloatingChildBiomeModifier floatingChildBiomes = new IrisFloatingChildBiomeModifier(getEngine());
EngineStage sBiome = (x, z, k, p, m, c) -> biome.actuate(x, z, p, m, c);
- EngineStage sGenMatter = (x, z, k, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- generateTerrainMatter(
- x >> 4,
- z >> 4,
- m || getEngine().isStudio(),
- c);
- };
+ EngineStage sGenMatter = (x, z, k, p, m, c) -> generateTerrainMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c);
EngineStage sTerrain = (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c);
EngineStage sDecorant = (x, z, k, p, m, c) -> decorant.actuate(x, z, k, m, c);
- EngineStage sCave = (x, z, k, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- cave.modify(x >> 4, z >> 4, k, m, c);
- };
- EngineStage sDeposit = (x, z, k, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- deposit.modify(x, z, k, m, c);
- };
- EngineStage sPost = (x, z, k, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- post.modify(x, z, k, m, c);
- };
- EngineStage sInsertMatter = (x, z, K, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- getMantle().insertMatter(x >> 4, z >> 4, K, m, c);
- };
+ EngineStage sCave = (x, z, k, p, m, c) -> cave.modify(x >> 4, z >> 4, k, m, c);
+ EngineStage sDeposit = (x, z, k, p, m, c) -> deposit.modify(x, z, k, m, c);
+ EngineStage sPost = (x, z, k, p, m, c) -> post.modify(x, z, k, m, c);
+ EngineStage sInsertMatter = (x, z, K, p, m, c) -> getMantle().insertMatter(x >> 4, z >> 4, K, m, c);
EngineStage sFloatingTerrainSolid = (x, z, k, p, m, c) -> floatingChildBiomes.modify(x, z, k, m, c);
EngineStage sFloatingDecorate = (x, z, k, p, m, c) -> floatingChildBiomes.decorateColumns(x, z, k, m, c);
EngineStage sPerfection = (x, z, k, p, m, c) -> perfection.modify(x, z, k, m, c);
- EngineStage sCustom = (x, z, k, p, m, c) -> {
- if (shouldBypassMantleStages()) {
- return;
- }
- custom.modify(x, z, k, m, c);
- };
+ EngineStage sCustom = (x, z, k, p, m, c) -> custom.modify(x, z, k, m, c);
// Matter runs on the calling thread so its window fans out across the burst pool (a pool
// thread would run every chunk of the window inline) while biome and terrain run alongside.
@@ -114,11 +72,7 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode {
// the surface sInsertMatter writes), so parallel order is scheduler-dependent. The
// production path already runs them inline in this order; sequential registration
// makes studio (the only multicore path) match production and the goldenhash baseline.
- registerStage((x, z, k, p, m, c) -> {
- if (!shouldBypassMantleStages()) {
- generateContentMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c);
- }
- });
+ registerStage((x, z, k, p, m, c) -> generateContentMatter(x >> 4, z >> 4, m || getEngine().isStudio(), c));
registerStage(sDeposit);
registerStage(sInsertMatter);
registerStage(sDecorant);
@@ -128,17 +82,4 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode {
registerStage(sCustom);
}
}
-
- private boolean shouldBypassMantleStages() {
- boolean active = platformHooks.shouldBypassMantleStages(getEngine());
- if (active) {
- long now = System.currentTimeMillis();
- long last = lastMaintenanceBypassLog.get();
- if (now - last >= 5000L && lastMaintenanceBypassLog.compareAndSet(last, now)) {
- IrisLogging.info("Maintenance regen bypass: skipping mantle-backed overworld stages for Folia safety.");
- }
- }
- return active;
- }
-
}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java
index 5f563e06c..a5449c555 100644
--- a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java
@@ -335,6 +335,7 @@ public final class DimensionTerrainContext implements DataProvider {
}
return new NoiseBounds(minimum, maximum);
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
IrisLogging.reportError(e);
return ZERO_NOISE_BOUNDS;
}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java
index 44661074d..f8dabff8c 100644
--- a/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/EnginePlatformHooks.java
@@ -94,7 +94,11 @@ public interface EnginePlatformHooks {
return false;
}
- default boolean shouldBypassMantleStages(Engine engine) {
+ /**
+ * True while world maintenance forbids the mantle-backed stages. Generating without them would persist a
+ * chunk with no objects, caves or deposits, so the engine refuses the chunk instead.
+ */
+ default boolean shouldRefuseGeneration(Engine engine) {
return false;
}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java
new file mode 100644
index 000000000..fe6f0ee7d
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationClosedException.java
@@ -0,0 +1,7 @@
+package art.arcane.iris.generation.runtime;
+
+public final class GenerationClosedException extends IllegalStateException {
+ public GenerationClosedException(String message) {
+ super(message);
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java
new file mode 100644
index 000000000..0ecab8a9c
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java
@@ -0,0 +1,73 @@
+package art.arcane.iris.generation.runtime;
+
+import art.arcane.iris.world.history.SavedBiomeUnavailableException;
+import art.arcane.volmlib.util.mantle.MantleClosedException;
+
+import java.io.InterruptedIOException;
+import java.nio.channels.ClosedChannelException;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Predicate;
+
+/**
+ * Separates failures of the engine, its mantle, generation history, executors or the JVM from failures of one
+ * piece of content. Content loops (objects, decorators, layers) may skip a failed item because every rebuild
+ * skips it the same way. An engine failure is transient, so skipping would cache and persist terrain that a
+ * healthy engine never produces: it must fail the whole chunk instead.
+ */
+public final class GenerationFailures {
+ private static final int MAXIMUM_CAUSE_DEPTH = 32;
+
+ private GenerationFailures() {
+ }
+
+ public static boolean isEngineFailure(Throwable failure) {
+ return isShutdownFailure(failure) || hasCause(failure, (Throwable cause) -> cause instanceof Error
+ || cause instanceof WrongEngineBroException
+ || cause instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading()
+ || cause instanceof TimeoutException);
+ }
+
+ /**
+ * The engine failures that only mean a runtime, pool or thread is stopping. They say nothing about the world, so
+ * nothing may remember them against it once the next runtime is up.
+ */
+ public static boolean isShutdownFailure(Throwable failure) {
+ return Thread.currentThread().isInterrupted() || hasCause(failure, (Throwable cause) ->
+ cause instanceof GenerationClosedException
+ || cause instanceof MantleClosedException
+ || cause instanceof GenerationSessionException session && session.isExpectedTeardown()
+ || cause instanceof InterruptedException
+ || cause instanceof InterruptedIOException
+ || cause instanceof ClosedChannelException
+ || cause instanceof RejectedExecutionException
+ || cause instanceof CancellationException);
+ }
+
+ private static boolean hasCause(Throwable failure, Predicate match) {
+ Throwable current = failure;
+ for (int depth = 0; current != null && depth < MAXIMUM_CAUSE_DEPTH; depth++, current = current.getCause()) {
+ if (match.test(current)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static void rethrowEngineFailure(Throwable failure) {
+ if (!isEngineFailure(failure)) {
+ return;
+ }
+ if (failure instanceof RuntimeException runtimeFailure) {
+ throw runtimeFailure;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ throw new IllegalStateException("Iris generation stopped: " + failure, failure);
+ }
+}
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java
index f9f4b1b31..ffd5b3872 100644
--- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java
@@ -2044,6 +2044,7 @@ public class IrisComplex implements DataProvider {
GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds);
return bounds.noiseBounds;
} catch (Throwable e) {
+ GenerationFailures.rethrowEngineFailure(e);
long now = System.currentTimeMillis();
long last = lastBoundsFailureLog.get();
// The five second gate keeps this off the hot path; the once key keeps it out of a log
diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
index 5fb5cd1e6..905f4ff78 100644
--- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
+++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java
@@ -86,6 +86,8 @@ import java.util.function.Supplier;
@Data
public class IrisEngine implements Engine {
static final long SESSION_DRAIN_TIMEOUT_MILLIS = 15000L;
+ private static final long MAINTENANCE_REFUSAL_WARNING_MILLIS = 5000L;
+ private static final AtomicLong lastMaintenanceRefusalWarning = new AtomicLong();
private final AtomicInteger bud;
private final AtomicInteger buds;
@@ -1083,10 +1085,10 @@ public class IrisEngine implements Engine {
throw new IllegalArgumentException("Generation runtime binding belongs to a different Iris engine.");
}
if (retiringGenerationRuntimes.contains(required.runtime)) {
- throw new IllegalStateException("Iris generation runtime binding is retiring.");
+ throw new GenerationClosedException("Iris generation runtime binding is retiring.");
}
if (!isGenerationRuntimeBindingLive(required)) {
- throw new IllegalStateException("Iris generation runtime binding is closed or no longer owned.");
+ throw new GenerationClosedException("Iris generation runtime binding is closed or no longer owned.");
}
return threadState.open(required);
}
@@ -1485,6 +1487,9 @@ public class IrisEngine implements Engine {
@Override
public void generate(int x, int z, Hunk vblocks, Hunk vbiomes, boolean multicore) throws WrongEngineBroException {
awaitGenerationCacheWarm();
+ if (platformHooks.shouldRefuseGeneration(this)) {
+ refuseMaintenanceGeneration(x >> 4, z >> 4);
+ }
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope =
openGenerationHistoryCoordinateScope(x, z);
GenerationSessionLease lease = acquireGenerationLease("chunk_generate");
@@ -1505,20 +1510,17 @@ public class IrisEngine implements Engine {
activeMode.generate(x, z, blocks, vbiomes, multicore, lease.sessionId());
}
- boolean skipRealFlag = platformHooks.shouldBypassMantleStages(this);
- if (!skipRealFlag) {
- MantleChunk chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4);
- try {
- synchronized (chunk) {
- Matter section = chunk.get(0);
- if (section != null) {
- section.deleteSlice(ObjectContinuationBundle.class);
- }
- chunk.flag(MantleFlag.REAL, true);
+ MantleChunk chunk = getMantle().getMantle().useChunk(x >> 4, z >> 4);
+ try {
+ synchronized (chunk) {
+ Matter section = chunk.get(0);
+ if (section != null) {
+ section.deleteSlice(ObjectContinuationBundle.class);
}
- } finally {
- chunk.release();
+ chunk.flag(MantleFlag.REAL, true);
}
+ } finally {
+ chunk.release();
}
getMetrics().getTotal().put(p.getMilliseconds());
generated.incrementAndGet();
@@ -1538,6 +1540,17 @@ public class IrisEngine implements Engine {
}
}
+ private void refuseMaintenanceGeneration(int chunkX, int chunkZ) throws GenerationSessionException {
+ long now = System.currentTimeMillis();
+ long last = lastMaintenanceRefusalWarning.get();
+ if (now - last >= MAINTENANCE_REFUSAL_WARNING_MILLIS && lastMaintenanceRefusalWarning.compareAndSet(last, now)) {
+ IrisLogging.warn("Iris refused chunk " + chunkX + "," + chunkZ + " in " + getWorld().name()
+ + ": world maintenance forbids mantle generation, the chunk stays ungenerated");
+ }
+ throw new GenerationSessionException("Iris refused chunk " + chunkX + "," + chunkZ + " in "
+ + getWorld().name() + " during world maintenance.", true);
+ }
+
@Override
public GenerationSessionManager getGenerationSessions() {
return generationSessions;
@@ -1682,7 +1695,7 @@ public class IrisEngine implements Engine {
void requireRunning(String operation) {
if (closed || closing.get() || lifecycleState != LifecycleState.RUNNING || runtime == null) {
- throw new IllegalStateException("Cannot " + operation + " while Iris engine " + getWorld().name()
+ throw new GenerationClosedException("Cannot " + operation + " while Iris engine " + getWorld().name()
+ " is " + lifecycleState.name().toLowerCase() + ".");
}
}
diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java
index de24fa14d..75530bb34 100644
--- a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java
+++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java
@@ -21,6 +21,7 @@ package art.arcane.iris.generation.stage;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.DimensionStackLayout;
+import art.arcane.iris.generation.runtime.GenerationFailures;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.decoration.FloatingDecorator;
import art.arcane.iris.generation.decoration.IrisSeaSurfaceDecorator;
@@ -100,6 +101,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier {
diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java
index 4acd70858..95985aeac 100644
--- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java
+++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java
@@ -459,12 +459,14 @@ public final class StudioOpenCoordinator {
) {
AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
- if (world != null) {
- IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
- }
CompletableFuture sequence = sequenceStudioClose(
() -> evacuateWorldFamily(worldName, world),
+ () -> {
+ if (world != null) {
+ IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
+ }
+ },
() -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
() -> deleteFolder
@@ -491,14 +493,22 @@ public final class StudioOpenCoordinator {
});
}
+ /**
+ * Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
+ * system failure, so it is entered only once no player is left to request one.
+ */
static CompletableFuture sequenceStudioClose(
Supplier> evacuate,
+ Runnable enterMaintenance,
Supplier> unload,
Supplier> closeGenerator,
Supplier> deleteFolders
) {
return invokePhase(evacuate)
- .thenCompose(ignored -> invokePhase(unload))
+ .thenCompose(ignored -> {
+ enterMaintenance.run();
+ return invokePhase(unload);
+ })
.thenCompose(ignored -> invokePhase(closeGenerator))
.thenCompose(ignored -> invokePhase(deleteFolders));
}
diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java
index e8d90b201..2040c60a9 100644
--- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java
+++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java
@@ -756,12 +756,17 @@ public class IrisCreator {
}
}
if (activeWorld != null) {
- IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true);
+ boolean maintenanceEntered = false;
try {
PlatformChunkGenerator generator = IrisToolbelt.access(activeWorld);
boolean evacuated = Boolean.TRUE.equals(IrisToolbelt.evacuateAsync(activeWorld)
.get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
- if (!evacuated) {
+ if (evacuated) {
+ // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk
+ // as a chunk system failure, so it starts only once no player is left to request one.
+ IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true);
+ maintenanceEntered = true;
+ } else {
safeToDelete = false;
failure.addSuppressed(new IllegalStateException(
"Rollback could not evacuate world \"" + name + "\"."));
@@ -789,7 +794,9 @@ public class IrisCreator {
ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\".");
}
} finally {
- IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true);
+ if (maintenanceEntered) {
+ IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true);
+ }
}
}
diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java
index 98c4bec63..4ffc516bb 100644
--- a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java
+++ b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java
@@ -18,11 +18,14 @@ import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
+import java.util.Set;
import java.util.stream.Stream;
public final class IrisWorldStorage {
private static final String IRIS_NAMESPACE = "iris";
private static final String DEFAULT_LEVEL_NAME = "world";
+ private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
+ private static final String APPLE_DOUBLE_PREFIX = "._";
/**
* Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain
@@ -327,6 +330,46 @@ public final class IrisWorldStorage {
return target.toFile();
}
+ /**
+ * True when the world folder's {@code iris/} entry holds anything of Iris' own, which makes the folder an Iris
+ * world whatever its dimension namespace.
+ *
+ * The desktop file managers recreate a directory to hold their own metadata while a delete is still in
+ * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
+ * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
+ * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
+ * the list all count as content.
+ */
+ public static boolean holdsIrisContent(Path dimensionRoot) {
+ Path irisRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").resolve(IRIS_NAMESPACE);
+ if (Files.isSymbolicLink(irisRoot)) {
+ return true;
+ }
+ if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
+ return true;
+ }
+ try (Stream entries = Files.list(irisRoot)) {
+ return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
+ } catch (IOException unreadable) {
+ return true;
+ }
+ }
+
+ private static boolean isOperatingSystemMetadata(Path entry) {
+ if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
+ return false;
+ }
+ Path name = entry.getFileName();
+ if (name == null) {
+ return false;
+ }
+ String fileName = name.toString();
+ return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
+ }
+
/**
* True when the level root holds at least one Iris-managed world directory. Used by the failure paths
* that have to decide whether letting the server continue would put vanilla terrain into Iris storage.
diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationAdmission.java b/core/src/main/java/art/arcane/iris/world/history/GenerationAdmission.java
index 29f67d874..b4972a0ea 100644
--- a/core/src/main/java/art/arcane/iris/world/history/GenerationAdmission.java
+++ b/core/src/main/java/art/arcane/iris/world/history/GenerationAdmission.java
@@ -1,5 +1,7 @@
package art.arcane.iris.world.history;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
+
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@@ -137,7 +139,7 @@ public final class GenerationAdmission {
private void requireOpen() {
if (closed) {
- throw new IllegalStateException("Generation admission belongs to a closed runtime.");
+ throw new GenerationClosedException("Generation admission belongs to a closed runtime.");
}
}
diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java
index 82a5d6c50..e87679454 100644
--- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java
+++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java
@@ -1,5 +1,6 @@
package art.arcane.iris.world.history;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.pack.AtomicDirectoryPublisher;
import art.arcane.iris.world.storage.Durability;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
@@ -1386,7 +1387,7 @@ public final class GenerationHistory {
throw new IllegalArgumentException("Generation stage belongs to a different history.");
}
if (closed) {
- throw new IllegalStateException("Generation stage is closed.");
+ throw new GenerationClosedException("Generation stage is closed.");
}
}
}
diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java
index 42a244213..60608b719 100644
--- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java
+++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistoryRuntimeRouter.java
@@ -9,6 +9,7 @@ import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.generation.runtime.EngineTarget;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.generation.runtime.GenerationTransitionGate;
import art.arcane.iris.generation.mantle.ObjectContinuationBundle;
import art.arcane.iris.world.storage.matter.IrisMatterContext;
@@ -749,7 +750,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
throw propagate(entry.failure, "Saved mantle for activation " + entry.activationId + " is unavailable.");
}
if (closed) {
- throw new IllegalStateException("Generation-history runtime router is closed.");
+ throw new GenerationClosedException("Generation-history runtime router is closed.");
}
try {
inactive.await();
@@ -800,7 +801,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
private void awaitRetirementLocked(long activationId) throws IOException {
while (true) {
if (closed) {
- throw new IllegalStateException("Generation-history runtime router is closed.");
+ throw new GenerationClosedException("Generation-history runtime router is closed.");
}
RuntimeRetirement retirement = retiringBindings.get(activationId);
if (retirement == null) {
@@ -1032,7 +1033,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
stateLock.lock();
try {
if (closed) {
- throw new IllegalStateException("Generation-history runtime router is closed.");
+ throw new GenerationClosedException("Generation-history runtime router is closed.");
}
activeOperations++;
operationDepth.set(operationDepth.get() + 1);
@@ -1045,7 +1046,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
stateLock.lock();
try {
if (closed) {
- throw new IllegalStateException("Generation-history runtime router is closed.");
+ throw new GenerationClosedException("Generation-history runtime router is closed.");
}
activeOperations++;
} finally {
@@ -1202,7 +1203,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
public Scope openScope() {
synchronized (this) {
if (closed) {
- throw new IllegalStateException("Saved chunk mantle is closed.");
+ throw new GenerationClosedException("Saved chunk mantle is closed.");
}
activeScopes++;
}
@@ -1535,7 +1536,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
public RuntimeScope openRuntimeScope() {
synchronized (this) {
if (closed) {
- throw new IllegalStateException("Generation-history runtime route is closed.");
+ throw new GenerationClosedException("Generation-history runtime route is closed.");
}
activeScopes++;
}
diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java
index c84f34c9d..2b035ab23 100644
--- a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java
+++ b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java
@@ -552,15 +552,14 @@ public final class GenerationRegistryContractFactory {
for (Map.Entry
entry
: requiredContract.generatedSources().entrySet()) {
- GenerationRegistryContract.GeneratedSource previous = generatedSources.get(entry.getKey());
+ GenerationRegistryContract.GeneratedSource previous = generatedSources.putIfAbsent(
+ entry.getKey(),
+ entry.getValue()
+ );
if (previous != null && !sameGeneratedSemantic(previous, entry.getValue())) {
throw new IOException("Retained generation epochs require conflicting generated sources for "
+ entry.getKey().registryKey() + " / " + entry.getKey().resourceKey() + ".");
}
- generatedSources.put(
- entry.getKey(),
- preferredGeneratedSource(previous, entry.getValue(), registry)
- );
}
}
Map captured = new TreeMap<>();
@@ -596,18 +595,6 @@ public final class GenerationRegistryContractFactory {
return GenerationRegistryContract.fromDefinitions(captured);
}
- public static String requireGeneratedSource(
- GenerationRegistryContract contract,
- GenerationRegistryContract.PhysicalResourceKey key
- ) throws IOException {
- return requireGeneratedSource(
- contract,
- key,
- DataVersion.getRuntime().get(),
- IrisPlatforms.get().registries().generationRegistry()
- );
- }
-
public static String renderGeneratedSource(
GenerationRegistryContract contract,
GenerationRegistryContract.PhysicalResourceKey key,
@@ -622,68 +609,9 @@ public final class GenerationRegistryContractFactory {
+ requiredKey.registryKey() + " / " + requiredKey.resourceKey() + ".");
}
requireGeneratedSemanticFingerprint(requiredKey, source, requiredContract.definitions().get(requiredKey));
- String sourceFingerprint = fingerprintDefinition(requiredKey,
- PlatformGenerationRegistry.Definition.exactJson(source.sourceJson()), "generated");
- if (!source.renderedDefinitionSha256().equals(sourceFingerprint)) {
- throw new IOException("Historical generated registry source changed for "
- + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + ".");
- }
return renderGeneratedSemantic(requiredKey, source, requiredFixer);
}
- public static String requireGeneratedSource(
- GenerationRegistryContract contract,
- GenerationRegistryContract.PhysicalResourceKey key,
- IDataFixer fixer
- ) throws IOException {
- return requireGeneratedSource(
- contract,
- key,
- fixer,
- IrisPlatforms.get().registries().generationRegistry()
- );
- }
-
- public static String requireGeneratedSource(
- GenerationRegistryContract contract,
- GenerationRegistryContract.PhysicalResourceKey key,
- PlatformGenerationRegistry generationRegistry
- ) throws IOException {
- return requireGeneratedSource(
- contract,
- key,
- DataVersion.getRuntime().get(),
- generationRegistry
- );
- }
-
- public static String requireGeneratedSource(
- GenerationRegistryContract contract,
- GenerationRegistryContract.PhysicalResourceKey key,
- IDataFixer fixer,
- PlatformGenerationRegistry generationRegistry
- ) throws IOException {
- GenerationRegistryContract requiredContract = Objects.requireNonNull(contract, "contract");
- GenerationRegistryContract.PhysicalResourceKey requiredKey = Objects.requireNonNull(key, "key");
- IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer");
- PlatformGenerationRegistry registry = Objects.requireNonNull(
- generationRegistry,
- "generationRegistry"
- );
- GenerationRegistryContract.GeneratedSource source = requiredContract.generatedSources().get(requiredKey);
- if (source == null) {
- throw new IOException("Historical generated registry source is missing for "
- + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + ".");
- }
- String expectedFingerprint = requiredContract.definitions().get(requiredKey);
- if (expectedFingerprint == null) {
- throw new IOException("Historical generated registry definition is missing for "
- + requiredKey.registryKey() + " / " + requiredKey.resourceKey() + ".");
- }
- requireGeneratedSemanticFingerprint(requiredKey, source, expectedFingerprint);
- return canonicalGeneratedDefinition(requiredKey, source, requiredFixer, registry).value();
- }
-
public static String fingerprintDefinition(
GenerationRegistryContract.PhysicalResourceKey key,
PlatformGenerationRegistry.Definition definition,
@@ -783,7 +711,7 @@ public final class GenerationRegistryContractFactory {
DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA,
GenerationEpochContractFactory.fingerprintDimensionType(dimension.getDimensionType()),
GenerationEpochContractFactory.dimensionTypeSemanticJson(dimension.getDimensionType()),
- registry.generatedDefinitionRendererIdentity(),
+ rendererIdentity(fixer),
canonicalDefinition
);
}
@@ -833,7 +761,7 @@ public final class GenerationRegistryContractFactory {
CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
generated.semanticFingerprint(),
generated.semanticJson(),
- registry.generatedDefinitionRendererIdentity(),
+ rendererIdentity(fixer),
generated.definition()
);
Set tags = biomeTags.computeIfAbsent(generated.physicalKey().resourceKey(), ignored -> new TreeSet<>());
@@ -882,7 +810,7 @@ public final class GenerationRegistryContractFactory {
CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
generated.semanticFingerprint(),
generated.semanticJson(),
- registry.generatedDefinitionRendererIdentity(),
+ rendererIdentity(fixer),
generated.definition()
);
}
@@ -1087,32 +1015,9 @@ public final class GenerationRegistryContractFactory {
}
}
- private static GenerationRegistryContract.GeneratedSource preferredGeneratedSource(
- GenerationRegistryContract.GeneratedSource previous,
- GenerationRegistryContract.GeneratedSource candidate,
- PlatformGenerationRegistry registry
- ) {
- if (previous == null) {
- return candidate;
- }
- String currentRenderer = requireText(
- registry.generatedDefinitionRendererIdentity(),
- "Generated registry renderer identity"
- );
- boolean previousMatches = previous.rendererIdentity().equals(currentRenderer);
- boolean candidateMatches = candidate.rendererIdentity().equals(currentRenderer);
- if (previousMatches != candidateMatches) {
- return candidateMatches ? candidate : previous;
- }
- return generatedSourceOrder(candidate).compareTo(generatedSourceOrder(previous)) < 0
- ? candidate
- : previous;
- }
-
- private static String generatedSourceOrder(GenerationRegistryContract.GeneratedSource source) {
- return source.rendererIdentity() + '\u0000'
- + source.renderedDefinitionSha256() + '\u0000'
- + source.sourceJson();
+ // Recorded for diagnostics only. Validation always re-renders the semantics with the current fixer.
+ private static String rendererIdentity(IDataFixer fixer) {
+ return fixer.getClass().getName();
}
private static boolean sameGeneratedSemantic(
@@ -1130,36 +1035,19 @@ public final class GenerationRegistryContractFactory {
IDataFixer fixer,
PlatformGenerationRegistry registry
) throws IOException {
- String currentRenderer = requireText(
- registry.generatedDefinitionRendererIdentity(),
- "Generated registry renderer identity"
- );
- String renderedSource;
- if (source.rendererIdentity().equals(currentRenderer)) {
- renderedSource = source.sourceJson();
- } else {
- renderedSource = renderGeneratedSemantic(key, source, fixer);
- }
- PlatformGenerationRegistry.Definition definition;
+ String renderedSource = renderGeneratedSemantic(key, source, fixer);
try {
- definition = registry.canonicalDefinition(
+ PlatformGenerationRegistry.Definition definition = registry.canonicalDefinition(
key.registryKey(),
key.resourceKey(),
renderedSource
);
requireGeneratedDefinition(definition, "historical generated registry definition");
+ return definition;
} catch (RuntimeException failure) {
throw new IOException("Historical generated registry source is incompatible for "
+ key.registryKey() + " / " + key.resourceKey() + ".", failure);
}
- if (source.rendererIdentity().equals(currentRenderer)) {
- String renderedFingerprint = fingerprintDefinition(key, definition, "generated");
- if (!source.renderedDefinitionSha256().equals(renderedFingerprint)) {
- throw new IOException("Historical generated registry source changed for "
- + key.registryKey() + " / " + key.resourceKey() + ".");
- }
- }
- return definition;
}
private static String renderGeneratedSemantic(
diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java
index 6275e7905..403b36621 100644
--- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java
+++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java
@@ -1,5 +1,6 @@
package art.arcane.iris.world.history;
+import art.arcane.iris.generation.runtime.GenerationClosedException;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
@@ -365,7 +366,7 @@ public final class SavedBiomeRuntime implements AutoCloseable {
}
if (!refresh) {
if (closed || prepared == null) {
- loading.result.completeExceptionally(new IllegalStateException("Saved biome runtime is closed."));
+ loading.result.completeExceptionally(new GenerationClosedException("Saved biome runtime is closed."));
} else {
loading.result.complete(prepared);
}
@@ -480,7 +481,7 @@ public final class SavedBiomeRuntime implements AutoCloseable {
private void requireOpen() {
if (closed) {
- throw new IllegalStateException("Saved biome runtime is closed.");
+ throw new GenerationClosedException("Saved biome runtime is closed.");
}
}
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..e3acf3413 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
@@ -4,6 +4,7 @@ import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldSlotKey;
import art.arcane.iris.generation.runtime.IrisEngineMantle;
import art.arcane.iris.world.history.GenerationHistoryPaths;
+import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -26,11 +27,16 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object();
+ private static final List VANILLA_SLOTS = List.of(
+ NamespacedKey.minecraft("overworld"),
+ NamespacedKey.minecraft("the_nether"),
+ NamespacedKey.minecraft("the_end"));
private BukkitWorldConfiguration() {
}
@@ -160,7 +166,8 @@ public final class BukkitWorldConfiguration {
}
/**
- * Classifies the world storage behind every Iris entry in bukkit.yml.
+ * Classifies the world storage behind every Iris entry in bukkit.yml, and behind every vanilla slot of the level
+ * that holds an Iris world, bound or not.
*
* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
* anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose
@@ -178,22 +185,34 @@ public final class BukkitWorldConfiguration {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toAbsolutePath()
.normalize();
- Path configurationPath = requiredConfigurationFile.toPath();
- if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
- return List.of();
- }
Path worldContainer = requiredLevelRoot.getParent();
if (worldContainer == null) {
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
}
synchronized (MUTATION_LOCK) {
+ YamlConfiguration configuration = Files.exists(requiredConfigurationFile.toPath(), LinkOption.NOFOLLOW_LINKS)
+ ? load(requiredConfigurationFile)
+ : new YamlConfiguration();
List entries = new ArrayList<>();
- for (IrisWorldCandidate candidate : readIrisWorldCandidates(
- load(requiredConfigurationFile),
- requiredLevelName
- )) {
+ for (IrisWorldCandidate candidate : readIrisWorldCandidates(configuration, requiredLevelName)) {
entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
}
+ for (NamespacedKey vanillaSlot : VANILLA_SLOTS) {
+ Path dimensionRoot = expectedDimensionRoot(requiredLevelRoot, vanillaSlot);
+ if (!IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
+ continue;
+ }
+ String configuredName = IrisWorldStorage.configuredWorldName(vanillaSlot, requiredLevelName);
+ if (configuration.get("worlds." + configuredName + ".generator") instanceof String generator
+ && isIrisGenerator(generator.trim())) {
+ entries.add(classifyStorage(worldContainer, requiredLevelRoot,
+ new IrisWorldCandidate(configuredName, vanillaSlot, generator.trim())));
+ } else {
+ entries.add(new IrisWorldStorageEntry(configuredName,
+ new WorldSlotKey(vanillaSlot.getNamespace(), vanillaSlot.getKey()), dimensionRoot,
+ IrisWorldStorageState.UNBOUND, "bukkit.yml does not name Iris as its generator"));
+ }
+ }
return List.copyOf(entries);
}
}
@@ -249,12 +268,21 @@ public final class BukkitWorldConfiguration {
hasWorldData(resolvedRoot)
? IrisWorldStorageState.UNUSABLE
: IrisWorldStorageState.EMPTY,
- unusablePack.getMessage());
+ describe(unusablePack));
}
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
IrisWorldStorageState.PRESENT, null);
}
+ /**
+ * The cause chain on one line: why a history cannot be read sits below the failure that names the world.
+ */
+ private static String describe(Throwable failure) {
+ return GenerationRefusalNotice.causes(failure).stream()
+ .map((String cause) -> cause.lines().findFirst().orElse(cause))
+ .collect(Collectors.joining("; "));
+ }
+
/**
* True when the world folder holds something a vanilla generator could overwrite: an {@code iris/}
* directory marking it as an Iris world whose pack snapshot broke, or stored chunk data.
@@ -372,8 +400,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 +422,59 @@ public final class BukkitWorldConfiguration {
return candidates;
}
+ /**
+ * True when a bukkit.yml world the server loads names Iris as its generator: the level's overworld, nether or
+ * end, or a world whose dimension folder exists. CraftServer asks a plugin for a generator only for worlds it
+ * creates, so these are the ones a disabled Iris would hand to vanilla; an entry for a world that is never
+ * created is not.
+ */
+ public static boolean configuresLoadingIrisWorld(File configurationFile, Path levelRoot) throws IOException {
+ Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
+ Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
+ Path levelName = root.getFileName();
+ Path worldContainer = root.getParent();
+ if (levelName == null || worldContainer == null) {
+ throw new IOException("Selected level root has no world container: " + root);
+ }
+ 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())
+ && loadsAtStartup(worldContainer, root, levelName.toString(), worldName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private static boolean loadsAtStartup(Path worldContainer, Path levelRoot, String levelName, String worldName) {
+ if (worldName.equals(levelName)
+ || worldName.equals(levelName + "_nether")
+ || worldName.equals(levelName + "_the_end")) {
+ return true;
+ }
+ NamespacedKey worldKey;
+ try {
+ worldKey = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
+ } catch (IllegalArgumentException unmappable) {
+ return false;
+ }
+ return hasDimensionDirectory(worldContainer, levelRoot, worldName, worldKey);
+ }
+
+ 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,
@@ -738,7 +818,12 @@ public final class BukkitWorldConfiguration {
* A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
* generate it and nothing else may.
*/
- UNUSABLE
+ UNUSABLE,
+ /**
+ * An Iris world in a vanilla slot whose bukkit.yml entry does not name Iris, so the server would build
+ * it with the vanilla generator.
+ */
+ UNBOUND
}
public record IrisWorldStorageEntry(
diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java
index 6fc105721..89b6db1bd 100644
--- a/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java
+++ b/core/src/main/java/art/arcane/iris/world/lifecycle/HuskWorldQuarantine.java
@@ -16,7 +16,6 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
-import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Stream;
@@ -41,8 +40,6 @@ public final class HuskWorldQuarantine {
private static final String[] CHUNK_DATA_DIRECTORIES = {
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
};
- private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
- private static final String APPLE_DOUBLE_PREFIX = "._";
private static final DateTimeFormatter STAMP = DateTimeFormatter
.ofPattern("yyyyMMdd'T'HHmmss")
.withZone(ZoneOffset.UTC);
@@ -117,7 +114,7 @@ public final class HuskWorldQuarantine {
if (!isReadableDirectory(dimensionRoot)) {
return false;
}
- if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
+ if (IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
return false;
}
for (String directory : CHUNK_DATA_DIRECTORIES) {
@@ -128,44 +125,6 @@ public final class HuskWorldQuarantine {
return true;
}
- /**
- * True when the {@code iris/} entry holds anything of Iris' own.
- *
- * The desktop file managers recreate a directory to hold their own metadata while a delete is still in
- * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
- * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
- * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
- * the list all count as content.
- */
- private static boolean holdsIrisContent(Path irisRoot) {
- if (Files.isSymbolicLink(irisRoot)) {
- return true;
- }
- if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
- return false;
- }
- if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
- return true;
- }
- try (Stream entries = Files.list(irisRoot)) {
- return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
- } catch (IOException unreadable) {
- return true;
- }
- }
-
- private static boolean isOperatingSystemMetadata(Path entry) {
- if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
- return false;
- }
- Path name = entry.getFileName();
- if (name == null) {
- return false;
- }
- String fileName = name.toString();
- return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
- }
-
/**
* True when the path is a real directory that can actually be listed. The permission bits alone are not
* the answer: what matters is whether Iris can see everything in it before deciding it holds nothing.
diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java
index 81ffb3db5..bbc80f374 100644
--- a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java
+++ b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java
@@ -152,15 +152,21 @@ public final class IrisWorldRemovalService {
boolean deleteFiles,
AtomicBoolean terminal
) {
+ // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk
+ // system failure, so it starts only once no player is left to request one.
+ AtomicBoolean maintenanceEntered = new AtomicBoolean(false);
CompletableFuture operation = phase(
- RemovalStatus.RESOLUTION_FAILED,
- () -> backend.beginMaintenance(resolved).thenApply(ignored -> resolved),
+ RemovalStatus.TELEPORT_FAILED,
+ () -> backend.evacuatePlayers(resolved).thenApply(ignored -> resolved),
terminal
)
.thenCompose(activeWorld -> phase(
- RemovalStatus.TELEPORT_FAILED,
- () -> backend.evacuatePlayers(activeWorld)
- .thenApply(ignored -> activeWorld),
+ RemovalStatus.RESOLUTION_FAILED,
+ () -> {
+ CompletableFuture begun = backend.beginMaintenance(activeWorld);
+ maintenanceEntered.set(true);
+ return begun.thenApply(ignored -> activeWorld);
+ },
terminal
))
.thenCompose(activeWorld -> phase(
@@ -198,7 +204,7 @@ public final class IrisWorldRemovalService {
);
}));
- return operation.handle((result, throwable) -> backend.endMaintenance(resolved)
+ return operation.handle((result, throwable) -> endMaintenance(resolved, maintenanceEntered.get())
.handle((ignored, cleanupFailure) -> {
if (cleanupFailure != null) {
Throwable unwrappedCleanup = unwrap(cleanupFailure);
@@ -216,6 +222,10 @@ public final class IrisWorldRemovalService {
})).thenCompose(result -> result);
}
+ private CompletableFuture endMaintenance(ResolvedWorld resolved, boolean entered) {
+ return entered ? backend.endMaintenance(resolved) : CompletableFuture.completedFuture(null);
+ }
+
private CompletableFuture validateResolvedWorld(ResolvedWorld resolvedWorld) {
if (!resolvedWorld.managed()) {
return CompletableFuture.failedFuture(new RemovalFailure(
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..b10741189
--- /dev/null
+++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java
@@ -0,0 +1,64 @@
+package art.arcane.iris.world.safeguard;
+
+import java.nio.file.FileSystemException;
+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