This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-12 00:42:33 -04:00
1 parent 8a189195ad
commit 12b97b7994
62 files changed
+2878 -706

No files matched your search

@@ -1,7 +1,5 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
@@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy {
private ExactWorldSlotPathPolicy() {
}
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
public static Target resolve(Path levelRoot, WorldSlotKey worldKey) {
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
SlotKind slotKind = classify(requiredWorldKey);
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize();
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
throw new Rejection(
RejectionReason.PATH_TRAVERSAL,
@@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy {
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
}
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
public static Target validate(Path levelRoot, WorldSlotKey worldKey, Path candidate) {
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
rejectTraversal(requiredCandidate, "World candidate");
Target target = resolve(levelRoot, worldKey);
@@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy {
}
}
private static SlotKind classify(NamespacedKey worldKey) {
if ("iris".equals(worldKey.getNamespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
private static SlotKind classify(WorldSlotKey worldKey) {
if ("iris".equals(worldKey.namespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) {
throw new Rejection(
RejectionReason.INVALID_IRIS_KEY,
"Iris world keys must be safe single path segments."
@@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy {
}
return SlotKind.IRIS_MANAGED;
}
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
if (!"minecraft".equals(worldKey.namespace())) {
throw new Rejection(
RejectionReason.FOREIGN_NAMESPACE,
"Only Iris-managed and exact vanilla dimension slots can be replaced."
);
}
return switch (worldKey.getKey()) {
return switch (worldKey.key()) {
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
case "the_nether" -> SlotKind.VANILLA_NETHER;
case "the_end" -> SlotKind.VANILLA_END;
@@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy {
}
public record Target(
NamespacedKey worldKey,
WorldSlotKey worldKey,
SlotKind slotKind,
Path levelRoot,
Path namespaceRoot,
@@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy {
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
SlotKind expectedSlotKind = classify(worldKey);
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key());
if (slotKind != expectedSlotKind
|| !namespaceRoot.equals(expectedNamespaceRoot)
|| !worldDirectory.equals(expectedWorldDirectory)) {
@@ -0,0 +1,41 @@
package art.arcane.iris.core;
import java.util.Objects;
import java.util.regex.Pattern;
public record WorldSlotKey(String namespace, String key) {
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z0-9/._-]+$");
private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^[a-z0-9._-]+$");
public WorldSlotKey {
namespace = Objects.requireNonNull(namespace, "namespace");
key = Objects.requireNonNull(key, "key");
if (!NAMESPACE_PATTERN.matcher(namespace).matches()) {
throw new IllegalArgumentException("World slot namespace is invalid: " + namespace);
}
if (!KEY_PATTERN.matcher(key).matches()) {
throw new IllegalArgumentException("World slot key is invalid: " + key);
}
}
public static WorldSlotKey parse(String value) {
String requiredValue = Objects.requireNonNull(value, "value");
int separator = requiredValue.indexOf(':');
if (separator <= 0 || separator != requiredValue.lastIndexOf(':') || separator == requiredValue.length() - 1) {
throw new IllegalArgumentException("World slot key must use namespace:key syntax.");
}
return new WorldSlotKey(
requiredValue.substring(0, separator),
requiredValue.substring(separator + 1)
);
}
public static WorldSlotKey minecraft(String key) {
return new WorldSlotKey("minecraft", key);
}
@Override
public String toString() {
return namespace + ":" + key;
}
}
@@ -0,0 +1,370 @@
package art.arcane.iris.core.lifecycle;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
public final class BukkitStartupPaths {
private static final String DEFAULT_BUKKIT_CONFIGURATION = "bukkit.yml";
private static final String DEFAULT_LEVEL_NAME = "world";
private static final String DEFAULT_SERVER_PROPERTIES = "server.properties";
private static final String DEFAULT_WORLD_CONTAINER = ".";
private final Path bukkitConfiguration;
private final Path levelRoot;
private final String levelName;
private final Path serverProperties;
private final Path serverRoot;
private final Path worldContainer;
private BukkitStartupPaths(
Path serverRoot,
Path serverProperties,
Path bukkitConfiguration,
Path worldContainer,
String levelName,
Path levelRoot
) {
this.serverRoot = Objects.requireNonNull(serverRoot, "serverRoot");
this.serverProperties = Objects.requireNonNull(serverProperties, "serverProperties");
this.bukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration");
this.worldContainer = Objects.requireNonNull(worldContainer, "worldContainer");
this.levelName = Objects.requireNonNull(levelName, "levelName");
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
}
public static BukkitStartupPaths resolveCurrent() throws IOException {
return resolve(Path.of(""), currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory) throws IOException {
return resolve(serverWorkingDirectory, currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory, String[] processArguments)
throws IOException {
Path serverRoot = canonicalDirectory(
Objects.requireNonNull(serverWorkingDirectory, "serverWorkingDirectory").toAbsolutePath().normalize(),
"Server working directory"
);
StartupArguments arguments = parseArguments(processArguments);
Path serverProperties = resolveAgainstServerRoot(
serverRoot,
arguments.serverProperties(),
DEFAULT_SERVER_PROPERTIES,
"server properties"
);
Path bukkitConfiguration = resolveAgainstServerRoot(
serverRoot,
arguments.bukkitConfiguration(),
DEFAULT_BUKKIT_CONFIGURATION,
"Bukkit configuration"
);
String levelName = effectiveLevelName(serverProperties, arguments.levelName());
Path worldContainer = canonicalPotentialDirectory(
resolveAgainstServerRoot(
serverRoot,
arguments.worldContainer() == null
? BukkitWorldConfiguration.readWorldContainer(bukkitConfiguration.toFile())
: arguments.worldContainer(),
DEFAULT_WORLD_CONTAINER,
"world container"
),
"Configured world container"
);
Path configuredLevel = parsePath(levelName, "level name");
Path levelRoot = canonicalPotentialDirectory(
configuredLevel.isAbsolute()
? configuredLevel
: worldContainer.resolve(configuredLevel),
"Configured level root"
);
return new BukkitStartupPaths(
serverRoot,
serverProperties,
bukkitConfiguration,
worldContainer,
levelName,
levelRoot
);
}
public Path bukkitConfiguration() {
return bukkitConfiguration;
}
public Path levelRoot() {
return levelRoot;
}
public String levelName() {
return levelName;
}
public Path serverProperties() {
return serverProperties;
}
public Path serverRoot() {
return serverRoot;
}
public Path worldContainer() {
return worldContainer;
}
private static String[] currentArguments() {
return applicationArguments(ProcessHandle.current().info().arguments().orElse(new String[0]));
}
static String[] applicationArguments(String[] processArguments) {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
for (int index = 0; index < arguments.length; index++) {
if (arguments[index].equals("-jar")
|| arguments[index].equals("-m")
|| arguments[index].equals("--module")) {
int applicationStart = Math.min(arguments.length, index + 2);
return Arrays.copyOfRange(arguments, applicationStart, arguments.length);
}
}
Set<String> optionsWithValues = Set.of(
"-cp",
"-classpath",
"--class-path",
"-p",
"--module-path",
"--upgrade-module-path",
"--add-modules",
"--enable-native-access",
"--limit-modules",
"--add-exports",
"--add-opens",
"--add-reads",
"--patch-module",
"--source"
);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
if (optionsWithValues.contains(argument)) {
index++;
continue;
}
if (argument.startsWith("-") || argument.startsWith("@")) {
continue;
}
return Arrays.copyOfRange(arguments, index + 1, arguments.length);
}
return new String[0];
}
private static StartupArguments parseArguments(String[] processArguments) throws IOException {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
String serverProperties = null;
String bukkitConfiguration = null;
String levelName = null;
String worldContainer = null;
for (int index = 0; index < arguments.length; index++) {
String argument = Objects.requireNonNull(arguments[index], "process argument");
if (argument.equals("--")) {
break;
}
ParsedArgument parsed = parseArgument(
argument,
index + 1 < arguments.length ? arguments[index + 1] : null
);
if (parsed == null) {
continue;
}
switch (parsed.kind()) {
case SERVER_PROPERTIES -> serverProperties = parsed.value();
case BUKKIT_CONFIGURATION -> bukkitConfiguration = parsed.value();
case LEVEL_NAME -> levelName = parsed.value();
case WORLD_CONTAINER -> worldContainer = parsed.value();
}
if (parsed.consumedFollowing()) {
index++;
}
}
return new StartupArguments(serverProperties, bukkitConfiguration, levelName, worldContainer);
}
private static ParsedArgument parseArgument(String argument, String following) throws IOException {
ParsedArgument parsed = parseArgument(
argument,
following,
ArgumentKind.SERVER_PROPERTIES,
"-c",
"--config"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.BUKKIT_CONFIGURATION,
"-b",
"--bukkit-settings"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.WORLD_CONTAINER,
"-W",
"--world-dir",
"--universe",
"--world-container"
);
if (parsed != null) {
return parsed;
}
return parseArgument(
argument,
following,
ArgumentKind.LEVEL_NAME,
"-w",
"--world",
"--level-name"
);
}
private static ParsedArgument parseArgument(
String argument,
String following,
ArgumentKind kind,
String... keys
) throws IOException {
for (String key : keys) {
if (argument.equals(key)) {
if (following == null || following.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, following, true);
}
if (key.length() == 2 && argument.startsWith(key) && argument.length() > key.length()) {
String value = argument.substring(key.length());
if (value.startsWith("=")) {
value = value.substring(1);
}
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
String prefix = key + "=";
if (argument.startsWith(prefix)) {
String value = argument.substring(prefix.length());
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
}
return null;
}
private static Path resolveAgainstServerRoot(
Path serverRoot,
String configuredValue,
String defaultValue,
String label
) throws IOException {
String value = configuredValue == null ? defaultValue : configuredValue;
Path configured = parsePath(value, label);
return configured.isAbsolute()
? configured.normalize()
: serverRoot.resolve(configured).normalize();
}
private static Path parsePath(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
try {
return Path.of(value);
} catch (InvalidPathException exception) {
throw new IOException("Configured " + label + " is invalid", exception);
}
}
private static String effectiveLevelName(Path serverProperties, String argumentOverride) throws IOException {
if (argumentOverride != null) {
return requireValue(argumentOverride, "level name");
}
if (!Files.exists(serverProperties, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_LEVEL_NAME;
}
if (!Files.isRegularFile(serverProperties)) {
throw new IOException("Configured server properties is not a regular file: " + serverProperties);
}
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(serverProperties)) {
properties.load(input);
}
return requireValue(properties.getProperty("level-name", DEFAULT_LEVEL_NAME), "level name");
}
private static String requireValue(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
return value;
}
private static Path canonicalDirectory(Path path, String label) throws IOException {
Path canonical = path.toRealPath();
if (!Files.isDirectory(canonical)) {
throw new IOException(label + " is not a directory: " + path);
}
return canonical;
}
private static Path canonicalPotentialDirectory(Path path, String label) throws IOException {
Path normalized = path.toAbsolutePath().normalize();
ArrayList<Path> missing = new ArrayList<>();
Path existing = normalized;
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
Path fileName = existing.getFileName();
Path parent = existing.getParent();
if (fileName == null || parent == null) {
throw new IOException(label + " has no existing filesystem ancestor: " + normalized);
}
missing.add(fileName);
existing = parent;
}
Path canonical = canonicalDirectory(existing, label);
for (int index = missing.size() - 1; index >= 0; index--) {
canonical = canonical.resolve(missing.get(index));
}
return canonical.normalize();
}
private enum ArgumentKind {
SERVER_PROPERTIES,
BUKKIT_CONFIGURATION,
LEVEL_NAME,
WORLD_CONTAINER
}
private record ParsedArgument(ArgumentKind kind, String value, boolean consumedFollowing) {
}
private record StartupArguments(
String serverProperties,
String bukkitConfiguration,
String levelName,
String worldContainer
) {
}
}
@@ -9,14 +9,17 @@ import java.io.IOException;
import java.nio.channels.FileChannel;
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.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Objects;
import java.util.function.Predicate;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object();
private BukkitWorldConfiguration() {
@@ -63,6 +66,31 @@ public final class BukkitWorldConfiguration {
}
}
public static String readWorldContainer(File configurationFile) throws IOException {
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
Path configurationPath = requiredConfigurationFile.toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_WORLD_CONTAINER;
}
if (!Files.isRegularFile(configurationPath)) {
throw new IOException("Configured Bukkit configuration is not a regular file: " + configurationPath);
}
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(requiredConfigurationFile);
Object configured = configuration.get("settings.world-container");
if (configured == null) {
return DEFAULT_WORLD_CONTAINER;
}
if (!(configured instanceof String value)) {
throw new IOException("Bukkit settings.world-container must be a path string");
}
if (value.isBlank()) {
throw new IOException("Configured world container is empty");
}
return value;
}
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration {
return new GeneratorReplacement(false, current, replacement);
}
apply(configuration, requiredWorldName, replacement);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return new GeneratorReplacement(true, current, replacement);
}
}
@@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration {
return false;
}
apply(configuration, requiredWorldName, requiredRestoration);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return true;
}
}
@@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration {
}
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
saveAtomic(target, configuration, false);
}
private static void saveAtomic(
Path target,
YamlConfiguration configuration,
boolean requireAtomicReplacement
) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
requireRegularConfigurationFile(absoluteTarget);
Path parent = absoluteTarget.getParent();
if (parent == null) {
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
@@ -206,9 +243,16 @@ public final class BukkitWorldConfiguration {
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
requireRegularConfigurationFile(absoluteTarget);
try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
if (requireAtomicReplacement) {
throw new IOException(
"Exact world replacement requires atomic bukkit.yml publication on this filesystem.",
exception
);
}
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
@@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration {
}
private static YamlConfiguration load(File configurationFile) throws IOException {
requireRegularConfigurationFile(configurationFile.toPath());
YamlConfiguration configuration = new YamlConfiguration();
try {
configuration.load(configurationFile);
@@ -226,6 +271,18 @@ public final class BukkitWorldConfiguration {
}
}
private static void requireRegularConfigurationFile(Path configurationFile) throws IOException {
Path path = configurationFile.toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
path,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
throw new IOException("bukkit.yml must be an existing regular file and was not changed: " + path);
}
}
private static WorldGeneratorSnapshot snapshot(
YamlConfiguration configuration,
String worldName
@@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap {
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state.");
}
Transaction active = transaction;
if (active.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled incomplete Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
active = active.withPhase(Phase.ARMED);
WorldReplacementJournal.write(dataDirectory, active);
}
if (active.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap {
return ReconcileAction.PUBLISHED;
}
if (active.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Rolled back Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap {
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
? transaction
: transaction.withPhase(Phase.ROLLBACK_PENDING);
if (rollback != transaction) {
if (transaction.phase() != Phase.ROLLBACK_PENDING) {
WorldReplacementJournal.write(dataDirectory, rollback);
}
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
@@ -0,0 +1,16 @@
package art.arcane.iris.core.lifecycle;
public final class WorldReplacementBootstrapMarker {
private static volatile boolean bootstrappedThisProcess;
private WorldReplacementBootstrapMarker() {
}
public static boolean wasBootstrappedThisProcess() {
return bootstrappedThisProcess;
}
public static void markBootstrapped() {
bootstrappedThisProcess = true;
}
}
@@ -1,8 +1,10 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -26,6 +28,11 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WorldReplacementFilesystem {
private static final List<Path> PAPER_WORLD_METADATA = List.of(
Path.of("data/paper/metadata.dat"),
Path.of("data/paper/level_overrides.dat"),
Path.of("data/minecraft/world_gen_settings.dat")
);
private static final Pattern STAGE_NAME = Pattern.compile(
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
private static final Pattern BACKUP_NAME = Pattern.compile(
@@ -37,7 +44,7 @@ public final class WorldReplacementFilesystem {
public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
UUID requiredId = Objects.requireNonNull(id, "id");
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId;
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().key() + "-" + requiredId;
return new ReplacementPaths(
requiredTarget.worldDirectory(),
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
@@ -45,6 +52,17 @@ public final class WorldReplacementFilesystem {
);
}
public static void requireExistingTarget(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (!state.targetPresent()) {
throw new IOException("overwrite=true requires an existing exact world slot; use ordinary create for a new world.");
}
if (state.stagePresent() || state.backupPresent()) {
throw new IOException("A replacement artifact already exists for this transaction.");
}
}
public static void publish(
ReplacementPaths paths,
boolean originalTargetPresent,
@@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem {
if (state.stagePresent()) {
requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
Path retainedWorld = state.targetPresent()
? requiredPaths.target()
: requiredPaths.backup();
preservePaperWorldMetadata(retainedWorld, requiredPaths.stage());
}
if (state.targetPresent()) {
if (state.backupPresent()) {
throw new IOException("Replacement target, stage, and backup are all present.");
@@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem {
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target());
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
}
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
@@ -298,6 +327,71 @@ public final class WorldReplacementFilesystem {
}
}
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
requireDirectory(retainedWorld, "retained world");
requireDirectory(replacementWorld, "replacement world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
if (!sourceAttributes.isRegularFile()) {
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
}
}
ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
Path destination = replacementWorld.resolve(relative);
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes destinationAttributes = requireSafeEntry(destination);
if (!destinationAttributes.isRegularFile()) {
throw new IOException("Replacement Paper world metadata is not a regular file: " + relative);
}
continue;
}
copyMetadataFile(retainedWorld.resolve(relative), destination);
}
}
private static void ensureDirectory(Path directory, String label) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
requireDirectory(directory, label);
return;
}
Files.createDirectory(directory);
forceDirectoryRequired(directory.getParent());
}
private static void copyMetadataFile(Path source, Path destination) throws IOException {
Path temporary = destination.resolveSibling("." + destination.getFileName() + ".iris-replace.tmp");
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attributes = requireSafeEntry(temporary);
if (!attributes.isRegularFile()) {
throw new IOException("Replacement metadata staging path is unsafe: " + temporary);
}
Files.delete(temporary);
}
try {
Files.copy(source, temporary, StandardCopyOption.COPY_ATTRIBUTES);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException(
"Exact world replacement requires atomic Paper metadata publication on this filesystem.",
exception
);
}
forceDirectoryRequired(destination.getParent());
} finally {
Files.deleteIfExists(temporary);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
@@ -313,13 +407,37 @@ public final class WorldReplacementFilesystem {
}
private static void move(Path source, Path target) throws IOException {
forceDirectoryRequired(source.getParent());
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
throw new IOException(
"Exact world replacement requires atomic directory moves on this filesystem.",
exception
);
}
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
forceDirectoryAfterCommit(source.getParent());
}
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement move completed, but its parent directory could not be durability-synced.",
failure
);
}
}
@@ -1,10 +1,12 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import org.bukkit.NamespacedKey;
import art.arcane.iris.spi.IrisLogging;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
public final class WorldReplacementJournal {
@@ -46,6 +50,12 @@ public final class WorldReplacementJournal {
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
Set<WorldSlotKey> worldKeys = new HashSet<>();
for (Transaction transaction : transactions) {
if (!worldKeys.add(transaction.worldKey())) {
throw new IOException("Multiple replacement journals target " + transaction.worldKey() + ".");
}
}
return List.copyOf(transactions);
}
@@ -77,8 +87,9 @@ public final class WorldReplacementJournal {
if (directory == null) {
return;
}
forceDirectoryRequired(directory);
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
forceDirectory(directory);
forceDirectoryAfterCommit(directory);
}
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
@@ -91,31 +102,44 @@ public final class WorldReplacementJournal {
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
throw new IOException("The configured level root changed after the world replacement was staged.");
}
String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
String expectedWorldName;
try {
expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
} catch (IllegalArgumentException failure) {
throw new IOException("The replacement journal targets an ambiguous logical world name.", failure);
}
if (!expectedWorldName.equals(requiredTransaction.worldName())) {
throw new IOException("The logical world name changed after the world replacement was staged.");
}
return target;
}
public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) {
public static String logicalWorldName(Path levelRoot, WorldSlotKey worldKey) {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if ("iris".equals(requiredWorldKey.getNamespace())) {
return requiredWorldKey.getKey();
}
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path fileName = requiredLevelRoot.getFileName();
if (fileName == null || fileName.toString().isBlank()) {
throw new IllegalArgumentException("Level root must have a logical world name.");
}
String levelName = fileName.toString();
if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) {
if ("iris".equals(requiredWorldKey.namespace())) {
String logicalName = requiredWorldKey.key();
if (logicalName.equals(levelName)
|| logicalName.equals(levelName + "_nether")
|| logicalName.equals(levelName + "_the_end")) {
throw new IllegalArgumentException(
"An Iris-managed world cannot use a configured vanilla world alias."
);
}
return logicalName;
}
if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) {
return levelName;
}
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) {
return levelName + "_nether";
}
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) {
return levelName + "_the_end";
}
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
@@ -135,9 +159,11 @@ public final class WorldReplacementJournal {
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
throw new IOException("Replacement journal filename does not match its transaction id.");
}
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
if (worldKey == null) {
throw new IOException("Replacement journal contains an invalid world key.");
WorldSlotKey worldKey;
try {
worldKey = WorldSlotKey.parse(required(properties, "worldKey"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid world key.", failure);
}
String worldName = exact(properties, "worldName");
Path recordedLevelRoot = recordedLevelRoot(properties);
@@ -300,26 +326,46 @@ public final class WorldReplacementJournal {
}
channel.force(true);
}
forceDirectoryRequired(parent);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
throw new IOException(
"Exact world replacement requires atomic journal publication on this filesystem.",
failure
);
}
forceDirectory(parent);
forceDirectoryAfterCommit(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement journal change completed, but its parent directory could not be durability-synced.",
failure
);
}
}
public record Transaction(
UUID id,
NamespacedKey worldKey,
WorldSlotKey worldKey,
String worldName,
Path levelRoot,
String dimension,
@@ -1,6 +1,7 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList;
@@ -27,7 +28,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
@@ -40,9 +43,20 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final List<PackSpec> DEFAULT_PACKS = List.of(
new PackSpec(
"overworld",
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
"overworld"
),
new PackSpec(
"underworld",
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
"underworld"
)
);
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 3;
private static final int MARKER_SCHEMA = 4;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
@@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner {
private DefaultPackBootstrapProvisioner() {
}
static List<PackSpec> defaultPacks() {
return DEFAULT_PACKS;
}
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
return provision(dataDirectory, feedback, BukkitStartupPaths.resolveCurrent());
}
public static ProvisionResult provision(
Path dataDirectory,
Consumer<String> feedback,
BukkitStartupPaths startupPaths
) throws IOException {
Objects.requireNonNull(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback");
Path serverRoot = Path.of("").toAbsolutePath().normalize();
Path levelRoot = resolveLevelRoot(serverRoot);
Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
ProvisionOptions options = new ProvisionOptions(
DEFAULT_SOURCE,
DEFAULT_PACKS,
client,
Clock.systemUTC(),
Duration.ofMinutes(30),
@@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner {
try {
return isProvisioned(
dataDirectory,
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
BukkitStartupPaths.resolveCurrent().levelRoot()
);
} catch (IOException | RuntimeException exception) {
return false;
@@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner {
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
try {
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path datapackRoot = worldDatapackRoot(normalizedLevel);
Path packRoot = normalizedData.resolve("packs/overworld");
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) {
return false;
}
Properties marker = loadProperties(markerFile);
@@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner {
.equals(marker.getProperty("compilerIdentity"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
for (PackSpec spec : requiredPacks) {
Path packRoot = normalizedData.resolve("packs").resolve(spec.key());
if (!isPackRoot(packRoot, spec)
|| !spec.source().toString().equals(marker.getProperty(markerKey(spec, "source")))
|| !spec.requiredDimension().equals(marker.getProperty(markerKey(spec, "requiredDimension")))
|| !directoryFingerprint(packRoot).equals(marker.getProperty(markerKey(spec, "fingerprint")))) {
return false;
}
}
return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
normalizedData,
@@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner {
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path legacyDatapackRoot = bootstrapRoot.resolve("datapack");
Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks");
@@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner {
Files.createDirectories(datapacksRoot);
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
boolean existingPack = isPackRoot(packRoot);
boolean existingDatapack = isDatapackRoot(datapackRoot);
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
boolean unchangedManagedPack = markerOwned
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
boolean managedDefault = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managedDefault = false;
List<PackPlan> plans = new ArrayList<>(options.packs().size());
for (PackSpec spec : options.packs()) {
Path packRoot = packsRoot.resolve(spec.key()).normalize();
if (!Objects.equals(packRoot.getParent(), packsRoot)) {
throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot);
}
boolean existingPack = isPackRoot(packRoot, spec);
String currentFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(markerProperty(previousMarker, spec, "managed"));
boolean unchangedManagedPack = markerOwned
&& currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint"));
boolean managed = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managed = false;
}
String retainedSourceSha = markerProperty(previousMarker, spec, "sourceSha256");
Archive archive = managed
? acquireArchive(cacheRoot, previousMarker, spec, feedback, options)
: new Archive(null, retainedSourceSha == null || retainedSourceSha.isBlank()
? currentFingerprint
: retainedSourceSha);
boolean replacePack = !existingPack || managed
&& (!archive.sha256().equals(markerProperty(previousMarker, spec, "sourceSha256"))
|| !currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint")));
plans.add(new PackPlan(spec, packRoot, existingPack, managed, archive, replacePack));
}
Archive archive = managedDefault
? acquireArchive(cacheRoot, previousMarker, feedback, options)
: new Archive(null, currentPackFingerprint);
boolean replacePack = !existingPack || managedDefault
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
Path stagedPack = null;
Path extractionRoot = null;
Map<PackPlan, Path> stagedPacks = new LinkedHashMap<>();
List<Path> extractionRoots = new ArrayList<>();
Path compileContainer = null;
Path packBackup = null;
Path datapackBackup = null;
boolean packReplaced = false;
boolean datapackReplaced = false;
List<PackPublication> packPublications = new ArrayList<>();
boolean committed = false;
try {
if (replacePack) {
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
for (PackPlan plan : plans) {
if (!plan.replace()) {
continue;
}
Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID());
extractionRoots.add(extractionRoot);
Files.createDirectories(extractionRoot);
Path extractedPack = extractArchive(archive.path(), extractionRoot);
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec());
Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID());
copyDirectory(extractedPack, stagedPack);
validatePackRoot(stagedPack);
packBackup = replaceWithBackup(stagedPack, packRoot);
packReplaced = true;
validatePackRoot(stagedPack, plan.spec());
stagedPacks.put(plan, stagedPack);
}
for (Map.Entry<PackPlan, Path> entry : stagedPacks.entrySet()) {
Path backup = replaceWithBackup(entry.getValue(), entry.getKey().root());
packPublications.add(new PackPublication(entry.getKey().root(), backup));
}
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
@@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner {
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
boolean anyPackReplaced = !packPublications.isEmpty();
boolean rebuildDatapack = anyPackReplaced
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
@@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner {
datapackReplaced = true;
}
validatePackRoot(packRoot);
for (PackPlan plan : plans) {
validatePackRoot(plan.root(), plan.spec());
}
if (!isDatapackRoot(datapackRoot)) {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
options.levelRoot());
@@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner {
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
marker.setProperty("source", options.source().toString());
marker.setProperty("sourceSha256", archive.sha256());
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
for (PackPlan plan : plans) {
marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString());
marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256());
marker.setProperty(markerKey(plan.spec(), "managed"), Boolean.toString(plan.managed()));
marker.setProperty(markerKey(plan.spec(), "fingerprint"), directoryFingerprint(plan.root()));
marker.setProperty(markerKey(plan.spec(), "requiredDimension"), plan.spec().requiredDimension());
}
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
committed = true;
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
for (PackPublication publication : packPublications) {
deleteQuietly(publication.backup(), feedback);
}
deleteQuietly(datapackBackup, feedback);
deleteQuietly(legacyDatapackRoot, feedback);
boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed);
ProvisionStatus status;
if (!existingPack && !existingDatapack) {
if (everyPackMissing && !existingDatapack) {
status = ProvisionStatus.INSTALLED;
} else if (replacePack || rebuildDatapack) {
} else if (anyPackReplaced || rebuildDatapack) {
status = ProvisionStatus.UPDATED;
} else {
status = ProvisionStatus.UNCHANGED;
}
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
return new ProvisionResult(packRoot, datapackRoot, status);
feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + ".");
Map<String, Path> provisionedPacks = new LinkedHashMap<>();
for (PackPlan plan : plans) {
provisionedPacks.put(plan.spec().key(), plan.root());
}
return new ProvisionResult(provisionedPacks, datapackRoot, status);
} catch (IOException failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
} catch (RuntimeException | LinkageError failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw new IOException("Iris bootstrap provisioning failed", failure);
} finally {
deleteQuietly(stagedPack, feedback);
deleteQuietly(extractionRoot, feedback);
for (Path stagedPack : stagedPacks.values()) {
deleteQuietly(stagedPack, feedback);
}
for (Path extractionRoot : extractionRoots) {
deleteQuietly(extractionRoot, feedback);
}
deleteQuietly(compileContainer, feedback);
}
}
@@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner {
private static Archive acquireArchive(
Path cacheRoot,
Properties marker,
PackSpec spec,
Consumer<String> feedback,
ProvisionOptions options
) throws IOException {
Path archivePath = cacheRoot.resolve("default-overworld.zip");
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip");
Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties");
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
boolean validCache = false;
if (Files.isRegularFile(archivePath)) {
try {
validCache = validateArchive(archivePath);
validCache = validateArchive(archivePath, spec);
} catch (IOException exception) {
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement.");
}
}
String cachedSource = metadata.getProperty("source", "");
boolean cacheMatchesSource = validCache && (spec.source().toString().equals(cachedSource)
|| cachedSource.isBlank() && "overworld".equals(spec.key()));
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
boolean fresh = cacheMatchesSource
&& options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
if (fresh) {
if (cachedSource.isBlank()) {
metadata.setProperty("source", spec.source().toString());
storePropertiesAtomic(metadataPath, metadata);
}
return new Archive(archivePath, sha256(archivePath));
}
IOException networkFailure = null;
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
try {
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
HttpRequest.Builder request = HttpRequest.newBuilder(spec.source())
.timeout(options.requestTimeout())
.header("Accept", "application/octet-stream")
.header("User-Agent", "Iris-Bootstrap")
.GET();
if (validCache) {
if (cacheMatchesSource) {
String etag = metadata.getProperty("etag");
String lastModified = metadata.getProperty("lastModified");
if (etag != null && !etag.isBlank()) {
@@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner {
}
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status == 304 && validCache) {
if (status == 304 && cacheMatchesSource) {
close(response.body());
metadata.setProperty("source", spec.source().toString());
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(metadataPath, metadata);
return new Archive(archivePath, sha256(archivePath));
@@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner {
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
try {
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
copyLimited(input, output, options.maxArchiveBytes());
copyLimited(input, output, options.maxArchiveBytes(), spec);
}
if (!validateArchive(temporary)) {
throw new IOException("Downloaded default overworld archive is invalid");
if (!validateArchive(temporary, spec)) {
throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid");
}
move(temporary, archivePath, true);
} finally {
@@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner {
Properties updated = new Properties();
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
updated.setProperty("source", spec.source().toString());
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated);
feedback.accept("Downloaded the Iris default overworld beta archive.");
feedback.accept("Downloaded the Iris " + spec.key() + " beta archive.");
return new Archive(archivePath, updated.getProperty("sha256"));
}
close(response.body());
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status);
if (!retryableStatus(status)) {
networkFailure = statusFailure;
break;
@@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld download was interrupted", exception);
throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception);
} catch (IOException exception) {
networkFailure = exception;
}
@@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner {
}
}
if (validCache) {
feedback.accept("Default overworld download failed; using the validated cached archive.");
if (cacheMatchesSource) {
feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath));
}
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) {
String sourceSha = marker.getProperty("sourceSha256");
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
return new Archive(null, sourceSha);
}
throw networkFailure == null
? new IOException("Default overworld archive is unavailable and no valid cache exists")
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists")
: new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure);
}
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) {
String sourceSha = marker.getProperty("sourceSha256");
String expectedFingerprint = marker.getProperty("defaultPackFingerprint");
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
String expectedFingerprint = markerProperty(marker, spec, "fingerprint");
if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) {
return false;
}
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key());
try {
return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint);
return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static Path resolveLevelRoot(Path serverRoot) throws IOException {
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
String levelName = readConfiguredLevelName(normalizedServerRoot);
Path configured = Path.of(levelName);
return configured.isAbsolute()
? configured.normalize()
: normalizedServerRoot.resolve(configured).normalize();
public static Path resolveLevelRoot(Path serverRoot) throws IOException {
return BukkitStartupPaths.resolve(serverRoot).levelRoot();
}
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
String levelName = "world";
Path propertiesFile = serverRoot.resolve("server.properties");
if (Files.isRegularFile(propertiesFile)) {
Properties properties = loadProperties(propertiesFile);
levelName = properties.getProperty("level-name", levelName);
}
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
String parsed = parseLevelArgument(argument, following);
if (parsed != null) {
levelName = parsed;
}
}
if (levelName.isBlank()) {
throw new IOException("Configured level name is empty");
}
return levelName;
}
private static String parseLevelArgument(String argument, String following) {
for (String key : List.of("-w", "--level-name", "--world")) {
if (argument.equals(key) && following != null && !following.isBlank()) {
return following;
}
String prefix = key + "=";
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
return argument.substring(prefix.length());
}
}
return null;
}
private static boolean validateArchive(Path archive) throws IOException {
private static boolean validateArchive(Path archive, PackSpec spec) throws IOException {
int entries = 0;
long expanded = 0L;
boolean dimensionFound = false;
@@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) {
throw new IOException("Default overworld archive contains duplicate path " + name);
throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name);
}
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json";
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
dimensionFound = true;
}
if (!entry.isDirectory()) {
@@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
}
}
@@ -471,9 +526,9 @@ public final class DefaultPackBootstrapProvisioner {
return entries > 0 && dimensionFound;
}
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException {
if (archive == null) {
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild");
}
long expanded = 0L;
int entries = 0;
@@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) {
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(output);
@@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
file.write(buffer, 0, read);
}
@@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner {
zip.closeEntry();
}
}
if (isPackRoot(extractionRoot)) {
if (isPackRoot(extractionRoot, spec)) {
return extractionRoot;
}
List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
for (Path child : children) {
if (Files.isDirectory(child) && isPackRoot(child)) {
if (Files.isDirectory(child) && isPackRoot(child, spec)) {
candidates.add(child);
}
}
}
if (candidates.size() != 1) {
throw new IOException("Default overworld archive has an invalid root layout");
throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout");
}
return candidates.getFirst();
}
private static String normalizedZipEntry(String raw) throws IOException {
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
throw new IOException("Invalid path in default overworld archive");
throw new IOException("Invalid path in Iris bootstrap pack archive");
}
String normalized = raw.replace('\\', '/');
Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
throw new IOException("Unsafe path in default overworld archive: " + raw);
throw new IOException("Unsafe path in Iris bootstrap pack archive: " + raw);
}
return path.toString().replace('\\', '/');
}
private static boolean isPackRoot(Path path) {
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
private static boolean isPackRoot(Path path, PackSpec spec) {
return path != null
&& Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json"));
}
private static void validatePackRoot(Path path) throws IOException {
if (!isPackRoot(path)) {
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
if (!isPackRoot(path, spec)) {
throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/"
+ spec.requiredDimension() + ".json at " + path);
}
}
@@ -636,26 +693,27 @@ public final class DefaultPackBootstrapProvisioner {
}
private static IOException rollback(
Path packRoot,
Path packBackup,
boolean packReplaced,
List<PackPublication> packPublications,
Path datapackRoot,
Path datapackBackup,
boolean datapackReplaced
) {
IOException failure = null;
try {
restore(packRoot, packBackup, packReplaced);
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
failure = exception;
}
try {
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
for (int index = packPublications.size() - 1; index >= 0; index--) {
PackPublication publication = packPublications.get(index);
try {
restore(publication.root(), publication.backup(), true);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
}
return failure;
@@ -726,14 +784,19 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
private static void copyLimited(
InputStream input,
OutputStream output,
long limit,
PackSpec spec
) throws IOException {
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) {
throw new IOException("Default overworld archive exceeds the download size limit");
throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit");
}
output.write(buffer, 0, read);
}
@@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner {
Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld retry wait was interrupted", exception);
throw new IOException("Iris bootstrap pack retry wait was interrupted", exception);
}
}
@@ -787,6 +850,24 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static String markerKey(PackSpec spec, String property) {
return "pack." + spec.key() + "." + property;
}
private static String markerProperty(Properties marker, PackSpec spec, String property) {
String current = marker.getProperty(markerKey(spec, property));
if (current != null || !"overworld".equals(spec.key())) {
return current;
}
return switch (property) {
case "source" -> marker.getProperty("source");
case "sourceSha256" -> marker.getProperty("sourceSha256");
case "managed" -> marker.getProperty("managedDefault");
case "fingerprint" -> marker.getProperty("defaultPackFingerprint");
default -> null;
};
}
private static void close(InputStream input) {
try {
input.close();
@@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner {
UNCHANGED
}
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
public record ProvisionResult(Map<String, Path> packRoots, Path datapackRoot, ProvisionStatus status) {
public ProvisionResult {
Objects.requireNonNull(packRoot, "packRoot");
packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots"));
Objects.requireNonNull(datapackRoot, "datapackRoot");
Objects.requireNonNull(status, "status");
}
}
record ProvisionOptions(
URI source,
List<PackSpec> packs,
HttpClient client,
Clock clock,
Duration refreshInterval,
@@ -820,19 +901,49 @@ public final class DefaultPackBootstrapProvisioner {
Path levelRoot
) {
ProvisionOptions {
Objects.requireNonNull(source, "source");
packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval");
Objects.requireNonNull(requestTimeout, "requestTimeout");
Objects.requireNonNull(retryDelay, "retryDelay");
Objects.requireNonNull(levelRoot, "levelRoot");
if (attempts < 1 || maxArchiveBytes < 1L) {
if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) {
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
}
Set<String> keys = new HashSet<>();
for (PackSpec pack : packs) {
if (!keys.add(pack.key())) {
throw new IllegalArgumentException("Duplicate bootstrap pack key '" + pack.key() + "'");
}
}
}
}
record PackSpec(String key, URI source, String requiredDimension) {
PackSpec {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(source, "source");
Objects.requireNonNull(requiredDimension, "requiredDimension");
if (!key.matches("[a-z0-9_-]+") || !requiredDimension.matches("[a-z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid bootstrap pack specification for '" + key + "'");
}
}
}
private record PackPlan(
PackSpec spec,
Path root,
boolean existed,
boolean managed,
Archive archive,
boolean replace
) {
}
private record PackPublication(Path root, Path backup) {
}
private record Archive(Path path, String sha256) {
private Archive {
Objects.requireNonNull(sha256, "sha256");
@@ -36,10 +36,12 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -55,6 +57,14 @@ public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
private static final String UNDERWORLD_PACK = "underworld";
private static final String UNDERWORLD_REPOSITORY = "IrisDimensions/underworld";
private static final String UNDERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip";
private static final List<String> MANAGED_BETA_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK);
private static final Map<String, ManagedBetaPack> MANAGED_BETA_PACKS = Map.of(
DEFAULT_OVERWORLD_PACK, new ManagedBetaPack(DEFAULT_OVERWORLD_REPOSITORY, DEFAULT_OVERWORLD_RELEASE_URL),
UNDERWORLD_PACK, new ManagedBetaPack(UNDERWORLD_REPOSITORY, UNDERWORLD_RELEASE_URL)
);
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
@@ -74,6 +84,14 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_PACK.equals(pack);
}
public static boolean isManagedBetaPack(String pack) {
return pack != null && MANAGED_BETA_PACKS.containsKey(pack);
}
public static List<String> managedBetaPacks() {
return MANAGED_BETA_PACK_KEYS;
}
public static String defaultOverworldPack() {
return DEFAULT_OVERWORLD_PACK;
}
@@ -115,8 +133,40 @@ public final class PackDownloader {
}
}
public static boolean isManagedBetaPackPresent(File packsFolder, String key) {
if (!isManagedBetaPack(key) || !isPackPresent(packsFolder, key)) {
return false;
}
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
if (resolvedPack == null) {
return false;
}
Path primaryDimension = resolvedPack.toPath().toAbsolutePath().normalize()
.resolve("dimensions")
.resolve(key + ".json");
return !Files.isSymbolicLink(primaryDimension)
&& Files.isRegularFile(primaryDimension, LinkOption.NOFOLLOW_LINKS);
}
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
return downloadManagedBeta(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
}
public static PackInstallResult downloadManagedBeta(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback) throws IOException {
ManagedBetaPack managed = pack == null ? null : MANAGED_BETA_PACKS.get(pack);
if (managed == null) {
throw new IllegalArgumentException("Pack '" + pack + "' has no managed beta release");
}
return download(
packsFolder,
managed.repository(),
managed.releaseUrl(),
forceOverwrite,
true,
pack,
feedback
);
}
/**
@@ -135,7 +185,10 @@ public final class PackDownloader {
}
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
return withDownloadLock(lockKey, () -> {
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
boolean present = isManagedBetaPack(expectedKey)
? isManagedBetaPackPresent(packsFolder, expectedKey)
: isPackPresent(packsFolder, expectedKey);
if (!forceOverwrite && present) {
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false);
}
@@ -235,11 +288,11 @@ public final class PackDownloader {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null;
}
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback);
if (selectedDimension == null) {
return null;
}
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
IrisDimension dimension = data.getDimensionLoader().load(selectedDimension);
if (dimension == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null;
@@ -280,6 +333,29 @@ public final class PackDownloader {
return new PreparedPack(key, name, validation);
}
private static String selectDimensionKey(String[] dimensions, String expectedKey,
Consumer<String> feedback) throws IOException {
if (expectedKey == null || expectedKey.isBlank()) {
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
return null;
}
return dimensions[0];
}
int matches = 0;
for (String dimension : dimensions) {
if (expectedKey.equals(dimension)) {
matches++;
}
}
if (matches != 1) {
throw new IOException("Downloaded pack dimensions " + Arrays.toString(dimensions)
+ " do not contain exactly one requested key '" + expectedKey + "'");
}
return expectedKey;
}
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
Path target = packsRoot.resolve(prepared.key()).normalize();
@@ -299,15 +375,19 @@ public final class PackDownloader {
));
return null;
}
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
boolean present = isManagedBetaPack(prepared.key())
? isManagedBetaPackPresent(packsRoot.toFile(), prepared.key())
: isPackPresent(packsRoot.toFile(), prepared.key());
if (!forceOverwrite && present) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.PACK_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key())
));
return null;
}
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
if (!forceOverwrite && Files.exists(target) && !present) {
IrisLogging.warn("Replacing partial pack folder " + target
+ " (required primary dimension is missing).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
@@ -572,6 +652,10 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_RELEASE_URL;
}
static String underworldReleaseUrl() {
return UNDERWORLD_RELEASE_URL;
}
private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length())
@@ -597,6 +681,9 @@ public final class PackDownloader {
private record PreparedPack(String key, String name, PackValidationResult validation) {
}
private record ManagedBetaPack(String repository, String releaseUrl) {
}
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
}
@@ -68,12 +68,13 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -97,20 +98,17 @@ public class StudioSVC implements IrisService {
@Override
public void onEnable() {
J.a(() -> {
String pack = IrisSettings.get().getGenerator().getDefaultWorldType();
VolmitSender console = BukkitPlatform.console();
runPackMutation(console, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"managed-beta-packs", () -> installMissingManagedBetaPacks(console),
"Failed to install Iris managed beta packs at startup.");
// Presence means a non-empty pack folder: an empty leftover folder must still
// trigger the install instead of shadowing it forever.
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) {
if (PackDownloader.isDefaultOverworld(pack)) {
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
} else {
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
}
}
});
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
if (!PackDownloader.isManagedBetaPack(configuredPack)
&& !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
IrisLogging.warn("Default pack '" + configuredPack
+ "' is not installed. Please download it manually with /iris download " + configuredPack);
}
}
@Override
@@ -348,12 +346,15 @@ public class StudioSVC implements IrisService {
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key))));
}
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
String key = PackDownloader.defaultOverworldPack();
public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) {
if (!PackDownloader.isManagedBetaPack(key)) {
sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release.");
return;
}
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite);
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite);
return finishStandalonePackMutation(sender, outcome);
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE));
}, "Failed to download IrisDimensions/" + key + " beta release.");
}
public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) {
@@ -380,8 +381,8 @@ public class StudioSVC implements IrisService {
}
private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException {
if (PackDownloader.isDefaultOverworld(key)) {
return downloadDefaultOverworldLocked(sender, forceOverwrite);
if (PackDownloader.isManagedBetaPack(key)) {
return downloadManagedBetaLocked(sender, key, forceOverwrite);
}
String descriptor = key.contains("/") ? key : getListing(false).get(key);
@@ -411,21 +412,52 @@ public class StudioSVC implements IrisService {
return new PackListingReference(repository, ref, expectedKey);
}
private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException {
String expectedKey = PackDownloader.defaultOverworldPack();
if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) {
private DownloadOutcome downloadManagedBetaLocked(
VolmitSender sender,
String expectedKey,
boolean forceOverwrite
) throws IOException {
if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return DownloadOutcome.notChanged();
}
PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld(
PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta(
getWorkspaceFolder(),
expectedKey,
forceOverwrite,
sender::sendMessage
);
return DownloadOutcome.from(result);
}
private boolean installMissingManagedBetaPacks(VolmitSender sender) {
boolean changed = false;
boolean restartRequired = false;
for (String key : missingManagedBetaPacks(getWorkspaceFolder())) {
IrisLogging.info("Downloading managed Iris pack " + key + " (beta release)");
try {
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, false);
changed |= outcome.changed();
restartRequired |= outcome.restartRequired();
} catch (Throwable failure) {
IrisLogging.reportError("Failed to download IrisDimensions/" + key + " beta release.", failure);
sender.sendMessage("Failed to download IrisDimensions/" + key + " beta release. " + errorDetail(failure));
}
}
return finishStandalonePackMutation(sender, new DownloadOutcome(changed, restartRequired));
}
static List<String> missingManagedBetaPacks(File workspaceFolder) {
List<String> missing = new ArrayList<>();
for (String key : PackDownloader.managedBetaPacks()) {
if (!PackDownloader.isManagedBetaPackPresent(workspaceFolder, key)) {
missing.add(key);
}
}
return List.copyOf(missing);
}
private DownloadOutcome downloadLocked(
VolmitSender sender,
String repo,
@@ -1,7 +1,6 @@
package art.arcane.iris.util.common.misc;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import java.io.File;
import java.io.FileInputStream;
@@ -16,48 +15,19 @@ public class ServerProperties {
public static final String LEVEL_NAME;
static {
String[] args = ProcessHandle.current()
.info()
.arguments()
.orElse(new String[0]);
String propertiesPath = "server.properties";
String bukkitYml = "bukkit.yml";
String levelName = null;
for (int i = 0; i < args.length; i++) {
String arg = args[i];
String next = i < args.length - 1 ? args[i + 1] : null;
propertiesPath = parse(arg, next, propertiesPath, "-c", "--config");
bukkitYml = parse(arg, next, bukkitYml, "-b", "--bukkit-settings");
levelName = parse(arg, next, levelName, "-w", "--level-name", "--world");
BukkitStartupPaths startupPaths;
try {
startupPaths = BukkitStartupPaths.resolveCurrent();
} catch (IOException exception) {
throw new RuntimeException(exception);
}
SERVER_PROPERTIES = new File(propertiesPath);
BUKKIT_YML = new File(bukkitYml);
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)){
SERVER_PROPERTIES = startupPaths.serverProperties().toFile();
BUKKIT_YML = startupPaths.bukkitConfiguration().toFile();
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) {
DATA.load(in);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (IOException exception) {
throw new RuntimeException(exception);
}
if (levelName != null) LEVEL_NAME = levelName;
else LEVEL_NAME = DATA.getProperty("level-name", "world");
}
private static String parse(
@NotNull String current,
@Nullable String next,
String fallback,
@NotNull String @NotNull ... keys
) {
for (String k : keys) {
if (current.equals(k) && next != null)
return next;
if (current.startsWith(k + "=") && current.length() > k.length() + 1)
return current.substring(k.length() + 1);
}
return fallback;
LEVEL_NAME = startupPaths.levelName();
}
}
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dimension oder Pack, mit der bzw. dem die Welt erstellt wird",
"iris.director.commandiris.param.seed_generate_world_with": "Seed für die Generierung der Welt",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ob man diese Welt automatisch als Hauptwelt benutzt oder nicht",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Den exakten vorhandenen Welt-Slot beim nächsten Neustart ersetzen",
"iris.director.commandiris.director.teleport_another_world": "Teleportieren in eine andere Welt",
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
"iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensión o el pack con el que se creará el mundo",
"iris.director.commandiris.param.seed_generate_world_with": "La semilla con la que se generará el mundo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indica si este mundo debe usarse automáticamente como mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Reemplazar el espacio exacto del mundo existente en el próximo reinicio",
"iris.director.commandiris.director.teleport_another_world": "Teletransportarse a otro mundo",
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
"iris.director.commandiris.param.player_teleport": "El jugador que se teletransportará",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Ulottuvuus / paketti luoda maailma",
"iris.director.commandiris.param.seed_generate_world_with": "Siemenet tuottaa maailman kanssa",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Käytetäänkö tätä maailmaa automaattisesti päämaailmana",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Korvaa täsmällinen olemassa oleva maailmapaikka seuraavalla uudelleenkäynnistyksellä",
"iris.director.commandiris.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
"iris.director.commandiris.param.world_teleport": "Maailman teleportata",
"iris.director.commandiris.param.player_teleport": "Pelaaja teleportata",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimension ou le pack avec lequel créer le monde",
"iris.director.commandiris.param.seed_generate_world_with": "La graine avec laquelle générer le monde",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indique si ce monde doit être utilisé automatiquement comme monde principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Remplacer l’emplacement exact du monde existant au prochain redémarrage",
"iris.director.commandiris.director.teleport_another_world": "Se téléporter vers un autre monde",
"iris.director.commandiris.param.world_teleport": "Le monde vers lequel se téléporter",
"iris.director.commandiris.param.player_teleport": "Le joueur à téléporter",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
"iris.director.commandiris.param.seed_generate_world_with": "הזרע ליצור את העולם עם",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "בין אם להשתמש בעולם באופן אוטומטי כעולם הראשי",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "החלפת משבצת העולם הקיימת המדויקת בהפעלה מחדש הבאה",
"iris.director.commandiris.director.teleport_another_world": "טלפורט לעולם אחר",
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
"iris.director.commandiris.param.player_teleport": "שחקן לטלפורט",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensione o il Pack con cui creare il mondo",
"iris.director.commandiris.param.seed_generate_world_with": "Il seed con cui generare il mondo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Che sia o meno utilizzare automaticamente questo mondo come il mondo principale",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Sostituisci lo slot esatto del mondo esistente al prossimo riavvio",
"iris.director.commandiris.director.teleport_another_world": "Teletrasporto in un altro mondo",
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
"iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
"iris.director.commandiris.param.seed_generate_world_with": "ワールドの生成に使用するシード",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "このワールドをメインワールドとして自動設定するかどうか",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "次回の再起動時に既存の正確なワールドスロットを置き換える",
"iris.director.commandiris.director.teleport_another_world": "別のワールドへテレポートします",
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
"iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
"iris.director.commandiris.param.seed_generate_world_with": "월드 생성에 사용할 시드",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "이 월드를 메인 월드로 자동 사용할지 여부",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "다음 재시작 시 기존의 정확한 월드 슬롯 교체",
"iris.director.commandiris.director.teleport_another_world": "다른 월드로 순간이동합니다",
"iris.director.commandiris.param.world_teleport": "순간이동할 월드",
"iris.director.commandiris.param.player_teleport": "순간이동시킬 플레이어",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "dimensija / paketas sukurti pasaulį su",
"iris.director.commandiris.param.seed_generate_world_with": "Sėkla generuoti pasaulį su",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ar automatiškai naudoti šį pasaulį kaip pagrindinį pasaulį",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Per kitą paleidimą iš naujo pakeisti tikslų esamą pasaulio lizdą",
"iris.director.commandiris.director.teleport_another_world": "Teleportas į kitą pasaulį",
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
"iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "De dimensie/pack om de wereld te creëren met",
"iris.director.commandiris.param.seed_generate_world_with": "Het zaad om de wereld te genereren met",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Of deze wereld automatisch gebruikt moet worden als de belangrijkste wereld",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "De exacte bestaande wereldsleuf bij de volgende herstart vervangen",
"iris.director.commandiris.director.teleport_another_world": "Teleporteren naar een andere wereld",
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
"iris.director.commandiris.param.player_teleport": "Speler naar teleporteren",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Wymiar / pakiet do tworzenia świata z",
"iris.director.commandiris.param.seed_generate_world_with": "Nasienie do generowania świata z",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Czy automatycznie używać tego świata jako głównego świata",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Zastąp dokładny istniejący slot świata przy następnym restarcie",
"iris.director.commandiris.director.teleport_another_world": "Teleport do innego świata",
"iris.director.commandiris.param.world_teleport": "Świat teleportować do",
"iris.director.commandiris.param.player_teleport": "Gracz do teleportowania",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "A dimensão/pack para criar o mundo com",
"iris.director.commandiris.param.seed_generate_world_with": "A semente para gerar o mundo com",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Se deve ou não usar automaticamente este mundo como o mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Substituir o espaço exato do mundo existente no próximo reinício",
"iris.director.commandiris.director.teleport_another_world": "Teletransporte para outro mundo",
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
"iris.director.commandiris.param.player_teleport": "Jogador para teletransportar",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Размер/пакет для создания мира",
"iris.director.commandiris.param.seed_generate_world_with": "Семя, чтобы создать мир с",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Использовать или не использовать этот мир как основной",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Заменить точный существующий слот мира при следующем перезапуске",
"iris.director.commandiris.director.teleport_another_world": "Телепорт в другой мир",
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
"iris.director.commandiris.param.player_teleport": "Игрок телепортируется",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dünyayı yaratmak için boyut / paket",
"iris.director.commandiris.param.seed_generate_world_with": "Dünyayı üretmek için tohum",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Bu dünyayı ana dünya olarak otomatik olarak kullanıp kullanmayalım",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Bir sonraki yeniden başlatmada mevcut tam dünya yuvasını değiştir",
"iris.director.commandiris.director.teleport_another_world": "Teleport başka bir dünyaya",
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
"iris.director.commandiris.param.player_teleport": "Oyuncuya teleport",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Kích thước/ lốc tạo ra thế giới với",
"iris.director.commandiris.param.seed_generate_world_with": "Hạt giống để tạo ra thế giới với",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Có nên tự động sử dụng thế giới này làm thế giới chính hay không",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Thay thế đúng vị trí thế giới hiện có vào lần khởi động lại tiếp theo",
"iris.director.commandiris.director.teleport_another_world": "Name",
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển",
"iris.director.commandiris.param.player_teleport": "Người chơi cần dịch chuyển",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用于创建世界的维度包",
"iris.director.commandiris.param.seed_generate_world_with": "用于生成世界的种子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自动将此世界设为主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重启时替换指定的现有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "传送到另一个世界",
"iris.director.commandiris.param.world_teleport": "要传送到的世界",
"iris.director.commandiris.param.player_teleport": "要传送的玩家",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
"iris.director.commandiris.param.seed_generate_world_with": "用於生成世界的種子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自動將此世界設為主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重新啟動時取代指定的現有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "傳送到另一個世界",
"iris.director.commandiris.param.world_teleport": "要傳送到的世界",
"iris.director.commandiris.param.player_teleport": "要傳送的玩家",