This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-20 10:59:17 -04:00
1 parent fec655def4
commit 5cacf6d435
62 files changed
+4892 -277

No files matched your search

@@ -31,12 +31,15 @@ import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisStartupAdmissionListener;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.core.IrisWorldGeneratorResolver;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.PendingWorldDeleteQueue;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
@@ -65,6 +68,7 @@ import art.arcane.iris.core.service.IrisProtocolService;
import art.arcane.iris.core.service.IrisTerrainSVC;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.core.service.LogFilterSVC;
import art.arcane.iris.core.service.MultiverseSVC;
import art.arcane.iris.core.service.ObjectSVC;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.core.service.PreservationSVC;
@@ -100,6 +104,7 @@ import art.arcane.volmlib.util.io.InstanceState;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.misc.Bindings;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.misc.SlimJar;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.common.plugin.IrisService;
@@ -142,6 +147,7 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -261,12 +267,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
/**
* A warning raised here is the same kind of thing as one raised in core, so it takes the same route: the
* plugin logger at WARNING, where a log scan of logs/latest.log finds it.
*/
public static void warn(String format, Object... objs) {
msg(C.YELLOW + safeFormat(format, objs));
diagnostic(Level.WARNING, safeFormat(format, objs));
}
public static void error(String format, Object... objs) {
msg(C.RED + safeFormat(format, objs));
diagnostic(Level.SEVERE, safeFormat(format, objs));
}
public static void debug(String string) {
@@ -512,12 +522,52 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static void bridgeLog(LogLevel level, String message) {
LogLevel target = level == null ? LogLevel.INFO : level;
switch (target) {
case DEBUG -> Iris.debug(message);
case INFO -> Iris.info(message);
case WARN -> Iris.warn(message);
case ERROR -> Iris.error(message);
Level diagnostic = diagnosticLevel(target);
if (diagnostic != null) {
diagnostic(diagnostic, message);
return;
}
if (target == LogLevel.DEBUG) {
Iris.debug(message);
return;
}
Iris.info(message);
}
/**
* The java.util.logging level a message keeps, or null when it belongs on the console sender path.
* <p>
* Core states a severity and every other adapter honours it; on Bukkit a coloured line through the
* console sender reaches the terminal but not the instance's logs/latest.log, so a WARN-level scan of
* that file never saw a single core warning. Diagnostics go to the plugin logger at their own level
* instead, and NOTICE carries the handful of lifecycle lines that have to land there too. Player-facing
* text still goes through {@code IrisLogging.msg}.
*/
static Level diagnosticLevel(LogLevel level) {
return switch (level) {
case NOTICE -> Level.INFO;
case WARN -> Level.WARNING;
case ERROR -> Level.SEVERE;
case DEBUG, INFO -> null;
};
}
private static void diagnostic(Level level, String message) {
String line = IrisLogging.clean(message);
Iris plugin = instance;
if (plugin != null) {
try {
plugin.getLogger().log(level, line);
return;
} catch (Throwable unavailable) {
// Paper runs the bootstrap before a plugin logger exists; the streams below are all there is.
}
}
if (level.intValue() >= Level.WARNING.intValue()) {
System.err.println("[Iris] " + line);
return;
}
System.out.println("[Iris] " + line);
}
/**
@@ -573,6 +623,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
new WandSVC(),
new BoardSVC(),
new IrisIntegrationService(),
new MultiverseSVC(),
new IrisProtocolService(),
new IrisApiEventSVC(),
new CommandSVC()
@@ -597,6 +648,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.attempt(this::splash);
IrisSafeguard.printReports();
IrisSafeguard.printFooter();
// Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there
// never reach logs/latest.log. Replay them once now that the platform log is up.
MissingWorldStorageLog.replayToPlatformLog();
tickets = new ChunkTickets();
linkMultiverseCore = new MultiverseCoreLink();
IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore);
@@ -606,6 +660,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(EngineWorldManagerProvider.class,
(EngineWorldManagerProvider) IrisWorldManager::new);
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld);
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
settingsHotloadWatch = watch;
configHotloadEngine = new ConfigHotloadEngine(
@@ -710,6 +765,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return worldReconciler;
}
/**
* The load every integration goes through. {@code /iris load} and the Multiverse guard both land here,
* so a world loaded from outside Iris still gets the keyed creator, the pack's environment and the
* bukkit.yml reconciliation that make it an Iris world rather than a vanilla one under the same name.
*/
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
}
public PendingWorldReplacementManager pendingWorldReplacements() {
return pendingWorldReplacements;
}
@@ -757,8 +822,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
boolean enabled = enable();
boolean enabled;
try {
enabled = enable();
} catch (Throwable failure) {
refuseVanillaFallback(failure);
throw failure;
}
if (!enabled) {
refuseVanillaFallback(null);
return;
}
BukkitGuiHost.install();
@@ -766,6 +838,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
super.onEnable();
}
/**
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
* <p>
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
* lives in IrisBootstrap, which refuses startup before any level is created.
*/
private static void refuseVanillaFallback(Throwable failure) {
File levelRoot;
try {
levelRoot = IrisWorldStorage.levelRoot();
} catch (Throwable unavailable) {
return;
}
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
return;
}
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
if (failure != null) {
Iris.reportError("Iris enable failed", failure);
}
try {
Bukkit.shutdown();
} catch (Throwable unavailable) {
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
}
}
public void onDisable() {
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
@@ -1,6 +1,10 @@
package art.arcane.iris;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisWorldStorageEntry;
import art.arcane.iris.core.lifecycle.HuskWorldQuarantine;
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
@@ -13,6 +17,8 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.function.Consumer;
@SuppressWarnings("UnstableApiUsage")
public final class IrisBootstrap implements PluginBootstrap {
@@ -22,6 +28,8 @@ public final class IrisBootstrap implements PluginBootstrap {
try {
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
reconcilePendingWorldReplacements(context, startupPaths);
quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message));
requireUsableWorldStorage(startupPaths);
ProvisionResult provisioned = provision(context, startupPaths);
Path datapackRoot = provisioned.datapackRoot();
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
@@ -58,6 +66,76 @@ public final class IrisBootstrap implements PluginBootstrap {
}
}
/**
* Moves a hot-deleted world's husk out of {@code <levelRoot>/dimensions/iris} before anything enumerates
* it.
* <p>
* Deleting a loaded world's folder and letting the server save the level back leaves a directory holding
* only the server's own {@code data/} skeleton. Left there it stops the boot whichever way it is
* classified: as unusable storage it trips the guard below, and as an empty folder it is excluded from
* the dimension registry, which trips Paper's interactive world-migration gate - a prompt that consumes
* no console input and hangs startup forever. The folder is moved rather than deleted, so it stays
* recoverable; {@link HuskWorldQuarantine} refuses anything that is not provably worthless.
*/
static void quarantineWorthlessHusks(BukkitStartupPaths startupPaths, Consumer<String> warn) {
HuskWorldQuarantine.quarantineWorthlessHusks(startupPaths.levelRoot(), warn);
}
/**
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
* <p>
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} 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/<id>.json} is.
* <p>
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
*/
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
Path levelRoot = startupPaths.levelRoot();
Path levelName = levelRoot.getFileName();
if (levelName == null || levelName.toString().isBlank()) {
throw new IOException("Configured level root has no startup level id: " + levelRoot);
}
List<IrisWorldStorageEntry> entries = BukkitWorldConfiguration.auditIrisWorldStorage(
startupPaths.bukkitConfiguration().toFile(),
levelName.toString(),
levelRoot
);
StringBuilder unusable = new StringBuilder();
for (IrisWorldStorageEntry entry : entries) {
switch (entry.state()) {
case PRESENT -> {
}
case MISSING -> MissingWorldStorageLog.warnOnce(
entry.configuredWorldName(),
entry.storagePath());
case EMPTY -> MissingWorldStorageLog.warnEmptyOnce(
entry.configuredWorldName(),
entry.storagePath());
case UNUSABLE -> unusable.append(unusable.isEmpty() ? "" : "; ")
.append(entry.configuredWorldName())
.append(" (")
.append(entry.storagePath())
.append(": ")
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
.append(')');
}
}
if (unusable.isEmpty()) {
return;
}
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
+ " terrain over it: " + unusable
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
+ " reported and skipped, and the server starts.");
}
static void armStartupFailure(BootstrapContext context, Throwable failure) {
armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY);
}
@@ -28,6 +28,7 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisWorld;
@@ -237,7 +238,7 @@ public final class IrisWorldGeneratorResolver {
@Nullable
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName());
File pack = IrisWorldStorage.frozenDimensionRoot(
Bukkit.getWorldContainer(),
@@ -263,8 +264,65 @@ public final class IrisWorldGeneratorResolver {
return dimension;
}
static NamespacedKey configuredWorldKey(String worldName, String levelName) {
return IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
/**
* Resolves the Iris key behind a Bukkit world name, accepting the runtime keyed name alongside the
* startup name.
* <p>
* Paper names a world built from {@code WorldCreator.ofKey} {@code <namespace>_<key>}, and Multiverse
* loads Iris worlds that way because its own keyed creator refuses a non-{@code minecraft} namespace.
* That is not the startup name, so the configured-name parser reads {@code iris_moon} as
* {@code iris:iris_moon} and loses the world.
* <p>
* Only a name whose decoded key already has Iris storage is remapped, and only when the literal
* reading has none, so a world genuinely created as {@code /iris create "iris moon"} keeps its own
* identity and a server without Multiverse resolves exactly the names it resolved before.
*/
static NamespacedKey configuredWorldKey(String worldName, String levelName, File levelRoot) {
NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
if (!worldName.startsWith(runtimePrefix)
|| worldName.startsWith(levelName + "_" + runtimePrefix)
|| IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, literal)) {
return literal;
}
NamespacedKey runtime;
try {
runtime = IrisWorldStorage.managedKeyFromName(
IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
levelName);
} catch (RuntimeException notAManagedKey) {
return literal;
}
if (!IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, runtime)) {
return literal;
}
IrisLogging.debug("Resolved runtime keyed world name " + worldName + " as " + runtime);
return runtime;
}
/**
* The key a refusal message should name for a world name, without the storage-existence guard.
* <p>
* {@link #configuredWorldKey} only remaps the runtime keyed name when the decoded key has storage, so an
* orphan keeps the literal reading and a message built from it names {@code iris:iris_moon} and tells
* the admin to create a world that never existed under that name. Messaging does not have to be safe
* against mis-mapping a live world, only correct about what the admin typed, so it drops that guard;
* generation keeps it.
*/
static NamespacedKey messageWorldKey(String worldName, String levelName) {
NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_";
if (!worldName.startsWith(runtimePrefix)
|| worldName.startsWith(levelName + "_" + runtimePrefix)) {
return literal;
}
try {
return IrisWorldStorage.managedKeyFromName(
IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()),
levelName);
} catch (RuntimeException notAManagedKey) {
return literal;
}
}
/**
@@ -298,7 +356,7 @@ public final class IrisWorldGeneratorResolver {
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
@@ -361,10 +419,11 @@ public final class IrisWorldGeneratorResolver {
&& (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) {
return;
}
throw new IllegalStateException("'" + worldName + "' (" + worldKey
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + worldName + " type=<pack>;"
+ " Iris registers them with Multiverse itself.");
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
+ " type=<pack>; Iris registers them with Multiverse itself.");
}
private static boolean hasFrozenPack(File dimensionRoot) {
@@ -374,7 +433,7 @@ public final class IrisWorldGeneratorResolver {
private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) {
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot(
Bukkit.getWorldContainer(),
levelRoot,
@@ -162,7 +162,37 @@ public final class IrisEngineSVC implements IrisService {
World world = event.getWorld();
CompletionStage<Boolean> unloadBoundary = WorldUnloadBoundaryRegistry.claim(
WorldIdentity.serialize(world));
remove(world, unloadBoundary);
if (remove(world, unloadBoundary) || unloadBoundary != null) {
return;
}
closeUntrackedGenerator(world);
}
/**
* Closes the engine of a world this service never registered.
* <p>
* Registration is skipped while a previous generator for the same identity is still closing and while
* the world is not yet the server's world for its UID, and an unload that lands in that window would
* otherwise leave a live engine bound to a dead world. Only an unload Iris did not drive is covered:
* an Iris-driven unload carries a boundary and closes the generator in its own phase. The close is
* CAS-idempotent, so a redundant call returns the in-flight completion.
*/
private void closeUntrackedGenerator(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || generator.isClosing()) {
return;
}
IrisLogging.debug("EngineSVC: closing untracked generator for unloaded world " + world.getName());
CompletableFuture<Void> close = generator.closeAsync();
if (close == null) {
return;
}
close.whenComplete((ignored, failure) -> {
if (failure != null) {
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
reportFailure("Failed to close untracked generator for " + world.getName(), cause);
}
});
}
@EventHandler
@@ -235,9 +265,12 @@ public final class IrisEngineSVC implements IrisService {
}
}
private void remove(World world, CompletionStage<Boolean> unloadBoundary) {
/**
* @return true when a registration was found and its close was reserved
*/
private boolean remove(World world, CompletionStage<Boolean> unloadBoundary) {
if (world == null) {
return;
return false;
}
Registered registered;
@@ -250,10 +283,12 @@ public final class IrisEngineSVC implements IrisService {
closing = reserveClose(registered);
}
}
if (closing != null) {
phases.closing(world);
deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
if (closing == null) {
return false;
}
phases.closing(world);
deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
return true;
}
private void deferCloseUntilWorldUnload(