This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-19 22:21:25 -04:00
1 parent 41b2b53ac8
commit fec655def4
79 files changed
+425 -1675

No files matched your search

@@ -117,7 +117,9 @@ public final class IrisWorldStorage {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
} else {
key = keyFromName(requestedName, levelName);
// "iris worlds" and Multiverse print the Bukkit startup name (<level>_iris_<key>), so
// accept that form here instead of turning it into iris:<level>_iris_<key>.
key = keyFromConfiguredWorldName(requestedName, levelName);
}
if (!IRIS_NAMESPACE.equals(key.getNamespace()) || !key.getKey().matches("[a-z0-9_-]+")) {
@@ -1,10 +1,13 @@
package art.arcane.iris.core;
import art.arcane.iris.spi.IrisLogging;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.WorldCreator;
import java.io.File;
import java.lang.reflect.Field;
import java.util.Objects;
/**
* WorldCreator.ofKey and WorldCreator#key are Paper-API-only. Once a call throws
@@ -12,31 +15,40 @@ import java.io.File;
* straight to the fallback. The fallback derives names/keys through IrisWorldStorage's
* current configured-name mapping so persistent Spigot worlds round-trip without changing
* their startup directory.
*
* <p>Paper names a keyed creator {@code <namespace>_<key>} and refuses a creator built from a name
* and a key together, but it names that same world {@code <level>_<namespace>_<key>} when it loads
* it out of {@code <level>/dimensions/<namespace>/<key>} on every later boot. A persistent world
* therefore has to be born under the startup name, which is what {@link #ofPersistentKey} does.</p>
*/
public final class WorldCreatorCompat {
private static volatile boolean keyedCreatorsUnavailable;
private static volatile boolean creatorNamesUnwritable;
private static volatile Field creatorNameField;
private WorldCreatorCompat() {
}
public static WorldCreator ofKey(NamespacedKey worldKey) {
return ofKey(worldKey, IrisWorldStorage.logicalName(worldKey));
}
public static WorldCreator ofKey(NamespacedKey worldKey, String fallbackWorldName) {
WorldCreator keyedCreator = keyedCreator(worldKey);
if (keyedCreator != null) {
return keyedCreator;
}
return new WorldCreator(fallbackWorldName);
return new WorldCreator(IrisWorldStorage.logicalName(worldKey));
}
public static WorldCreator ofKey(NamespacedKey worldKey, String worldName) {
String name = requireWorldName(worldName);
WorldCreator keyedCreator = keyedCreator(worldKey);
if (keyedCreator == null) {
return new WorldCreator(name);
}
renameCreator(keyedCreator, name);
return keyedCreator;
}
public static WorldCreator ofPersistentKey(NamespacedKey worldKey) {
WorldCreator keyedCreator = keyedCreator(worldKey);
if (keyedCreator != null) {
return keyedCreator;
}
return new WorldCreator(fallbackPersistentName(worldKey, IrisWorldStorage.levelRoot().getName()));
return ofKey(worldKey, persistentWorldName(worldKey, IrisWorldStorage.levelRoot().getName()));
}
public static File persistentDimensionRoot(NamespacedKey worldKey) {
@@ -75,18 +87,10 @@ public final class WorldCreatorCompat {
);
}
static String fallbackName(NamespacedKey worldKey, String levelName) {
return IrisWorldStorage.logicalName(worldKey, levelName);
}
static String fallbackPersistentName(NamespacedKey worldKey, String levelName) {
static String persistentWorldName(NamespacedKey worldKey, String levelName) {
return IrisWorldStorage.configuredWorldName(worldKey, levelName);
}
static NamespacedKey fallbackKey(String creatorName, String levelName) {
return IrisWorldStorage.keyFromConfiguredWorldName(creatorName, levelName);
}
private static WorldCreator keyedCreator(NamespacedKey worldKey) {
if (keyedCreatorsUnavailable) {
return null;
@@ -98,4 +102,42 @@ public final class WorldCreatorCompat {
return null;
}
}
/**
* WorldCreator rejects a name and a key passed together and derives the name from the key, so the
* only way to hand a keyed world its startup name at creation is to overwrite the creator's name.
* If that ever stops working the keyed creator is still correct except for the name, which is what
* shipped before, so the world is created rather than refused.
*/
private static void renameCreator(WorldCreator creator, String worldName) {
if (worldName.equals(creator.name()) || creatorNamesUnwritable) {
return;
}
try {
creatorNameField().set(creator, worldName);
} catch (ReflectiveOperationException | RuntimeException | Error e) {
creatorNamesUnwritable = true;
IrisLogging.warn("WorldCreator name is not writable; world %s is created as %s and renamed on the next boot.",
worldName, creator.name());
}
}
private static Field creatorNameField() throws ReflectiveOperationException {
Field cached = creatorNameField;
if (cached != null) {
return cached;
}
Field name = WorldCreator.class.getDeclaredField("name");
name.setAccessible(true);
creatorNameField = name;
return name;
}
private static String requireWorldName(String worldName) {
String name = Objects.requireNonNull(worldName, "worldName").trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
return name;
}
}
@@ -18,8 +18,11 @@
package art.arcane.iris.core.link;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.spi.IrisLogging;
import lombok.SneakyThrows;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.mvplugins.multiverse.core.MultiverseCoreApi;
import org.mvplugins.multiverse.core.utils.result.Attempt;
@@ -30,57 +33,146 @@ import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Multiverse indexes its world store by NamespacedKey and by the Bukkit world name it saw when the
* world was imported, and records that name as {@code legacy-world-name}. Iris creates and registers
* 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.
*/
public class MultiverseCoreLink {
public boolean removeFromConfig(World world) {
return removeFromConfig(world.getName());
}
public boolean removeFromConfig(String world) {
/**
* 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 = manager.getWorld(world).getOrElse((MultiverseWorld) null);
MultiverseWorld multiverseWorld = resolve(manager, worldName);
if (multiverseWorld == null) {
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.
*/
public boolean removeFromConfig(String configuredWorldName) {
String worldName = requireWorldName(configuredWorldName);
if (!isActive()) {
IrisLogging.debug("Multiverse is not enabled; skipped unregistering \"" + worldName + "\".");
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);
return false;
}
return remove(manager, multiverseWorld, worldName);
}
@SneakyThrows
public void updateWorld(World bukkitWorld, String configuredWorldName, String pack) {
World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld");
String worldName = requireWorldName(configuredWorldName);
if (!isActive()) {
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();
});
multiverseWorld.setAutoLoad(false);
if (!generator.equals(multiverseWorld.getGenerator())) {
setWorldConfigString(multiverseWorld, "setGenerator", generator);
}
manager.saveWorldsConfig().get();
}
private boolean remove(WorldManager manager, MultiverseWorld multiverseWorld, String worldName) {
Attempt<String, RemoveFailureReason> removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
if (removal.isFailure()) {
throw new IllegalStateException("Multiverse refused to remove world \"" + world + "\": "
throw new IllegalStateException("Multiverse refused to remove world \"" + worldName + "\": "
+ removal.getFailureMessage());
}
manager.saveWorldsConfig().get();
return true;
}
@SneakyThrows
public void updateWorld(World bukkitWorld, String pack) {
if (!isActive()) {
return;
private MultiverseWorld resolve(WorldManager manager, String worldName) {
for (String candidate : lookupNames(worldName, IrisWorldStorage.levelRoot().getName())) {
MultiverseWorld multiverseWorld = manager.getWorld(candidate).getOrElse((MultiverseWorld) null);
if (multiverseWorld != null) {
return multiverseWorld;
}
}
String generator = "Iris:" + pack;
WorldManager manager = worldManager();
MultiverseWorld multiverseWorld = manager.getWorld(bukkitWorld).getOrElse(() -> {
ImportWorldOptions options = ImportWorldOptions.worldName(bukkitWorld.getName())
.generator(generator)
.environment(bukkitWorld.getEnvironment())
.useSpawnAdjust(false);
return manager.importWorld(options).get();
});
return null;
}
multiverseWorld.setAutoLoad(false);
if (!generator.equals(multiverseWorld.getGenerator())) {
Field field = MultiverseWorld.class.getDeclaredField("worldConfig");
field.setAccessible(true);
Object config = field.get(multiverseWorld);
config.getClass()
.getDeclaredMethod("setGenerator", String.class)
.invoke(config, generator);
/**
* 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.
*/
static List<String> lookupNames(String worldName, String levelName) {
List<String> candidates = new ArrayList<>(2);
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.
}
return List.copyOf(candidates);
}
manager.saveWorldsConfig().get();
private static void setWorldConfigString(MultiverseWorld world, String setter, String value) throws Exception {
Field field = MultiverseWorld.class.getDeclaredField("worldConfig");
field.setAccessible(true);
Object config = field.get(world);
Method method = config.getClass().getDeclaredMethod(setter, String.class);
method.setAccessible(true);
method.invoke(config, value);
}
private static String requireWorldName(String worldName) {
String name = Objects.requireNonNull(worldName, "worldName").trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
return name;
}
private WorldManager worldManager() {
@@ -750,7 +750,7 @@ public final class StudioOpenCoordinator {
for (World loadedWorld : loadedWorlds) {
CompletableFuture<Boolean> unload = J.sfut(() ->
IrisServices.get(MultiverseCoreLink.class)
.removeFromConfig(loadedWorld))
.removeIfPresent(loadedWorld))
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false));
unloads.add(unload);
}
@@ -106,7 +106,7 @@ public final class WorldRuntimeControlService {
return false;
}
IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeFromConfig(world);
IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeIfPresent(world);
setIntGameRule(world, 0, "SPAWN_CHUNK_RADIUS", "spawnChunkRadius");
enableStudioEntitySpawning(world);
if (!IrisSettings.get().getStudio().isDisableTimeAndWeather()) {
@@ -331,16 +331,23 @@ public class IrisCreator {
reportCreationProgress(creationReporter, 0.84D, "register_world");
if (!studio && !benchmark) {
// bukkit.yml and Multiverse must agree on the startup name, or Multiverse re-imports the
// world on the next boot and collides with its own config key.
String registrationName = IrisWorldStorage.configuredWorldName(
worldKey,
IrisWorldStorage.levelRoot().getName()
);
BukkitWorldConfiguration.register(
BUKKIT_YML,
IrisWorldStorage.configuredWorldName(worldKey, IrisWorldStorage.levelRoot().getName()),
registrationName,
dimension,
seed
);
bukkitRegistered = true;
World createdWorld = world;
CompletableFuture<Void> multiverseRegistration = J.sfut(
() -> IrisServices.get(MultiverseCoreLink.class).updateWorld(createdWorld, dimension)
() -> IrisServices.get(MultiverseCoreLink.class)
.updateWorld(createdWorld, registrationName, dimension)
);
if (multiverseRegistration == null) {
throw new IrisException("Failed to schedule Multiverse registration for world \"" + name + "\".");