mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-27 06:20:02 +00:00
MV
This commit is contained in:
62 files changed
+4892
-277
No files matched your search
@@ -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) {
|
||||
WorldManager manager = worldManager();
|
||||
if (manager == null) {
|
||||
IrisLogging.debug("Multiverse is not available; skipped unregistering \""
|
||||
+ configuredWorldName + "\".");
|
||||
return false;
|
||||
}
|
||||
String worldName = requireWorldName(configuredWorldName);
|
||||
if (!isActive()) {
|
||||
IrisLogging.debug("Multiverse is not enabled; skipped unregistering \"" + worldName + "\".");
|
||||
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)" : "") + ".");
|
||||
}
|
||||
|
||||
+25
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user