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