This commit is contained in:
Brian Neumann-Fopiano
2026-08-12 00:42:33 -04:00
parent 8a189195ad
commit 12b97b7994
62 changed files with 2878 additions and 706 deletions
+3 -3
View File
@@ -62,12 +62,12 @@ content selecting on `#minecraft:is_overworld` and friends.
## Install ## Install
**Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start **Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start
the server. On first boot Iris downloads the default `overworld` pack automatically. the server. On first boot Iris downloads the managed `overworld` and `underworld` beta packs automatically.
**Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is **Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is
self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris
downloads the default `overworld` pack before the worldgen datapack is written, so the default prefetches the managed `overworld` and `underworld` beta packs and rebuilds the worldgen datapack;
pack is fully active immediately. Packs installed later register their custom dimension types restart once if startup reports that registry-visible pack data was installed too late for that boot. Packs installed later register their custom dimension types
(height ranges) and custom biomes through the forced datapack at server start - restart once after (height ranges) and custom biomes through the forced datapack at server start - restart once after
adding a pack so worlds get its full heights and biomes; worlds created before that restart run adding a pack so worlds get its full heights and biomes; worlds created before that restart run
with fallback heights. with fallback heights.
@@ -1,9 +1,15 @@
package art.arcane.iris; package art.arcane.iris;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.bootstrap.PluginBootstrap; import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
@@ -12,16 +18,75 @@ import java.nio.file.Path;
public final class IrisBootstrap implements PluginBootstrap { public final class IrisBootstrap implements PluginBootstrap {
@Override @Override
public void bootstrap(BootstrapContext context) { public void bootstrap(BootstrapContext context) {
ProvisionResult provisioned = provision(context); WorldReplacementBootstrapMarker.markBootstrapped();
try {
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
reconcilePendingWorldReplacements(context, startupPaths);
ProvisionResult provisioned = provision(context, startupPaths);
Path datapackRoot = provisioned.datapackRoot(); Path datapackRoot = provisioned.datapackRoot();
context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot); context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot);
} catch (Throwable failure) {
armStartupFailure(context, failure);
}
} }
private static ProvisionResult provision(BootstrapContext context) { private static void reconcilePendingWorldReplacements(
BootstrapContext context,
BukkitStartupPaths startupPaths
) {
try {
WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile(
context.getDataDirectory(),
startupPaths.levelRoot(),
startupPaths.bukkitConfiguration(),
message -> context.getLogger().info(message)
);
if (result.transactions() > 0) {
context.getLogger().info(
"Reconciled {} pending Iris world replacement(s): {} published, {} rolled back, {} retained",
result.transactions(),
result.published(),
result.rolledBack(),
result.retained()
);
}
} catch (IOException failure) {
throw new IllegalStateException(
"Unable to reconcile pending Iris world replacements before registry bootstrap",
failure
);
}
}
static void armStartupFailure(BootstrapContext context, Throwable failure) {
armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY);
}
static <E extends LifecycleEvent> void armStartupFailure(
BootstrapContext context,
Throwable failure,
LifecycleEventType<? super BootstrapContext, ? extends E, ?> eventType
) {
context.getLifecycleManager().registerEventHandler(eventType, event -> {
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
});
try {
context.getLogger().error(
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
failure
);
} catch (Throwable loggingFailure) {
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
}
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
try { try {
return DefaultPackBootstrapProvisioner.provision( return DefaultPackBootstrapProvisioner.provision(
context.getDataDirectory(), context.getDataDirectory(),
message -> context.getLogger().info(message) message -> context.getLogger().info(message),
startupPaths
); );
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("Unable to provision the Iris startup datapack", e); throw new IllegalStateException("Unable to provision the Iris startup datapack", e);
@@ -7,6 +7,7 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacem
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal; import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
@@ -68,7 +69,7 @@ public final class PendingWorldReplacementManager implements Listener {
if (worldKey == null) { if (worldKey == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName); throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
} }
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey); ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), toWorldSlotKey(worldKey));
return worldKey; return worldKey;
} }
@@ -80,8 +81,12 @@ public final class PendingWorldReplacementManager implements Listener {
) throws IOException { ) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender"); VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady(); IrisStartupValidation.requireWorldCreationReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
}
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
@@ -89,13 +94,13 @@ public final class PendingWorldReplacementManager implements Listener {
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE, LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString() requiredWorldKey.toString()
)) { )) {
if (findTransaction(requiredWorldKey) != null) { if (findTransaction(requiredWorldSlotKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
} }
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey); ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldSlotKey);
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed); long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed);
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldKey); String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) { if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks."); throw new IOException("Iris could not compile the dimension datapacks.");
@@ -103,7 +108,8 @@ public final class PendingWorldReplacementManager implements Listener {
UUID transactionId = UUID.randomUUID(); UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS); WorldReplacementFilesystem.requireExistingTarget(paths);
boolean targetPresent = true;
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML, ServerProperties.BUKKIT_YML,
worldName worldName
@@ -127,7 +133,7 @@ public final class PendingWorldReplacementManager implements Listener {
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction( transaction = new Transaction(
transactionId, transactionId,
requiredWorldKey, requiredWorldSlotKey,
worldName, worldName,
target.levelRoot(), target.levelRoot(),
installed.getLoadKey(), installed.getLoadKey(),
@@ -163,7 +169,7 @@ public final class PendingWorldReplacementManager implements Listener {
} catch (Throwable failure) { } catch (Throwable failure) {
if (configurationApplied && transaction != null) { if (configurationApplied && transaction != null) {
try { try {
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction); WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching( if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML, ServerProperties.BUKKIT_YML,
worldName, worldName,
@@ -236,7 +242,7 @@ public final class PendingWorldReplacementManager implements Listener {
if (transaction.phase() == Phase.CLEANUP_PENDING) { if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction); scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) { } else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(transaction.worldKey()) WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
.ifPresent(world -> verifyPublishedWorld(world, transaction)); .ifPresent(world -> verifyPublishedWorld(world, transaction));
} }
} }
@@ -266,7 +272,7 @@ public final class PendingWorldReplacementManager implements Listener {
private void verifyPublishedWorld(World world, Transaction transaction) { private void verifyPublishedWorld(World world, Transaction transaction) {
try { try {
if (!transaction.worldKey().equals(WorldIdentity.key(world))) { if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) {
throw new IOException("Loaded world identity does not match the replacement journal."); throw new IOException("Loaded world identity does not match the replacement journal.");
} }
if (!IrisToolbelt.isIrisWorld(world)) { if (!IrisToolbelt.isIrisWorld(world)) {
@@ -295,6 +301,13 @@ public final class PendingWorldReplacementManager implements Listener {
if (!transaction.packFingerprint().equals(fingerprint)) { if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("The replacement pack changed before runtime verification."); throw new IOException("The replacement pack changed before runtime verification.");
} }
WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(transaction))) {
throw new IOException("bukkit.yml changed before the replacement could be committed.");
}
} catch (Throwable failure) { } catch (Throwable failure) {
initiateRollback(transaction, failure); initiateRollback(transaction, failure);
return; return;
@@ -317,13 +330,14 @@ public final class PendingWorldReplacementManager implements Listener {
try { try {
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING); Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
writeTransaction(rollback); writeTransaction(rollback);
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
} catch (Throwable rollbackFailure) { } catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure); failure.addSuppressed(rollbackFailure);
IrisStartupValidation.markPacksInvalid(List.of( IrisStartupValidation.markPacksInvalid(List.of(
"Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure))); "Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure)));
Iris.reportError("Failed to arm Iris world replacement rollback for " Iris.reportError("Failed to arm Iris world replacement rollback for "
+ transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure); + transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure);
} finally {
ServerConfigurator.restart("An Iris world replacement failed verification and requires a cold restart.");
} }
} }
@@ -348,14 +362,14 @@ public final class PendingWorldReplacementManager implements Listener {
throw new RestartBoundaryRequired("The world-storage transaction has not reached its cold bootstrap."); throw new RestartBoundaryRequired("The world-storage transaction has not reached its cold bootstrap.");
} }
if (transaction.phase() == Phase.PREPARED) { if (transaction.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(replacement)) { if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
writeTransaction(transaction.withPhase(Phase.ARMED));
throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
WorldReplacementFilesystem.discardStage(paths); WorldReplacementFilesystem.discardStage(paths);
deleteJournal(transaction.id()); deleteJournal(transaction.id());
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + "."); Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
return; return;
} else if (current.matchesGeneratorAndSeed(replacement)) {
writeTransaction(transaction.withPhase(Phase.ARMED));
throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
} else { } else {
throw new IOException("bukkit.yml does not match the prepared replacement or its original state."); throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
} }
@@ -376,16 +390,38 @@ public final class PendingWorldReplacementManager implements Listener {
if (!cleanupInFlight.add(transaction.id())) { if (!cleanupInFlight.add(transaction.id())) {
return; return;
} }
try {
J.a(() -> cleanupCommittedReplacement(transaction)); J.a(() -> cleanupCommittedReplacement(transaction));
} catch (Throwable failure) {
cleanupInFlight.remove(transaction.id());
Iris.reportError("Could not schedule retained-backup cleanup for " + transaction.worldKey()
+ "; cleanup will retry without rolling back the verified world.", failure);
}
} }
private void cleanupCommittedReplacement(Transaction transaction) { private void cleanupCommittedReplacement(Transaction transaction) {
try { try {
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction); synchronized (this) {
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id()); Transaction current = findTransaction(transaction.worldKey());
if (current == null
|| !current.id().equals(transaction.id())
|| current.phase() != Phase.CLEANUP_PENDING) {
return;
}
WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
current.worldName()
);
if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(current))) {
throw new IOException("bukkit.yml changed before the retained backup could be removed.");
}
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(current);
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, current.id());
WorldReplacementFilesystem.validateCommittedTarget(paths, current.packFingerprint());
WorldReplacementFilesystem.cleanupBackup(paths); WorldReplacementFilesystem.cleanupBackup(paths);
deleteJournal(transaction.id()); deleteJournal(current.id());
Iris.success("Removed the retained backup for " + transaction.worldKey() + "."); Iris.success("Removed the retained backup for " + current.worldKey() + ".");
}
} catch (Throwable failure) { } catch (Throwable failure) {
Iris.reportError("Could not clean the retained backup for " + transaction.worldKey() Iris.reportError("Could not clean the retained backup for " + transaction.worldKey()
+ "; cleanup will retry without rolling back the verified world.", failure); + "; cleanup will retry without rolling back the verified world.", failure);
@@ -396,7 +432,7 @@ public final class PendingWorldReplacementManager implements Listener {
} }
} }
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException { private ExactWorldSlotPathPolicy.Target prepareTarget(WorldSlotKey worldKey) throws IOException {
Path levelRoot = IrisWorldStorage.levelRoot().toPath(); Path levelRoot = IrisWorldStorage.levelRoot().toPath();
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey); ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
Path dimensions = target.levelRoot().resolve("dimensions"); Path dimensions = target.levelRoot().resolve("dimensions");
@@ -416,6 +452,10 @@ public final class PendingWorldReplacementManager implements Listener {
} }
private Transaction findTransaction(NamespacedKey worldKey) throws IOException { private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
return findTransaction(toWorldSlotKey(worldKey));
}
private Transaction findTransaction(WorldSlotKey worldKey) throws IOException {
for (Transaction transaction : loadTransactions()) { for (Transaction transaction : loadTransactions()) {
if (transaction.worldKey().equals(worldKey)) { if (transaction.worldKey().equals(worldKey)) {
return transaction; return transaction;
@@ -425,159 +465,23 @@ public final class PendingWorldReplacementManager implements Listener {
} }
private List<Transaction> loadTransactions() throws IOException { private List<Transaction> loadTransactions() throws IOException {
Path directory = journalDirectory(false); return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath());
if (directory == null) {
return List.of();
}
ArrayList<Transaction> transactions = new ArrayList<>();
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
for (Path file : files) {
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal entry is unsafe: " + file);
}
transactions.add(readTransaction(file));
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
return List.copyOf(transactions);
}
private Transaction readTransaction(Path file) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(file)) {
properties.load(input);
}
UUID id = UUID.fromString(required(properties, "id"));
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.");
}
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
String dimension = required(properties, "dimension");
if (!safeDimension(dimension)) {
throw new IOException("Replacement journal contains an invalid dimension key.");
}
long seed = parseLong(properties, "seed");
String packFingerprint = required(properties, "packFingerprint");
if (!packFingerprint.matches("[0-9a-f]{64}")) {
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
}
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
Phase phase;
try {
phase = Phase.valueOf(required(properties, "phase"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid phase.", failure);
}
return new Transaction(
id,
worldKey,
dimension,
seed,
packFingerprint,
original,
originalTargetPresent,
phase
);
} }
private void writeTransaction(Transaction transaction) throws IOException { private void writeTransaction(Transaction transaction) throws IOException {
Path directory = Objects.requireNonNull(journalDirectory(true)); WorldReplacementJournal.write(dataDirectory(), transaction);
Path target = directory.resolve(transaction.id() + JOURNAL_SUFFIX);
Properties properties = new Properties();
properties.setProperty("id", transaction.id().toString());
properties.setProperty("worldKey", transaction.worldKey().toString());
properties.setProperty("dimension", transaction.dimension());
properties.setProperty("seed", Long.toString(transaction.seed()));
properties.setProperty("packFingerprint", transaction.packFingerprint());
properties.setProperty("originalTargetPresent", Boolean.toString(transaction.originalTargetPresent()));
properties.setProperty("phase", transaction.phase().name());
writeSnapshot(properties, "original.", transaction.originalConfiguration());
ByteArrayOutputStream output = new ByteArrayOutputStream();
properties.store(output, null);
writeAtomic(target, output.toByteArray());
} }
private void deleteJournal(UUID id) throws IOException { private void deleteJournal(UUID id) throws IOException {
Path directory = journalDirectory(false); WorldReplacementJournal.delete(dataDirectory(), id);
if (directory == null) {
return;
}
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
forceDirectory(directory);
} }
private Path journalDirectory(boolean create) throws IOException { private Path dataDirectory() {
Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).toPath().toAbsolutePath().normalize(); return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (!create) {
return null;
}
Files.createDirectories(directory);
}
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal storage is unsafe: " + directory);
}
return directory;
} }
private static void writeAtomic(Path target, byte[] content) throws IOException { private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException {
Path parent = Objects.requireNonNull(target.getParent(), "journal parent"); return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
try {
try (FileChannel channel = FileChannel.open(
temporary,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
ByteBuffer buffer = ByteBuffer.wrap(content);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
forceDirectory(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
private static ReplacementPaths replacementPaths(
ExactWorldSlotPathPolicy.Target target,
UUID id
) {
String artifactBase = ".iris-replace-" + target.worldKey().getKey() + "-" + id;
return new ReplacementPaths(
target.worldDirectory(),
target.namespaceRoot().resolve(artifactBase + ".stage"),
target.namespaceRoot().resolve(artifactBase + ".backup")
);
}
private static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
return new WorldGeneratorSnapshot(
true,
true,
true,
"Iris:" + transaction.dimension(),
true,
transaction.seed()
);
} }
private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) { private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) {
@@ -633,91 +537,27 @@ public final class PendingWorldReplacementManager implements Listener {
} }
} }
private static World.Environment expectedEnvironment(NamespacedKey worldKey) { private static World.Environment expectedEnvironment(WorldSlotKey worldKey) {
if (NamespacedKey.minecraft("overworld").equals(worldKey)) { if (WorldSlotKey.minecraft("overworld").equals(worldKey)) {
return World.Environment.NORMAL; return World.Environment.NORMAL;
} }
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) { if (WorldSlotKey.minecraft("the_nether").equals(worldKey)) {
return World.Environment.NETHER; return World.Environment.NETHER;
} }
if (NamespacedKey.minecraft("the_end").equals(worldKey)) { if (WorldSlotKey.minecraft("the_end").equals(worldKey)) {
return World.Environment.THE_END; return World.Environment.THE_END;
} }
return null; return null;
} }
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) { private static WorldSlotKey toWorldSlotKey(NamespacedKey worldKey) {
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent())); NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent())); return new WorldSlotKey(requiredWorldKey.getNamespace(), requiredWorldKey.getKey());
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
if (snapshot.generatorPresent()) {
properties.setProperty(prefix + "generator", snapshot.generator());
}
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
if (snapshot.seedPresent()) {
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
}
} }
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException { private static NamespacedKey toNamespacedKey(WorldSlotKey worldKey) {
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent"); WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent"); return new NamespacedKey(requiredWorldKey.namespace(), requiredWorldKey.key());
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
String generator = generatorPresent ? required(properties, prefix + "generator") : null;
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
try {
return new WorldGeneratorSnapshot(
worldsPresent,
worldPresent,
generatorPresent,
generator,
seedPresent,
seed
);
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
}
}
private static boolean safeDimension(String value) {
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
return false;
}
String[] segments = value.split("/", -1);
if (segments.length > 16) {
return false;
}
for (String segment : segments) {
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
return false;
}
}
return true;
}
private static String required(Properties properties, String key) throws IOException {
String value = properties.getProperty(key);
if (value == null || value.isBlank()) {
throw new IOException("Replacement journal is missing " + key + ".");
}
return value.trim();
}
private static boolean parseBoolean(Properties properties, String key) throws IOException {
String value = required(properties, key);
if (!"true".equals(value) && !"false".equals(value)) {
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
}
return Boolean.parseBoolean(value);
}
private static long parseLong(Properties properties, String key) throws IOException {
try {
return Long.parseLong(required(properties, key));
} catch (NumberFormatException failure) {
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
}
} }
private static String detail(Throwable failure) { private static String detail(Throwable failure) {
@@ -740,50 +580,12 @@ public final class PendingWorldReplacementManager implements Listener {
} }
} }
private record Transaction(
UUID id,
NamespacedKey worldKey,
String dimension,
long seed,
String packFingerprint,
WorldGeneratorSnapshot originalConfiguration,
boolean originalTargetPresent,
Phase phase
) {
private Transaction {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(dimension, "dimension");
Objects.requireNonNull(packFingerprint, "packFingerprint");
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
Objects.requireNonNull(phase, "phase");
}
private String worldName() {
return IrisWorldStorage.logicalName(worldKey);
}
private Transaction withPhase(Phase nextPhase) {
return new Transaction(
id,
worldKey,
dimension,
seed,
packFingerprint,
originalConfiguration,
originalTargetPresent,
nextPhase
);
}
}
private enum Phase {
PREPARED,
ARMED,
PUBLISHED,
ROLLBACK_PENDING
}
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) { private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
} }
private static final class RestartBoundaryRequired extends IOException {
private RestartBoundaryRequired(String message) {
super(message);
}
}
} }
@@ -710,9 +710,9 @@ public class CommandIris implements DirectorExecutor {
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false") @Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false")
boolean overwrite boolean overwrite
) { ) {
if (PackDownloader.isDefaultOverworld(pack)) { if (PackDownloader.isManagedBetaPack(pack)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK_BETA_RELEASE, MessageArgument.untrusted("pack", pack), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK_BETA_RELEASE, MessageArgument.untrusted("pack", pack), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite); Iris.service(StudioSVC.class).downloadManagedBeta(sender(), pack, overwrite);
} else { } else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite); Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
@@ -0,0 +1,65 @@
package art.arcane.iris;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager;
import io.papermc.paper.plugin.lifecycle.event.handler.LifecycleEventHandler;
import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEventHandlerConfiguration;
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
import org.junit.Test;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisBootstrapFailureContractTest {
@Test
public void bootstrapFailureAbortsDatapackDiscoveryInsteadOfDisablingOnlyIris() {
BootstrapContext context = mock(BootstrapContext.class);
ComponentLogger logger = mock(ComponentLogger.class);
CapturingLifecycleManager manager = new CapturingLifecycleManager();
IllegalStateException cause = new IllegalStateException("unsafe transaction");
LifecycleEventType<BootstrapContext, LifecycleEvent, ?> eventType = lifecycleEventType();
when(context.getLogger()).thenReturn(logger);
when(context.getLifecycleManager()).thenReturn(manager);
IrisBootstrap.armStartupFailure(context, cause, eventType);
assertSame(eventType, manager.eventType);
IllegalStateException failure = assertThrows(IllegalStateException.class, manager::runCapturedHandler);
assertSame(cause, failure.getCause());
}
@SuppressWarnings("unchecked")
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
}
private static final class CapturingLifecycleManager implements LifecycleEventManager<BootstrapContext> {
private LifecycleEventType<?, ?, ?> eventType;
private LifecycleEventHandler<?> handler;
@Override
public <E extends LifecycleEvent> void registerEventHandler(
LifecycleEventType<? super BootstrapContext, ? extends E, ?> eventType,
LifecycleEventHandler<? super E> eventHandler
) {
this.eventType = eventType;
this.handler = eventHandler;
}
@Override
public void registerEventHandler(
LifecycleEventHandlerConfiguration<? super BootstrapContext> handlerConfiguration
) {
throw new AssertionError("The direct handler overload must be used.");
}
@SuppressWarnings({"rawtypes", "unchecked"})
private void runCapturedHandler() {
((LifecycleEventHandler) handler).run(null);
}
}
}
@@ -42,12 +42,14 @@ public final class ModdedPackInstaller {
} }
public static boolean install(Path configDir, String pack, String branch, public static boolean install(Path configDir, String pack, String branch,
boolean forceOverwrite, Consumer<String> feedback) { boolean forceOverwrite, boolean refreshDatapack,
Consumer<String> feedback) {
if (pack == null || !PACK_NAME.matcher(pack).matches()) { if (pack == null || !PACK_NAME.matcher(pack).matches()) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_PACK_NAME, MessageArgument.untrusted("pack", String.valueOf(pack)))); feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_PACK_NAME, MessageArgument.untrusted("pack", String.valueOf(pack))));
return false; return false;
} }
if (branch == null || !BRANCH_NAME.matcher(branch).matches()) { boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
if (!acceptsBranch(pack, branch)) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_BRANCH_NAME, MessageArgument.untrusted("branch", String.valueOf(branch)))); feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_BRANCH_NAME, MessageArgument.untrusted("branch", String.valueOf(branch))));
return false; return false;
} }
@@ -56,8 +58,8 @@ public final class ModdedPackInstaller {
synchronized (installLock) { synchronized (installLock) {
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
try { try {
PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack) PackDownloader.PackInstallResult result = managedBeta
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) ? PackDownloader.downloadManagedBeta(packs, pack, forceOverwrite, feedback)
: PackDownloader.download( : PackDownloader.download(
packs, packs,
"IrisDimensions/" + pack, "IrisDimensions/" + pack,
@@ -67,7 +69,7 @@ public final class ModdedPackInstaller {
pack, pack,
feedback); feedback);
boolean installed = result != null; boolean installed = result != null;
if (result != null && result.changed()) { if (shouldRefreshDatapack(result, refreshDatapack)) {
// Pack-install completion is one of the four forced-datapack regeneration triggers; every // Pack-install completion is one of the four forced-datapack regeneration triggers; every
// install call site already runs off the server thread, so regenerate inline here. A // install call site already runs off the server thread, so regenerate inline here. A
// regeneration failure must never turn a successful install into a failed one. // regeneration failure must never turn a successful install into a failed one.
@@ -82,7 +84,8 @@ public final class ModdedPackInstaller {
} }
return installed; return installed;
} catch (IOException error) { } catch (IOException error) {
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); String source = managedBeta ? "beta release" : "branch " + branch;
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, source, error);
feedback.accept(IrisLanguage.plain( feedback.accept(IrisLanguage.plain(
PackDownloadMessages.DOWNLOAD_FAILED, PackDownloadMessages.DOWNLOAD_FAILED,
MessageArgument.untrusted("type", error.getClass().getSimpleName()), MessageArgument.untrusted("type", error.getClass().getSimpleName()),
@@ -92,4 +95,13 @@ public final class ModdedPackInstaller {
} }
} }
} }
static boolean acceptsBranch(String pack, String branch) {
return PackDownloader.isManagedBetaPack(pack)
|| (branch != null && BRANCH_NAME.matcher(branch).matches());
}
static boolean shouldRefreshDatapack(PackDownloader.PackInstallResult result, boolean refreshDatapack) {
return result != null && result.changed() && refreshDatapack;
}
} }
@@ -37,6 +37,7 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -253,20 +254,39 @@ public final class ModdedStartup {
if (!config.autoDownloadDefaultPack()) { if (!config.autoDownloadDefaultPack()) {
return; return;
} }
String pack = config.defaultPack();
Path configDir = ModdedEngineBootstrap.loader().configDir(); Path configDir = ModdedEngineBootstrap.loader().configDir();
File packFolder = new File(ModdedPackCommands.packsRoot(), pack); File packsRoot = ModdedPackCommands.packsRoot();
if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) { for (String pack : startupPacks(config.defaultPack())) {
ensureStartupPack(configDir, packsRoot, pack);
}
}
}
static List<String> startupPacks(String configuredDefault) {
LinkedHashSet<String> packs = new LinkedHashSet<>(PackDownloader.managedBetaPacks());
if (configuredDefault != null && !configuredDefault.isBlank()) {
packs.add(configuredDefault);
}
return List.copyOf(packs);
}
private static void ensureStartupPack(Path configDir, File packsRoot, String pack) {
boolean present = PackDownloader.isManagedBetaPack(pack)
? PackDownloader.isManagedBetaPackPresent(packsRoot, pack)
: PackDownloader.isPackPresent(packsRoot, pack);
if (present) {
return; return;
} }
String source = "master branch"; boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source); String branch = managedBeta ? "beta" : "master";
String source = managedBeta ? "beta release" : "master branch";
String role = managedBeta ? "managed beta pack" : "default pack";
LOGGER.info("Iris {} '{}' missing; downloading IrisDimensions/{} ({})", role, pack, pack, source);
boolean installed = ModdedPackInstaller.install( boolean installed = ModdedPackInstaller.install(
configDir, pack, "master", false, configDir, pack, branch, false, false,
(String line) -> LOGGER.info("Iris: {}", line)); (String line) -> LOGGER.info("Iris: {}", line));
if (!installed) { if (!installed) {
LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack); LOGGER.warn("Iris {} '{}' could not be downloaded; install it with /iris download {}", role, pack, pack);
}
} }
} }
} }
@@ -258,8 +258,8 @@ public final class IrisModdedCommands {
static int download(CommandSourceStack source, String pack, static int download(CommandSourceStack source, String pack,
String branch, boolean forceOverwrite) { String branch, boolean forceOverwrite) {
boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack); boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
String baseDownloadSource = defaultOverworld ? "beta release" : "branch " + branch; String baseDownloadSource = managedBeta ? "beta release" : "branch " + branch;
String downloadSource = forceOverwrite String downloadSource = forceOverwrite
? baseDownloadSource + IrisLanguage.plain(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) ? baseDownloadSource + IrisLanguage.plain(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX)
: baseDownloadSource; : baseDownloadSource;
@@ -274,7 +274,7 @@ public final class IrisModdedCommands {
} }
scheduler.async(() -> { scheduler.async(() -> {
boolean installed = ModdedPackInstaller.install( boolean installed = ModdedPackInstaller.install(
ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, true,
(String message) -> scheduler.global(() -> ok(source, message))); (String message) -> scheduler.global(() -> ok(source, message)));
if (installed) { if (installed) {
scheduler.global(() -> ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_INSTALLED_ITS_EXACT_DIMENSION_TYPES_CUSTOM_BIOMES_JOIN_FORCED, MessageArgument.untrusted("pack", pack)))); scheduler.global(() -> ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_INSTALLED_ITS_EXACT_DIMENSION_TYPES_CUSTOM_BIOMES_JOIN_FORCED, MessageArgument.untrusted("pack", pack))));
@@ -383,7 +383,7 @@ public final class ModdedStudioCommands {
File packFolder = new File(ModdedPackCommands.packsRoot(), pack); File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) { if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) { if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
@@ -588,7 +588,7 @@ public final class ModdedStudioCommands {
File templateFolder = new File(packsRoot, template); File templateFolder = new File(packsRoot, template);
if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) { if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) { if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
@@ -177,7 +177,7 @@ public final class ModdedWorldCommands {
} }
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
Thread thread = new Thread(() -> { Thread thread = new Thread(() -> {
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
server.execute(() -> { server.execute(() -> {
if (!installed || !packFolder.isDirectory()) { if (!installed || !packFolder.isDirectory()) {
@@ -280,7 +280,7 @@ public final class ModdedWorldCommands {
} }
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
Thread thread = new Thread(() -> { Thread thread = new Thread(() -> {
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
server.execute(() -> { server.execute(() -> {
if (!installed || !packFolder.isDirectory()) { if (!installed || !packFolder.isDirectory()) {
@@ -0,0 +1,52 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.core.pack.PackDownloader;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedPackInstallerTest {
@Test
public void managedBetaPacksIgnoreTheRequestedBranch() {
assertTrue(ModdedPackInstaller.acceptsBranch("overworld", null));
assertTrue(ModdedPackInstaller.acceptsBranch("underworld", "feature/arbitrary"));
}
@Test
public void nonManagedPacksStillRequireAValidBranch() {
assertTrue(ModdedPackInstaller.acceptsBranch("custom", "stable"));
assertFalse(ModdedPackInstaller.acceptsBranch("custom", null));
assertFalse(ModdedPackInstaller.acceptsBranch("custom", "feature/arbitrary"));
}
@Test
public void startupBatchDefersDatapackRefresh() {
PackDownloader.PackInstallResult changed = new PackDownloader.PackInstallResult("underworld", true, true);
assertFalse(ModdedPackInstaller.shouldRefreshDatapack(changed, false));
assertTrue(ModdedPackInstaller.shouldRefreshDatapack(changed, true));
assertFalse(ModdedPackInstaller.shouldRefreshDatapack(
new PackDownloader.PackInstallResult("underworld", false, false),
true
));
}
}
@@ -0,0 +1,41 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
public class ModdedStartupPackSelectionTest {
@Test
public void managedBetaPacksAreAlwaysInstalledInStableOrder() {
assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("overworld"));
assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("underworld"));
}
@Test
public void configuredNonManagedDefaultIsInstalledAfterManagedBetaPacks() {
assertEquals(
List.of("overworld", "underworld", "custom"),
ModdedStartup.startupPacks("custom")
);
}
}
+1 -1
View File
@@ -228,7 +228,7 @@ tasks.named('compileJava', JavaCompile).configure {
tasks.named('test', Test).configure { tasks.named('test', Test).configure {
jvmArgs('--add-modules', 'jdk.incubator.vector') jvmArgs('--add-modules', 'jdk.incubator.vector')
classpath += files('src/main/resources') classpath = files('src/main/resources') + classpath
} }
configurations.matching { it.name.startsWith('slim') }.all { } configurations.matching { it.name.startsWith('slim') }.all { }
@@ -1,7 +1,5 @@
package art.arcane.iris.core; package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption; import java.nio.file.LinkOption;
@@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy {
private ExactWorldSlotPathPolicy() { private ExactWorldSlotPathPolicy() {
} }
public static Target resolve(Path levelRoot, NamespacedKey worldKey) { public static Target resolve(Path levelRoot, WorldSlotKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot); Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
SlotKind slotKind = classify(requiredWorldKey); SlotKind slotKind = classify(requiredWorldKey);
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions"); Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace()); Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize(); Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize();
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) { if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
throw new Rejection( throw new Rejection(
RejectionReason.PATH_TRAVERSAL, RejectionReason.PATH_TRAVERSAL,
@@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy {
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory); 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"); Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
rejectTraversal(requiredCandidate, "World candidate"); rejectTraversal(requiredCandidate, "World candidate");
Target target = resolve(levelRoot, worldKey); Target target = resolve(levelRoot, worldKey);
@@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy {
} }
} }
private static SlotKind classify(NamespacedKey worldKey) { private static SlotKind classify(WorldSlotKey worldKey) {
if ("iris".equals(worldKey.getNamespace())) { if ("iris".equals(worldKey.namespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) { if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) {
throw new Rejection( throw new Rejection(
RejectionReason.INVALID_IRIS_KEY, RejectionReason.INVALID_IRIS_KEY,
"Iris world keys must be safe single path segments." "Iris world keys must be safe single path segments."
@@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy {
} }
return SlotKind.IRIS_MANAGED; return SlotKind.IRIS_MANAGED;
} }
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) { if (!"minecraft".equals(worldKey.namespace())) {
throw new Rejection( throw new Rejection(
RejectionReason.FOREIGN_NAMESPACE, RejectionReason.FOREIGN_NAMESPACE,
"Only Iris-managed and exact vanilla dimension slots can be replaced." "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 "overworld" -> SlotKind.VANILLA_OVERWORLD;
case "the_nether" -> SlotKind.VANILLA_NETHER; case "the_nether" -> SlotKind.VANILLA_NETHER;
case "the_end" -> SlotKind.VANILLA_END; case "the_end" -> SlotKind.VANILLA_END;
@@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy {
} }
public record Target( public record Target(
NamespacedKey worldKey, WorldSlotKey worldKey,
SlotKind slotKind, SlotKind slotKind,
Path levelRoot, Path levelRoot,
Path namespaceRoot, Path namespaceRoot,
@@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy {
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize(); namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize(); worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
SlotKind expectedSlotKind = classify(worldKey); SlotKind expectedSlotKind = classify(worldKey);
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace()); Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey()); Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key());
if (slotKind != expectedSlotKind if (slotKind != expectedSlotKind
|| !namespaceRoot.equals(expectedNamespaceRoot) || !namespaceRoot.equals(expectedNamespaceRoot)
|| !worldDirectory.equals(expectedWorldDirectory)) { || !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.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Objects; import java.util.Objects;
import java.util.function.Predicate; import java.util.function.Predicate;
public final class BukkitWorldConfiguration { public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object(); private static final Object MUTATION_LOCK = new Object();
private BukkitWorldConfiguration() { 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( public static GeneratorReplacement replaceIfMatching(
File configurationFile, File configurationFile,
String worldName, String worldName,
@@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration {
return new GeneratorReplacement(false, current, replacement); return new GeneratorReplacement(false, current, replacement);
} }
apply(configuration, requiredWorldName, replacement); apply(configuration, requiredWorldName, replacement);
saveAtomic(configurationFile.toPath(), configuration); saveAtomic(configurationFile.toPath(), configuration, true);
return new GeneratorReplacement(true, current, replacement); return new GeneratorReplacement(true, current, replacement);
} }
} }
@@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration {
return false; return false;
} }
apply(configuration, requiredWorldName, requiredRestoration); apply(configuration, requiredWorldName, requiredRestoration);
saveAtomic(configurationFile.toPath(), configuration); saveAtomic(configurationFile.toPath(), configuration, true);
return true; return true;
} }
} }
@@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration {
} }
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException { 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(); Path absoluteTarget = target.toAbsolutePath().normalize();
requireRegularConfigurationFile(absoluteTarget);
Path parent = absoluteTarget.getParent(); Path parent = absoluteTarget.getParent();
if (parent == null) { if (parent == null) {
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget); 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)) { try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true); channel.force(true);
} }
requireRegularConfigurationFile(absoluteTarget);
try { try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) { } 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); Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
} }
} finally { } finally {
@@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration {
} }
private static YamlConfiguration load(File configurationFile) throws IOException { private static YamlConfiguration load(File configurationFile) throws IOException {
requireRegularConfigurationFile(configurationFile.toPath());
YamlConfiguration configuration = new YamlConfiguration(); YamlConfiguration configuration = new YamlConfiguration();
try { try {
configuration.load(configurationFile); 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( private static WorldGeneratorSnapshot snapshot(
YamlConfiguration configuration, YamlConfiguration configuration,
String worldName String worldName
@@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap {
feedback.accept("Restored the retained world for " + transaction.worldKey() + "."); feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK; return ReconcileAction.ROLLED_BACK;
} }
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { Transaction active = transaction;
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement); if (active.phase() == Phase.PREPARED) {
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + "."); 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; return ReconcileAction.ROLLED_BACK;
} }
if (!current.matchesGeneratorAndSeed(replacement)) { if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state."); throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
} }
Transaction active = transaction;
if (active.phase() == Phase.PREPARED) {
active = active.withPhase(Phase.ARMED); active = active.withPhase(Phase.ARMED);
WorldReplacementJournal.write(dataDirectory, active); WorldReplacementJournal.write(dataDirectory, active);
} }
if (active.phase() == Phase.ARMED) { 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( WorldReplacementFilesystem.publish(
paths, paths,
active.originalTargetPresent(), active.originalTargetPresent(),
@@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap {
return ReconcileAction.PUBLISHED; return ReconcileAction.PUBLISHED;
} }
if (active.phase() == Phase.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( WorldReplacementFilesystem.publish(
paths, paths,
active.originalTargetPresent(), active.originalTargetPresent(),
@@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap {
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
? transaction ? transaction
: transaction.withPhase(Phase.ROLLBACK_PENDING); : transaction.withPhase(Phase.ROLLBACK_PENDING);
if (rollback != transaction) { if (transaction.phase() != Phase.ROLLBACK_PENDING) {
WorldReplacementJournal.write(dataDirectory, rollback); WorldReplacementJournal.write(dataDirectory, rollback);
} }
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent()); 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; package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.ExactWorldSlotPathPolicy; 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -26,6 +28,11 @@ import java.util.regex.Pattern;
import java.util.stream.Stream; import java.util.stream.Stream;
public final class WorldReplacementFilesystem { 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( 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$"); "^\\.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( 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) { public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target"); ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
UUID requiredId = Objects.requireNonNull(id, "id"); 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( return new ReplacementPaths(
requiredTarget.worldDirectory(), requiredTarget.worldDirectory(),
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"), 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( public static void publish(
ReplacementPaths paths, ReplacementPaths paths,
boolean originalTargetPresent, boolean originalTargetPresent,
@@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem {
if (state.stagePresent()) { if (state.stagePresent()) {
requireSafeTree(requiredPaths.stage(), "replacement stage"); requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint); 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.targetPresent()) {
if (state.backupPresent()) { if (state.backupPresent()) {
throw new IOException("Replacement target, stage, and backup are all present."); throw new IOException("Replacement target, stage, and backup are all present.");
@@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem {
} }
requireSafeTree(requiredPaths.target(), "replacement target"); requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); 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 { 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) { private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8); byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); 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 { private static void move(Path source, Path target) throws IOException {
forceDirectoryRequired(source.getParent());
try { try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) { } 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); 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; package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy; import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; 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.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
public final class WorldReplacementJournal { public final class WorldReplacementJournal {
@@ -46,6 +50,12 @@ public final class WorldReplacementJournal {
} }
} }
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString())); 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); return List.copyOf(transactions);
} }
@@ -77,8 +87,9 @@ public final class WorldReplacementJournal {
if (directory == null) { if (directory == null) {
return; return;
} }
forceDirectoryRequired(directory);
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX)); Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
forceDirectory(directory); forceDirectoryAfterCommit(directory);
} }
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot) public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
@@ -91,31 +102,44 @@ public final class WorldReplacementJournal {
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) { if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
throw new IOException("The configured level root changed after the world replacement was staged."); 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())) { if (!expectedWorldName.equals(requiredTransaction.worldName())) {
throw new IOException("The logical world name changed after the world replacement was staged."); throw new IOException("The logical world name changed after the world replacement was staged.");
} }
return target; 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(); Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if ("iris".equals(requiredWorldKey.getNamespace())) {
return requiredWorldKey.getKey();
}
Path fileName = requiredLevelRoot.getFileName(); Path fileName = requiredLevelRoot.getFileName();
if (fileName == null || fileName.toString().isBlank()) { if (fileName == null || fileName.toString().isBlank()) {
throw new IllegalArgumentException("Level root must have a logical world name."); throw new IllegalArgumentException("Level root must have a logical world name.");
} }
String levelName = fileName.toString(); 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; return levelName;
} }
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) { if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) {
return levelName + "_nether"; return levelName + "_nether";
} }
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) { if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) {
return levelName + "_the_end"; return levelName + "_the_end";
} }
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey); 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)) { if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
throw new IOException("Replacement journal filename does not match its transaction id."); throw new IOException("Replacement journal filename does not match its transaction id.");
} }
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey")); WorldSlotKey worldKey;
if (worldKey == null) { try {
throw new IOException("Replacement journal contains an invalid world key."); 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"); String worldName = exact(properties, "worldName");
Path recordedLevelRoot = recordedLevelRoot(properties); Path recordedLevelRoot = recordedLevelRoot(properties);
@@ -300,26 +326,46 @@ public final class WorldReplacementJournal {
} }
channel.force(true); channel.force(true);
} }
forceDirectoryRequired(parent);
try { try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) { } 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 { } finally {
Files.deleteIfExists(temporary); 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)) { try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true); 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( public record Transaction(
UUID id, UUID id,
NamespacedKey worldKey, WorldSlotKey worldKey,
String worldName, String worldName,
Path levelRoot, Path levelRoot,
String dimension, String dimension,
@@ -1,6 +1,7 @@
package art.arcane.iris.core.pack; package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler; 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.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -27,7 +28,9 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
@@ -40,9 +43,20 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner { 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 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 int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L; private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L; private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
@@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner {
private DefaultPackBootstrapProvisioner() { private DefaultPackBootstrapProvisioner() {
} }
static List<PackSpec> defaultPacks() {
return DEFAULT_PACKS;
}
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException { 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(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback"); Objects.requireNonNull(feedback, "feedback");
Path serverRoot = Path.of("").toAbsolutePath().normalize(); Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
Path levelRoot = resolveLevelRoot(serverRoot);
HttpClient client = HttpClient.newBuilder() HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15)) .connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS) .followRedirects(HttpClient.Redirect.ALWAYS)
.build(); .build();
ProvisionOptions options = new ProvisionOptions( ProvisionOptions options = new ProvisionOptions(
DEFAULT_SOURCE, DEFAULT_PACKS,
client, client,
Clock.systemUTC(), Clock.systemUTC(),
Duration.ofMinutes(30), Duration.ofMinutes(30),
@@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner {
try { try {
return isProvisioned( return isProvisioned(
dataDirectory, dataDirectory,
resolveLevelRoot(Path.of("").toAbsolutePath().normalize()) BukkitStartupPaths.resolveCurrent().levelRoot()
); );
} catch (IOException | RuntimeException exception) { } catch (IOException | RuntimeException exception) {
return false; return false;
@@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner {
} }
static boolean isProvisioned(Path dataDirectory, Path levelRoot) { static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
try { try {
Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path normalizedLevel = levelRoot.toAbsolutePath().normalize(); Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
Path bootstrapRoot = normalizedData.resolve("bootstrap"); Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path datapackRoot = worldDatapackRoot(normalizedLevel); Path datapackRoot = worldDatapackRoot(normalizedLevel);
Path packRoot = normalizedData.resolve("packs/overworld");
Path markerFile = bootstrapRoot.resolve("provisioned.properties"); Path markerFile = bootstrapRoot.resolve("provisioned.properties");
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) { if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) {
return false; return false;
} }
Properties marker = loadProperties(markerFile); Properties marker = loadProperties(markerFile);
@@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner {
.equals(marker.getProperty("compilerIdentity"))) { .equals(marker.getProperty("compilerIdentity"))) {
return false; return false;
} }
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint")) for (PackSpec spec : requiredPacks) {
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint")) 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")) && datapackRoot.toString().equals(marker.getProperty("datapackPath"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots( && packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
normalizedData, normalizedData,
@@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner {
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(""); PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs"); Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
Path bootstrapRoot = normalizedData.resolve("bootstrap"); Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path legacyDatapackRoot = bootstrapRoot.resolve("datapack"); Path legacyDatapackRoot = bootstrapRoot.resolve("datapack");
Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks"); Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks");
@@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner {
Files.createDirectories(datapacksRoot); Files.createDirectories(datapacksRoot);
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties(); Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
boolean existingPack = isPackRoot(packRoot);
boolean existingDatapack = isDatapackRoot(datapackRoot); boolean existingDatapack = isDatapackRoot(datapackRoot);
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : ""; List<PackPlan> plans = new ArrayList<>(options.packs().size());
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault")); for (PackSpec spec : options.packs()) {
boolean unchangedManagedPack = markerOwned Path packRoot = packsRoot.resolve(spec.key()).normalize();
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")); if (!Objects.equals(packRoot.getParent(), packsRoot)) {
boolean managedDefault = !existingPack || unchangedManagedPack; throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot);
if (Files.isSymbolicLink(packRoot)) { }
managedDefault = false; 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; Map<PackPlan, Path> stagedPacks = new LinkedHashMap<>();
Path extractionRoot = null; List<Path> extractionRoots = new ArrayList<>();
Path compileContainer = null; Path compileContainer = null;
Path packBackup = null;
Path datapackBackup = null; Path datapackBackup = null;
boolean packReplaced = false;
boolean datapackReplaced = false; boolean datapackReplaced = false;
List<PackPublication> packPublications = new ArrayList<>();
boolean committed = false;
try { try {
if (replacePack) { for (PackPlan plan : plans) {
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID()); if (!plan.replace()) {
continue;
}
Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID());
extractionRoots.add(extractionRoot);
Files.createDirectories(extractionRoot); Files.createDirectories(extractionRoot);
Path extractedPack = extractArchive(archive.path(), extractionRoot); Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec());
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID()); Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID());
copyDirectory(extractedPack, stagedPack); copyDirectory(extractedPack, stagedPack);
validatePackRoot(stagedPack); validatePackRoot(stagedPack, plan.spec());
packBackup = replaceWithBackup(stagedPack, packRoot); stagedPacks.put(plan, stagedPack);
packReplaced = true; }
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()); List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
@@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner {
} }
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer); String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots); String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack boolean anyPackReplaced = !packPublications.isEmpty();
boolean rebuildDatapack = anyPackReplaced
|| !existingDatapack || !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint")) || !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity")) || !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
@@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner {
datapackReplaced = true; datapackReplaced = true;
} }
validatePackRoot(packRoot); for (PackPlan plan : plans) {
validatePackRoot(plan.root(), plan.spec());
}
if (!isDatapackRoot(datapackRoot)) { if (!isDatapackRoot(datapackRoot)) {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot); throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
} }
String finalPackFingerprint = directoryFingerprint(packRoot);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots( List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData, normalizedData,
options.levelRoot()); options.levelRoot());
@@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner {
String finalDatapackFingerprint = directoryFingerprint(datapackRoot); String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties(); Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA)); marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
marker.setProperty("source", options.source().toString()); for (PackPlan plan : plans) {
marker.setProperty("sourceSha256", archive.sha256()); marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString());
marker.setProperty("managedDefault", Boolean.toString(managedDefault)); marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256());
marker.setProperty("defaultPackFingerprint", finalPackFingerprint); 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("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity); marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint); marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString()); marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis())); marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker); storePropertiesAtomic(markerFile, marker);
committed = true;
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint); PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true); PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback); for (PackPublication publication : packPublications) {
deleteQuietly(publication.backup(), feedback);
}
deleteQuietly(datapackBackup, feedback); deleteQuietly(datapackBackup, feedback);
deleteQuietly(legacyDatapackRoot, feedback); deleteQuietly(legacyDatapackRoot, feedback);
boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed);
ProvisionStatus status; ProvisionStatus status;
if (!existingPack && !existingDatapack) { if (everyPackMissing && !existingDatapack) {
status = ProvisionStatus.INSTALLED; status = ProvisionStatus.INSTALLED;
} else if (replacePack || rebuildDatapack) { } else if (anyPackReplaced || rebuildDatapack) {
status = ProvisionStatus.UPDATED; status = ProvisionStatus.UPDATED;
} else { } else {
status = ProvisionStatus.UNCHANGED; status = ProvisionStatus.UNCHANGED;
} }
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + "."); feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + ".");
return new ProvisionResult(packRoot, datapackRoot, status); 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) { } catch (IOException failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced); if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) { if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure); failure.addSuppressed(rollbackFailure);
} }
}
throw failure; throw failure;
} catch (RuntimeException | LinkageError failure) { } catch (RuntimeException | LinkageError failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced); if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) { if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure); failure.addSuppressed(rollbackFailure);
} }
}
throw new IOException("Iris bootstrap provisioning failed", failure); throw new IOException("Iris bootstrap provisioning failed", failure);
} finally { } finally {
for (Path stagedPack : stagedPacks.values()) {
deleteQuietly(stagedPack, feedback); deleteQuietly(stagedPack, feedback);
}
for (Path extractionRoot : extractionRoots) {
deleteQuietly(extractionRoot, feedback); deleteQuietly(extractionRoot, feedback);
}
deleteQuietly(compileContainer, feedback); deleteQuietly(compileContainer, feedback);
} }
} }
@@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner {
private static Archive acquireArchive( private static Archive acquireArchive(
Path cacheRoot, Path cacheRoot,
Properties marker, Properties marker,
PackSpec spec,
Consumer<String> feedback, Consumer<String> feedback,
ProvisionOptions options ProvisionOptions options
) throws IOException { ) throws IOException {
Path archivePath = cacheRoot.resolve("default-overworld.zip"); Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip");
Path metadataPath = cacheRoot.resolve("default-overworld.properties"); Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties");
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties(); Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
boolean validCache = false; boolean validCache = false;
if (Files.isRegularFile(archivePath)) { if (Files.isRegularFile(archivePath)) {
try { try {
validCache = validateArchive(archivePath); validCache = validateArchive(archivePath, spec);
} catch (IOException exception) { } 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); 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 (fresh) {
if (cachedSource.isBlank()) {
metadata.setProperty("source", spec.source().toString());
storePropertiesAtomic(metadataPath, metadata);
}
return new Archive(archivePath, sha256(archivePath)); return new Archive(archivePath, sha256(archivePath));
} }
IOException networkFailure = null; IOException networkFailure = null;
for (int attempt = 1; attempt <= options.attempts(); attempt++) { for (int attempt = 1; attempt <= options.attempts(); attempt++) {
try { try {
HttpRequest.Builder request = HttpRequest.newBuilder(options.source()) HttpRequest.Builder request = HttpRequest.newBuilder(spec.source())
.timeout(options.requestTimeout()) .timeout(options.requestTimeout())
.header("Accept", "application/octet-stream") .header("Accept", "application/octet-stream")
.header("User-Agent", "Iris-Bootstrap") .header("User-Agent", "Iris-Bootstrap")
.GET(); .GET();
if (validCache) { if (cacheMatchesSource) {
String etag = metadata.getProperty("etag"); String etag = metadata.getProperty("etag");
String lastModified = metadata.getProperty("lastModified"); String lastModified = metadata.getProperty("lastModified");
if (etag != null && !etag.isBlank()) { if (etag != null && !etag.isBlank()) {
@@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner {
} }
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream()); HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode(); int status = response.statusCode();
if (status == 304 && validCache) { if (status == 304 && cacheMatchesSource) {
close(response.body()); close(response.body());
metadata.setProperty("source", spec.source().toString());
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis())); metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(metadataPath, metadata); storePropertiesAtomic(metadataPath, metadata);
return new Archive(archivePath, sha256(archivePath)); return new Archive(archivePath, sha256(archivePath));
@@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner {
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip"); Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
try { try {
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) { try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
copyLimited(input, output, options.maxArchiveBytes()); copyLimited(input, output, options.maxArchiveBytes(), spec);
} }
if (!validateArchive(temporary)) { if (!validateArchive(temporary, spec)) {
throw new IOException("Downloaded default overworld archive is invalid"); throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid");
} }
move(temporary, archivePath, true); move(temporary, archivePath, true);
} finally { } finally {
@@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner {
Properties updated = new Properties(); Properties updated = new Properties();
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value)); response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", 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("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath)); updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated); 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")); return new Archive(archivePath, updated.getProperty("sha256"));
} }
close(response.body()); 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)) { if (!retryableStatus(status)) {
networkFailure = statusFailure; networkFailure = statusFailure;
break; break;
@@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure; networkFailure = statusFailure;
} catch (InterruptedException exception) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); 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) { } catch (IOException exception) {
networkFailure = exception; networkFailure = exception;
} }
@@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner {
} }
} }
if (validCache) { if (cacheMatchesSource) {
feedback.accept("Default overworld download failed; using the validated cached archive."); feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath)); return new Archive(archivePath, sha256(archivePath));
} }
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) { if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
String sourceSha = marker.getProperty("sourceSha256"); String sourceSha = markerProperty(marker, spec, "sourceSha256");
return new Archive(null, sourceSha); return new Archive(null, sourceSha);
} }
throw networkFailure == null throw networkFailure == null
? new IOException("Default overworld archive is unavailable and no valid cache exists") ? new IOException("Iris " + spec.key() + " beta 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", networkFailure);
} }
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) { private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
String sourceSha = marker.getProperty("sourceSha256"); String sourceSha = markerProperty(marker, spec, "sourceSha256");
String expectedFingerprint = marker.getProperty("defaultPackFingerprint"); String expectedFingerprint = markerProperty(marker, spec, "fingerprint");
if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) { if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) {
return false; return false;
} }
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld"); Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key());
try { try {
return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint); return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint);
} catch (IOException | RuntimeException exception) { } catch (IOException | RuntimeException exception) {
return false; return false;
} }
} }
static Path resolveLevelRoot(Path serverRoot) throws IOException { public static Path resolveLevelRoot(Path serverRoot) throws IOException {
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize(); return BukkitStartupPaths.resolve(serverRoot).levelRoot();
String levelName = readConfiguredLevelName(normalizedServerRoot);
Path configured = Path.of(levelName);
return configured.isAbsolute()
? configured.normalize()
: normalizedServerRoot.resolve(configured).normalize();
} }
private static String readConfiguredLevelName(Path serverRoot) throws IOException { private static boolean validateArchive(Path archive, PackSpec spec) 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 {
int entries = 0; int entries = 0;
long expanded = 0L; long expanded = 0L;
boolean dimensionFound = false; boolean dimensionFound = false;
@@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
entries++; entries++;
if (entries > MAX_ARCHIVE_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()); String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) { 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; dimensionFound = true;
} }
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
@@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) { while ((read = zip.read(buffer)) >= 0) {
expanded += read; expanded += read;
if (expanded > MAX_EXPANDED_BYTES) { 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; 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) { 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; long expanded = 0L;
int entries = 0; int entries = 0;
@@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
entries++; entries++;
if (entries > MAX_ARCHIVE_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()); String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize(); Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) { 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()) { if (entry.isDirectory()) {
Files.createDirectories(output); Files.createDirectories(output);
@@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) { while ((read = zip.read(buffer)) >= 0) {
expanded += read; expanded += read;
if (expanded > MAX_EXPANDED_BYTES) { 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); file.write(buffer, 0, read);
} }
@@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner {
zip.closeEntry(); zip.closeEntry();
} }
} }
if (isPackRoot(extractionRoot)) { if (isPackRoot(extractionRoot, spec)) {
return extractionRoot; return extractionRoot;
} }
List<Path> candidates = new ArrayList<>(); List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) { try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
for (Path child : children) { for (Path child : children) {
if (Files.isDirectory(child) && isPackRoot(child)) { if (Files.isDirectory(child) && isPackRoot(child, spec)) {
candidates.add(child); candidates.add(child);
} }
} }
} }
if (candidates.size() != 1) { 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(); return candidates.getFirst();
} }
private static String normalizedZipEntry(String raw) throws IOException { private static String normalizedZipEntry(String raw) throws IOException {
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) { 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('\\', '/'); String normalized = raw.replace('\\', '/');
Path path = Path.of(normalized).normalize(); Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) { 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('\\', '/'); return path.toString().replace('\\', '/');
} }
private static boolean isPackRoot(Path path) { private static boolean isPackRoot(Path path, PackSpec spec) {
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json")); return path != null
&& Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json"));
} }
private static void validatePackRoot(Path path) throws IOException { private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
if (!isPackRoot(path)) { if (!isPackRoot(path, spec)) {
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path); throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/"
+ spec.requiredDimension() + ".json at " + path);
} }
} }
@@ -636,21 +693,21 @@ public final class DefaultPackBootstrapProvisioner {
} }
private static IOException rollback( private static IOException rollback(
Path packRoot, List<PackPublication> packPublications,
Path packBackup,
boolean packReplaced,
Path datapackRoot, Path datapackRoot,
Path datapackBackup, Path datapackBackup,
boolean datapackReplaced boolean datapackReplaced
) { ) {
IOException failure = null; IOException failure = null;
try { try {
restore(packRoot, packBackup, packReplaced); restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) { } catch (IOException exception) {
failure = exception; failure = exception;
} }
for (int index = packPublications.size() - 1; index >= 0; index--) {
PackPublication publication = packPublications.get(index);
try { try {
restore(datapackRoot, datapackBackup, datapackReplaced); restore(publication.root(), publication.backup(), true);
} catch (IOException exception) { } catch (IOException exception) {
if (failure == null) { if (failure == null) {
failure = exception; failure = exception;
@@ -658,6 +715,7 @@ public final class DefaultPackBootstrapProvisioner {
failure.addSuppressed(exception); failure.addSuppressed(exception);
} }
} }
}
return failure; 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]; byte[] buffer = new byte[8192];
long total = 0L; long total = 0L;
int read; int read;
while ((read = input.read(buffer)) >= 0) { while ((read = input.read(buffer)) >= 0) {
total += read; total += read;
if (total > limit) { 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); output.write(buffer, 0, read);
} }
@@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner {
Thread.sleep(duration.toMillis()); Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); 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) { private static void close(InputStream input) {
try { try {
input.close(); input.close();
@@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner {
UNCHANGED UNCHANGED
} }
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) { public record ProvisionResult(Map<String, Path> packRoots, Path datapackRoot, ProvisionStatus status) {
public ProvisionResult { public ProvisionResult {
Objects.requireNonNull(packRoot, "packRoot"); packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots"));
Objects.requireNonNull(datapackRoot, "datapackRoot"); Objects.requireNonNull(datapackRoot, "datapackRoot");
Objects.requireNonNull(status, "status"); Objects.requireNonNull(status, "status");
} }
} }
record ProvisionOptions( record ProvisionOptions(
URI source, List<PackSpec> packs,
HttpClient client, HttpClient client,
Clock clock, Clock clock,
Duration refreshInterval, Duration refreshInterval,
@@ -820,18 +901,48 @@ public final class DefaultPackBootstrapProvisioner {
Path levelRoot Path levelRoot
) { ) {
ProvisionOptions { ProvisionOptions {
Objects.requireNonNull(source, "source"); packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
Objects.requireNonNull(client, "client"); Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock"); Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval"); Objects.requireNonNull(refreshInterval, "refreshInterval");
Objects.requireNonNull(requestTimeout, "requestTimeout"); Objects.requireNonNull(requestTimeout, "requestTimeout");
Objects.requireNonNull(retryDelay, "retryDelay"); Objects.requireNonNull(retryDelay, "retryDelay");
Objects.requireNonNull(levelRoot, "levelRoot"); Objects.requireNonNull(levelRoot, "levelRoot");
if (attempts < 1 || maxArchiveBytes < 1L) { if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) {
throw new IllegalArgumentException("Invalid bootstrap provisioning options"); 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 record Archive(Path path, String sha256) {
private Archive { private Archive {
@@ -36,10 +36,12 @@ import java.nio.file.Files;
import java.nio.file.LinkOption; import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; 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_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/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 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_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 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}"); 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); 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() { public static String defaultOverworldPack() {
return DEFAULT_OVERWORLD_PACK; 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 { 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; String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
return withDownloadLock(lockKey, () -> { 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))); sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false); return new PackInstallResult(expectedKey, false, false);
} }
@@ -235,11 +288,11 @@ public final class PackDownloader {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null; return null;
} }
if (dimensions.length != 1) { String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED)); if (selectedDimension == null) {
return null; return null;
} }
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]); IrisDimension dimension = data.getDimensionLoader().load(selectedDimension);
if (dimension == null) { if (dimension == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null; return null;
@@ -280,6 +333,29 @@ public final class PackDownloader {
return new PreparedPack(key, name, validation); 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, private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
boolean forceOverwrite, Consumer<String> feedback) throws IOException { boolean forceOverwrite, Consumer<String> feedback) throws IOException {
Path target = packsRoot.resolve(prepared.key()).normalize(); Path target = packsRoot.resolve(prepared.key()).normalize();
@@ -299,15 +375,19 @@ public final class PackDownloader {
)); ));
return null; 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( sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.PACK_KEY_CONFLICT, PackDownloadMessages.PACK_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key()) MessageArgument.untrusted("key", prepared.key())
)); ));
return null; return null;
} }
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) { if (!forceOverwrite && Files.exists(target) && !present) {
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found)."); IrisLogging.warn("Replacing partial pack folder " + target
+ " (required primary dimension is missing).");
} }
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key())); Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
@@ -572,6 +652,10 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_RELEASE_URL; return DEFAULT_OVERWORLD_RELEASE_URL;
} }
static String underworldReleaseUrl() {
return UNDERWORLD_RELEASE_URL;
}
private static void validateGithubRef(String qualifiedRef) { private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/") String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length()) ? qualifiedRef.substring("refs/heads/".length())
@@ -597,6 +681,9 @@ public final class PackDownloader {
private record PreparedPack(String key, String name, PackValidationResult validation) { 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) { 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.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -97,21 +98,18 @@ public class StudioSVC implements IrisService {
@Override @Override
public void onEnable() { public void onEnable() {
J.a(() -> { VolmitSender console = BukkitPlatform.console();
String pack = IrisSettings.get().getGenerator().getDefaultWorldType(); 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 String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
// trigger the install instead of shadowing it forever. if (!PackDownloader.isManagedBetaPack(configuredPack)
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) { && !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
if (PackDownloader.isDefaultOverworld(pack)) { IrisLogging.warn("Default pack '" + configuredPack
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)"); + "' is not installed. Please download it manually with /iris download " + configuredPack);
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);
} }
} }
});
}
@Override @Override
public void onDisable() { public void onDisable() {
@@ -348,12 +346,15 @@ public class StudioSVC implements IrisService {
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key)))); }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key))));
} }
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) { public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) {
String key = PackDownloader.defaultOverworldPack(); if (!PackDownloader.isManagedBetaPack(key)) {
sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release.");
return;
}
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite); DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite);
return finishStandalonePackMutation(sender, outcome); 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) { 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 { private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException {
if (PackDownloader.isDefaultOverworld(key)) { if (PackDownloader.isManagedBetaPack(key)) {
return downloadDefaultOverworldLocked(sender, forceOverwrite); return downloadManagedBetaLocked(sender, key, forceOverwrite);
} }
String descriptor = key.contains("/") ? key : getListing(false).get(key); String descriptor = key.contains("/") ? key : getListing(false).get(key);
@@ -411,21 +412,52 @@ public class StudioSVC implements IrisService {
return new PackListingReference(repository, ref, expectedKey); return new PackListingReference(repository, ref, expectedKey);
} }
private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException { private DownloadOutcome downloadManagedBetaLocked(
String expectedKey = PackDownloader.defaultOverworldPack(); VolmitSender sender,
if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) { String expectedKey,
boolean forceOverwrite
) throws IOException {
if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return DownloadOutcome.notChanged(); return DownloadOutcome.notChanged();
} }
PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld( PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta(
getWorkspaceFolder(), getWorkspaceFolder(),
expectedKey,
forceOverwrite, forceOverwrite,
sender::sendMessage sender::sendMessage
); );
return DownloadOutcome.from(result); 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( private DownloadOutcome downloadLocked(
VolmitSender sender, VolmitSender sender,
String repo, String repo,
@@ -1,7 +1,6 @@
package art.arcane.iris.util.common.misc; package art.arcane.iris.util.common.misc;
import org.jetbrains.annotations.NotNull; import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import org.jetbrains.annotations.Nullable;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@@ -16,48 +15,19 @@ public class ServerProperties {
public static final String LEVEL_NAME; public static final String LEVEL_NAME;
static { static {
String[] args = ProcessHandle.current() BukkitStartupPaths startupPaths;
.info() try {
.arguments() startupPaths = BukkitStartupPaths.resolveCurrent();
.orElse(new String[0]); } catch (IOException exception) {
throw new RuntimeException(exception);
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");
} }
SERVER_PROPERTIES = startupPaths.serverProperties().toFile();
SERVER_PROPERTIES = new File(propertiesPath); BUKKIT_YML = startupPaths.bukkitConfiguration().toFile();
BUKKIT_YML = new File(bukkitYml);
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) { try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) {
DATA.load(in); DATA.load(in);
} catch (IOException e) { } catch (IOException exception) {
throw new RuntimeException(e); throw new RuntimeException(exception);
} }
LEVEL_NAME = startupPaths.levelName();
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;
} }
} }
@@ -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.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.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.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.director.teleport_another_world": "Teleportieren in eine andere Welt",
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation", "iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
"iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler", "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.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.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.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.director.teleport_another_world": "Teletransportarse a otro mundo",
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará", "iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
"iris.director.commandiris.param.player_teleport": "El jugador 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.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.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.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.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
"iris.director.commandiris.param.world_teleport": "Maailman teleportata", "iris.director.commandiris.param.world_teleport": "Maailman teleportata",
"iris.director.commandiris.param.player_teleport": "Pelaaja 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.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.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.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 lemplacement 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.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.world_teleport": "Le monde vers lequel se téléporter",
"iris.director.commandiris.param.player_teleport": "Le joueur à 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.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "טלפורט לעולם אחר",
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט", "iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
"iris.director.commandiris.param.player_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.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.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.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.director.teleport_another_world": "Teletrasporto in un altro mondo",
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi", "iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
"iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare", "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.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "別のワールドへテレポートします",
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド", "iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
"iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー", "iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩", "iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "다른 월드로 순간이동합니다",
"iris.director.commandiris.param.world_teleport": "순간이동할 월드", "iris.director.commandiris.param.world_teleport": "순간이동할 월드",
"iris.director.commandiris.param.player_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.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.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.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.director.teleport_another_world": "Teleportas į kitą pasaulį",
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į", "iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
"iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą", "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.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.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.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.director.teleport_another_world": "Teleporteren naar een andere wereld",
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar", "iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
"iris.director.commandiris.param.player_teleport": "Speler naar teleporteren", "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.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.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.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.director.teleport_another_world": "Teleport do innego świata",
"iris.director.commandiris.param.world_teleport": "Świat teleportować do", "iris.director.commandiris.param.world_teleport": "Świat teleportować do",
"iris.director.commandiris.param.player_teleport": "Gracz do teleportowania", "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.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.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.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.director.teleport_another_world": "Teletransporte para outro mundo",
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar", "iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
"iris.director.commandiris.param.player_teleport": "Jogador 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.dimension_pack_create_world_with": "Размер/пакет для создания мира",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "Телепорт в другой мир",
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир", "iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
"iris.director.commandiris.param.player_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.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.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.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.director.teleport_another_world": "Teleport başka bir dünyaya",
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a", "iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
"iris.director.commandiris.param.player_teleport": "Oyuncuya teleport", "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.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.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.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.director.teleport_another_world": "Name",
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển", "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", "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.dimension_pack_create_world_with": "用于创建世界的维度包",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "传送到另一个世界",
"iris.director.commandiris.param.world_teleport": "要传送到的世界", "iris.director.commandiris.param.world_teleport": "要传送到的世界",
"iris.director.commandiris.param.player_teleport": "要传送的玩家", "iris.director.commandiris.param.player_teleport": "要传送的玩家",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包", "iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
"iris.director.commandiris.param.seed_generate_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.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.director.teleport_another_world": "傳送到另一個世界",
"iris.director.commandiris.param.world_teleport": "要傳送到的世界", "iris.director.commandiris.param.world_teleport": "要傳送到的世界",
"iris.director.commandiris.param.player_teleport": "要傳送的玩家", "iris.director.commandiris.param.player_teleport": "要傳送的玩家",
@@ -1,6 +1,5 @@
package art.arcane.iris.core; package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -22,22 +21,22 @@ public class ExactWorldSlotPathPolicyTest {
Path canonicalRoot = levelRoot.toRealPath(); Path canonicalRoot = levelRoot.toRealPath();
List<SlotExpectation> expectations = List.of( List<SlotExpectation> expectations = List.of(
new SlotExpectation( new SlotExpectation(
new NamespacedKey("iris", "underworld"), new WorldSlotKey("iris", "underworld"),
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED, ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
"dimensions/iris/underworld" "dimensions/iris/underworld"
), ),
new SlotExpectation( new SlotExpectation(
NamespacedKey.minecraft("overworld"), WorldSlotKey.minecraft("overworld"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD, ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
"dimensions/minecraft/overworld" "dimensions/minecraft/overworld"
), ),
new SlotExpectation( new SlotExpectation(
NamespacedKey.minecraft("the_nether"), WorldSlotKey.minecraft("the_nether"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER, ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
"dimensions/minecraft/the_nether" "dimensions/minecraft/the_nether"
), ),
new SlotExpectation( new SlotExpectation(
NamespacedKey.minecraft("the_end"), WorldSlotKey.minecraft("the_end"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END, ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
"dimensions/minecraft/the_end" "dimensions/minecraft/the_end"
) )
@@ -63,7 +62,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve( ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
levelRoot, levelRoot,
NamespacedKey.minecraft("the_nether") WorldSlotKey.minecraft("the_nether")
); );
assertEquals(worldDirectory.toRealPath(), target.worldDirectory()); assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
@@ -75,15 +74,15 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows( ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world")) () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("foreign", "world"))
); );
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows( ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world")) () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("iris", "nested/world"))
); );
ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows( ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom")) () -> ExactWorldSlotPathPolicy.resolve(levelRoot, WorldSlotKey.minecraft("custom"))
); );
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason()); assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
@@ -97,7 +96,7 @@ public class ExactWorldSlotPathPolicyTest {
@Test @Test
public void validatesOnlyTheExactExpectedCandidate() throws Exception { public void validatesOnlyTheExactExpectedCandidate() throws Exception {
Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath(); Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath();
NamespacedKey worldKey = NamespacedKey.minecraft("the_nether"); WorldSlotKey worldKey = WorldSlotKey.minecraft("the_nether");
Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether"); Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether");
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate( ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
@@ -136,7 +135,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve( () -> ExactWorldSlotPathPolicy.resolve(
levelRoot.resolve("child/.."), levelRoot.resolve("child/.."),
new NamespacedKey("iris", "underworld") new WorldSlotKey("iris", "underworld")
) )
); );
@@ -148,41 +147,41 @@ public class ExactWorldSlotPathPolicyTest {
Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath(); Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath();
Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level"); Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
Files.createSymbolicLink(levelLink, linkedLevelTarget); Files.createSymbolicLink(levelLink, linkedLevelTarget);
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld")); assertSymbolicLinkRejected(levelLink, new WorldSlotKey("iris", "underworld"));
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath(); Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath(); Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions); Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions);
assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld")); assertSymbolicLinkRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath(); Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions")); Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath(); Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace); Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace);
assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether")); assertSymbolicLinkRejected(namespaceLevel, WorldSlotKey.minecraft("the_nether"));
Path targetLevel = temporaryFolder.newFolder("linked-target").toPath(); Path targetLevel = temporaryFolder.newFolder("linked-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft")); Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
Path externalTarget = temporaryFolder.newFolder("external-target").toPath(); Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget); Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether")); assertSymbolicLinkRejected(targetLevel, WorldSlotKey.minecraft("the_nether"));
} }
@Test @Test
public void rejectsNonDirectoryStorageEntries() throws Exception { public void rejectsNonDirectoryStorageEntries() throws Exception {
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath(); Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory"); Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory");
assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld")); assertUnsafeEntryRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld"));
Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath(); Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath();
Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions")); Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Files.writeString(dimensions.resolve("iris"), "not a directory"); Files.writeString(dimensions.resolve("iris"), "not a directory");
assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld")); assertUnsafeEntryRejected(namespaceLevel, new WorldSlotKey("iris", "underworld"));
Path targetLevel = temporaryFolder.newFolder("file-target").toPath(); Path targetLevel = temporaryFolder.newFolder("file-target").toPath();
Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris")); Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
Files.writeString(namespace.resolve("underworld"), "not a directory"); Files.writeString(namespace.resolve("underworld"), "not a directory");
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld")); assertUnsafeEntryRejected(targetLevel, new WorldSlotKey("iris", "underworld"));
} }
@Test @Test
@@ -191,13 +190,13 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows( ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld")) () -> ExactWorldSlotPathPolicy.resolve(missing, new WorldSlotKey("iris", "underworld"))
); );
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows( ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve( () -> ExactWorldSlotPathPolicy.resolve(
missing.toAbsolutePath().getRoot(), missing.toAbsolutePath().getRoot(),
new NamespacedKey("iris", "underworld") new WorldSlotKey("iris", "underworld")
) )
); );
@@ -205,7 +204,7 @@ public class ExactWorldSlotPathPolicyTest {
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason()); assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason());
} }
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) { private void assertSymbolicLinkRejected(Path levelRoot, WorldSlotKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows( ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey) () -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -214,7 +213,7 @@ public class ExactWorldSlotPathPolicyTest {
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason()); assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason());
} }
private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) { private void assertUnsafeEntryRejected(Path levelRoot, WorldSlotKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows( ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class, ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey) () -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -224,7 +223,7 @@ public class ExactWorldSlotPathPolicyTest {
} }
private record SlotExpectation( private record SlotExpectation(
NamespacedKey worldKey, WorldSlotKey worldKey,
ExactWorldSlotPathPolicy.SlotKind slotKind, ExactWorldSlotPathPolicy.SlotKind slotKind,
String relativePath String relativePath
) { ) {
@@ -0,0 +1,260 @@
package art.arcane.iris.core.lifecycle;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
public class BukkitStartupPathsTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void defaultsResolveAgainstServerWorkingDirectory() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
BukkitStartupPaths paths = BukkitStartupPaths.resolve(serverRoot, new String[0]);
assertEquals(serverRoot, paths.serverRoot());
assertEquals(serverRoot.resolve("server.properties"), paths.serverProperties());
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
assertEquals(serverRoot, paths.worldContainer());
assertEquals("world", paths.levelName());
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
}
@Test
public void shortSeparatedArgumentsOverrideRelativeDefaults() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("short-config"));
Path properties = configurationRoot.resolve("custom.properties");
Path bukkit = configurationRoot.resolve("custom-bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: relative-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-c", "short-config/custom.properties",
"-b", "short-config/custom-bukkit.yml",
"-w", "argument-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("relative-worlds"), paths.worldContainer());
assertEquals("argument-world", paths.levelName());
assertEquals(serverRoot.resolve("relative-worlds/argument-world"), paths.levelRoot());
}
@Test
public void longSeparatedArgumentsUseCustomPropertiesAndWorldContainer() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("long-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=ignored-property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: long-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config", "long-config/server.properties",
"--bukkit-settings", "long-config/bukkit.yml",
"--world", "long-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("long-worlds"), paths.worldContainer());
assertEquals("long-world", paths.levelName());
assertEquals(serverRoot.resolve("long-worlds/long-world"), paths.levelRoot());
}
@Test
public void equalsArgumentsResolveRelativePathsAndLastWorldOverride() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("equals-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: equals-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config=equals-config/server.properties",
"--bukkit-settings=equals-config/bukkit.yml",
"--world=first-world",
"-w=last-world"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("equals-worlds"), paths.worldContainer());
assertEquals("last-world", paths.levelName());
assertEquals(serverRoot.resolve("equals-worlds/last-world"), paths.levelRoot());
}
@Test
public void absoluteConfigurationContainerAndLevelPathsRemainAbsolute() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("absolute-config"));
Path worldContainer = Files.createDirectories(serverRoot.resolve("absolute-worlds"));
Path absoluteLevel = serverRoot.resolve("absolute-level");
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: '" + worldContainer + "'\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths propertiesPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"--config=" + properties,
"--bukkit-settings=" + bukkit
}
);
BukkitStartupPaths argumentPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-c", properties.toString(),
"-b", bukkit.toString(),
"-w", absoluteLevel.toString()
}
);
assertEquals(properties, propertiesPaths.serverProperties());
assertEquals(bukkit, propertiesPaths.bukkitConfiguration());
assertEquals(worldContainer, propertiesPaths.worldContainer());
assertEquals(worldContainer.resolve("property-world"), propertiesPaths.levelRoot());
assertEquals(absoluteLevel.toString(), argumentPaths.levelName());
assertEquals(absoluteLevel, argumentPaths.levelRoot());
}
@Test
public void worldContainerArgumentsOverrideBukkitConfiguration() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path bukkit = serverRoot.resolve("bukkit.yml");
Files.writeString(
bukkit,
"settings:\n world-container: ignored-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths shortPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"-W", "short-worlds", "--world=short-level"}
);
BukkitStartupPaths longPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--world-dir=first-worlds", "--universe", "second-worlds"}
);
BukkitStartupPaths explicitPaths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--world-container=explicit-worlds"}
);
assertEquals(serverRoot.resolve("short-worlds"), shortPaths.worldContainer());
assertEquals(serverRoot.resolve("short-worlds/short-level"), shortPaths.levelRoot());
assertEquals(serverRoot.resolve("second-worlds"), longPaths.worldContainer());
assertEquals(serverRoot.resolve("second-worlds/world"), longPaths.levelRoot());
assertEquals(serverRoot.resolve("explicit-worlds"), explicitPaths.worldContainer());
assertEquals(serverRoot.resolve("explicit-worlds/world"), explicitPaths.levelRoot());
}
@Test
public void compactShortArgumentsMatchServerOptionParsing() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
Path configurationRoot = Files.createDirectories(serverRoot.resolve("compact-config"));
Path properties = configurationRoot.resolve("server.properties");
Path bukkit = configurationRoot.resolve("bukkit.yml");
Files.writeString(properties, "level-name=ignored-world\n", StandardCharsets.UTF_8);
Files.writeString(
bukkit,
"settings:\n world-container: ignored-worlds\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{
"-ccompact-config/server.properties",
"-bcompact-config/bukkit.yml",
"-Wcompact-worlds",
"-wcompact-level"
}
);
assertEquals(properties, paths.serverProperties());
assertEquals(bukkit, paths.bukkitConfiguration());
assertEquals(serverRoot.resolve("compact-worlds"), paths.worldContainer());
assertEquals("compact-level", paths.levelName());
assertEquals(serverRoot.resolve("compact-worlds/compact-level"), paths.levelRoot());
}
@Test
public void applicationArgumentsExcludeJvmAndLauncherOptions() {
assertArrayEquals(
new String[]{"-bconfig/bukkit.yml", "-Wworlds", "-wlevel", "-cserver.properties"},
BukkitStartupPaths.applicationArguments(new String[]{
"-Xmx4G",
"-jar",
"paper.jar",
"-bconfig/bukkit.yml",
"-Wworlds",
"-wlevel",
"-cserver.properties"
})
);
assertArrayEquals(
new String[]{"-bconfig/bukkit.yml"},
BukkitStartupPaths.applicationArguments(new String[]{
"-Xmx4G",
"-cp",
"paper.jar",
"org.bukkit.craftbukkit.Main",
"-bconfig/bukkit.yml"
})
);
}
@Test
public void endOfOptionsStopsStartupOptionParsing() throws Exception {
Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath();
BukkitStartupPaths paths = BukkitStartupPaths.resolve(
serverRoot,
new String[]{"--", "-bcustom-bukkit.yml", "-Wcustom-worlds", "-wcustom-level"}
);
assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration());
assertEquals(serverRoot, paths.worldContainer());
assertEquals("world", paths.levelName());
assertEquals(serverRoot.resolve("world"), paths.levelRoot());
}
}
@@ -7,13 +7,20 @@ import org.junit.rules.TemporaryFolder;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.file.FileSystems;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeNoException;
import static org.junit.Assume.assumeTrue;
public class BukkitWorldConfigurationTest { public class BukkitWorldConfigurationTest {
@Rule @Rule
@@ -317,4 +324,48 @@ public class BukkitWorldConfigurationTest {
assertTrue(failure.getMessage().contains("generator")); assertTrue(failure.getMessage().contains("generator"));
assertEquals(malformed, Files.readString(configuration.toPath())); assertEquals(malformed, Files.readString(configuration.toPath()));
} }
@Test
public void replacementRejectsSymbolicConfigurationWithoutChangingLinkOrTarget() throws Exception {
Path target = temporaryFolder.newFile("shared-bukkit.yml").toPath();
String original = "settings:\n allow-end: true\n";
Files.writeString(target, original);
Path link = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
try {
Files.createSymbolicLink(link, target.getFileName());
} catch (IOException | UnsupportedOperationException failure) {
assumeNoException(failure);
}
BukkitWorldConfiguration.WorldGeneratorSnapshot expected =
new BukkitWorldConfiguration.WorldGeneratorSnapshot(false, false, false, null, false, null);
assertThrows(IOException.class, () -> BukkitWorldConfiguration.replaceIfMatching(
link.toFile(),
"world_nether",
expected,
"underworld",
1337L
));
assertTrue(Files.isSymbolicLink(link));
assertEquals(original, Files.readString(target));
}
@Test
public void snapshotRejectsSpecialConfigurationWithoutOpeningIt() throws Exception {
assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix"));
Path socket = temporaryFolder.getRoot().toPath().resolve("bukkit.yml");
try (ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) {
try {
channel.bind(UnixDomainSocketAddress.of(socket));
} catch (IOException | UnsupportedOperationException failure) {
assumeNoException(failure);
}
assertThrows(IOException.class, () -> BukkitWorldConfiguration.snapshot(
socket.toFile(),
"world_nether"
));
}
}
} }
@@ -0,0 +1,386 @@
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 art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class WorldReplacementBootstrapTest {
private static final WorldSlotKey WORLD_KEY = WorldSlotKey.minecraft("the_nether");
private static final long SEED = 4242424242L;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private Path serverRoot;
private Path dataDirectory;
private Path levelRoot;
private Path bukkitConfiguration;
private ExactWorldSlotPathPolicy.Target target;
@Before
public void setUp() throws Exception {
serverRoot = temporaryFolder.newFolder("server").toPath();
dataDirectory = Files.createDirectories(serverRoot.resolve("plugins/Iris"));
levelRoot = Files.createDirectories(serverRoot.resolve("world"));
bukkitConfiguration = Files.createFile(serverRoot.resolve("bukkit.yml"));
target = ExactWorldSlotPathPolicy.resolve(levelRoot, WORLD_KEY);
Files.createDirectories(target.namespaceRoot());
}
@Test
public void publishesArmedReplacementBeforeRegistryCompilation() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.published());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.published());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void retainsPublishedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.retained());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void cancelsPreparedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement();
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void resumesPublicationAfterOriginalMoveCrash() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
ReplacementPaths paths = paths(transaction);
Files.move(paths.target(), paths.backup());
reconcile();
assertEquals("replacement", replacementContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void cancelsPreparedTransactionWhenConfigurationWasNotApplied() throws Exception {
Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void restoresPublishedWorldWhenConfigurationWasReverted() throws Exception {
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
restoreOriginalConfiguration(transaction);
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertFalse(Files.exists(paths(transaction).backup()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void rejectsThirdPartyConfigurationAfterPublicationWithoutMovingStorage() throws Exception {
Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
BukkitWorldConfiguration.replaceIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
replacement,
"other",
SEED
);
assertThrows(IOException.class, this::reconcile);
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test
public void completesRollbackAcrossPreparedStorageCrashBoundary() throws Exception {
Transaction transaction = stagedTransaction(Phase.ROLLBACK_PENDING, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementFilesystem.prepareRollback(paths(transaction), true);
restoreOriginalConfiguration(transaction);
WorldReplacementJournal.write(dataDirectory, transaction.withPhase(Phase.ROLLBACK_CLEANUP));
reconcile();
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(transaction).stage()));
assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty());
}
@Test
public void retainsVerifiedTargetWhenBackupWasAlreadyCleaned() throws Exception {
Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original");
configureReplacement(transaction);
WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint());
WorldReplacementFilesystem.cleanupBackup(paths(transaction));
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.retained());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals(Phase.CLEANUP_PENDING, loadSingle().phase());
}
@Test
public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world"));
assertThrows(
IOException.class,
() -> WorldReplacementBootstrap.reconcile(
dataDirectory,
otherLevelRoot,
bukkitConfiguration,
ignored -> {
}
)
);
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertTrue(Files.isDirectory(paths(transaction).stage()));
assertFalse(Files.exists(paths(transaction).backup()));
}
@Test
public void rejectsDuplicateWorldJournalsBeforePublishingEither() throws Exception {
Transaction first = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(first);
Transaction second = new Transaction(
UUID.randomUUID(),
first.worldKey(),
first.worldName(),
first.levelRoot(),
first.dimension(),
first.seed(),
first.packFingerprint(),
first.originalConfiguration(),
first.originalTargetPresent(),
first.phase()
);
WorldReplacementJournal.write(dataDirectory, second);
assertThrows(IOException.class, this::reconcile);
assertTrue(Files.isDirectory(paths(first).stage()));
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertFalse(Files.exists(paths(first).backup()));
}
@Test
public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception {
for (String generator : List.of("", " ")) {
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(
true,
true,
true,
generator,
false,
null
);
Transaction transaction = transaction(UUID.randomUUID(), original, Phase.PREPARED, false, "replacement");
WorldReplacementJournal.write(dataDirectory, transaction);
Transaction loaded = loadSingle();
assertEquals(generator, loaded.originalConfiguration().generator());
WorldReplacementJournal.delete(dataDirectory, transaction.id());
}
}
@Test
public void rejectsIrisWorldKeysThatCollideWithConfiguredVanillaAliases() {
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(false, false, false, null, false, null);
for (String alias : List.of("world", "world_nether", "world_the_end")) {
Transaction transaction = new Transaction(
UUID.randomUUID(),
new WorldSlotKey("iris", alias),
alias,
levelRoot,
"underworld",
SEED,
"0".repeat(64),
original,
false,
Phase.ARMED
);
assertThrows(IOException.class, () -> WorldReplacementJournal.resolveTarget(transaction, levelRoot));
}
}
private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception {
return WorldReplacementBootstrap.reconcile(
dataDirectory,
levelRoot,
bukkitConfiguration,
ignored -> {
}
);
}
private Transaction stagedTransaction(Phase phase, boolean originalPresent, String originalContent)
throws Exception {
WorldGeneratorSnapshot original = BukkitWorldConfiguration.snapshot(
bukkitConfiguration.toFile(),
"world_nether"
);
return transaction(UUID.randomUUID(), original, phase, originalPresent, originalContent);
}
private Transaction transaction(
UUID id,
WorldGeneratorSnapshot original,
Phase phase,
boolean originalPresent,
String originalContent
) throws Exception {
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, id);
if (originalPresent) {
writeOriginalTarget(paths, originalContent);
}
Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json");
Files.createDirectories(dimension.getParent());
Files.writeString(dimension, "replacement");
String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
Transaction transaction = new Transaction(
id,
WORLD_KEY,
"world_nether",
target.levelRoot(),
"underworld",
SEED,
fingerprint,
original,
originalPresent,
phase
);
WorldReplacementJournal.write(dataDirectory, transaction);
return transaction;
}
private void configureReplacement(Transaction transaction) throws Exception {
BukkitWorldConfiguration.GeneratorReplacement result = BukkitWorldConfiguration.replaceIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
transaction.originalConfiguration(),
transaction.dimension(),
transaction.seed()
);
assertTrue(result.applied());
}
private void configureExistingReplacement() throws Exception {
BukkitWorldConfiguration.register(
bukkitConfiguration.toFile(),
"world_nether",
"underworld",
SEED
);
}
private void restoreOriginalConfiguration(Transaction transaction) throws Exception {
assertTrue(BukkitWorldConfiguration.restoreIfMatching(
bukkitConfiguration.toFile(),
transaction.worldName(),
WorldReplacementBootstrap.replacementSnapshot(transaction),
transaction.originalConfiguration()
));
}
private Transaction loadSingle() throws Exception {
return WorldReplacementJournal.load(dataDirectory, levelRoot).getFirst();
}
private ReplacementPaths paths(Transaction transaction) {
return WorldReplacementFilesystem.paths(target, transaction.id());
}
private Path backup(Transaction transaction) {
return paths(transaction).backup();
}
private void writeOriginalTarget(ReplacementPaths paths, String originalContent) throws Exception {
Files.createDirectories(paths.target().resolve("data/paper"));
Files.createDirectories(paths.target().resolve("data/minecraft"));
Files.writeString(paths.target().resolve("original.txt"), originalContent);
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
}
private String replacementContent(Path worldDirectory) throws Exception {
return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json"));
}
}
@@ -31,15 +31,83 @@ public class WorldReplacementFilesystemTest {
public void publishesReplacementAndRetainsOriginalBackup() throws Exception { public void publishesReplacementAndRetainsOriginalBackup() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
writeOriginalTarget(paths, "original"); writeOriginalTarget(paths, "original");
Files.createDirectories(paths.target().resolve("region"));
Files.createDirectories(paths.target().resolve("entities"));
Files.createDirectories(paths.target().resolve("poi"));
Files.writeString(paths.target().resolve("region/r.0.0.mca"), "old-region");
Files.writeString(paths.target().resolve("entities/r.0.0.mca"), "old-entities");
Files.writeString(paths.target().resolve("poi/r.0.0.mca"), "old-poi");
String fingerprint = writeStage(paths, "replacement"); String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint); WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target())); assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt"))); assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
assertEquals(
"generation",
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
);
assertFalse(Files.exists(paths.target().resolve("region/r.0.0.mca")));
assertFalse(Files.exists(paths.target().resolve("entities/r.0.0.mca")));
assertFalse(Files.exists(paths.target().resolve("poi/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("region/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("entities/r.0.0.mca")));
assertTrue(Files.exists(paths.backup().resolve("poi/r.0.0.mca")));
assertFalse(Files.exists(paths.stage())); assertFalse(Files.exists(paths.stage()));
} }
@Test
public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.requireExistingTarget(paths)
);
assertTrue(failure.getMessage().contains("requires an existing exact world slot"));
assertFalse(Files.exists(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test
public void completesMetadataForAlreadyPublishedReplacement() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("published-metadata", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Files.move(paths.target(), paths.backup());
Files.move(paths.stage(), paths.target());
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat")));
assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat")));
assertEquals(
"generation",
Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat"))
);
}
@Test
public void rejectsUnmigratedRetainedWorldBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("missing-paper-metadata", TRANSACTION_ID);
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), "original");
String fingerprint = writeStage(paths, "replacement");
assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.publish(paths, true, fingerprint)
);
assertTrue(Files.isDirectory(paths.target()));
assertTrue(Files.isDirectory(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test @Test
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception { public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
@@ -136,6 +204,37 @@ public class WorldReplacementFilesystemTest {
assertFalse(Files.exists(paths.backup())); assertFalse(Files.exists(paths.backup()));
} }
@Test
public void preparedRollbackCanRepublishWhenConfigurationRestoreFails() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-republish", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
WorldReplacementFilesystem.prepareRollback(paths, true);
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
assertEquals("original", Files.readString(paths.backup().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
}
@Test
public void preparedRollbackCleanupIsRetryableAfterStageDeletion() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-cleanup-retry", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
WorldReplacementFilesystem.prepareRollback(paths, true);
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
WorldReplacementFilesystem.finishPreparedRollback(paths, true);
assertEquals("original", Files.readString(paths.target().resolve("original.txt")));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test @Test
public void rejectsPackMutationBeforePublication() throws Exception { public void rejectsPackMutationBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID);
@@ -289,6 +388,11 @@ public class WorldReplacementFilesystemTest {
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception { private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
Files.createDirectories(paths.target()); Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), content); Files.writeString(paths.target().resolve("original.txt"), content);
Files.createDirectories(paths.target().resolve("data/paper"));
Files.createDirectories(paths.target().resolve("data/minecraft"));
Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata");
Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides");
Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation");
} }
private String readPackContent(Path worldDirectory) throws Exception { private String readPackContent(Path worldDirectory) throws Exception {
@@ -7,6 +7,7 @@ import org.junit.Test;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -17,10 +18,14 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -31,6 +36,26 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class DefaultPackBootstrapProvisionerTest { public class DefaultPackBootstrapProvisionerTest {
@Test
public void defaultBetaSourcesArePinnedPerRequiredPack() {
Map<String, DefaultPackBootstrapProvisioner.PackSpec> packs = new LinkedHashMap<>();
for (DefaultPackBootstrapProvisioner.PackSpec pack : DefaultPackBootstrapProvisioner.defaultPacks()) {
packs.put(pack.key(), pack);
}
assertEquals(2, packs.size());
assertEquals(
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
packs.get("overworld").source()
);
assertEquals("overworld", packs.get("overworld").requiredDimension());
assertEquals(
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
packs.get("underworld").source()
);
assertEquals("underworld", packs.get("underworld").requiredDimension());
}
@Test @Test
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception { public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
byte[] archive = packArchive("overworld", "bootstrap_biome"); byte[] archive = packArchive("overworld", "bootstrap_biome");
@@ -58,14 +83,23 @@ public class DefaultPackBootstrapProvisionerTest {
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
assertEquals(1, requests.get()); assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json"))); assertTrue(Files.isRegularFile(installed.packRoots().get("overworld").resolve("dimensions/overworld.json")));
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld.json")));
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json")));
assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot()); assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot());
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack"))); assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()); assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
assertEquals("true", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
assertEquals("underworld", marker.getProperty("pack.underworld.requiredDimension"));
delete(installed.packRoots().get("underworld"));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
} finally { } finally {
server.stop(0); server.stop(0);
delete(root); delete(root);
@@ -101,9 +135,10 @@ public class DefaultPackBootstrapProvisionerTest {
); );
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status());
assertEquals(1, requests.get()); assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta"))); assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta")));
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
} finally { } finally {
if (!serverStopped) { if (!serverStopped) {
server.stop(0); server.stop(0);
@@ -138,9 +173,10 @@ public class DefaultPackBootstrapProvisionerTest {
); );
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(1, requests.get()); assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs"))); assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs")));
assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json"))); assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json")));
assertTrue(Files.isRegularFile(sharedPacks.resolve("underworld/dimensions/underworld.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
} finally { } finally {
server.stop(0); server.stop(0);
@@ -165,7 +201,7 @@ public class DefaultPackBootstrapProvisionerTest {
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> { DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options); }, options);
assertEquals(2, requests.get()); assertEquals(3, requests.get());
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) { try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
assertTrue(zip.getNextEntry() != null); assertTrue(zip.getNextEntry() != null);
} }
@@ -245,13 +281,13 @@ public class DefaultPackBootstrapProvisionerTest {
options options
); );
assertEquals(0, requests.get()); assertEquals(1, requests.get());
assertTrue(Files.isSymbolicLink(link)); assertTrue(Files.isSymbolicLink(link));
assertEquals(target.toRealPath(), link.toRealPath()); assertEquals(target.toRealPath(), link.toRealPath());
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json"))); assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8); Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory, dataDirectory,
ignored -> { ignored -> {
@@ -259,7 +295,7 @@ public class DefaultPackBootstrapProvisionerTest {
options options
); );
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(0, requests.get()); assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(link)); assertTrue(Files.isSymbolicLink(link));
} finally { } finally {
server.stop(0); server.stop(0);
@@ -280,7 +316,7 @@ public class DefaultPackBootstrapProvisionerTest {
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome"); writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome"); writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory, dataDirectory,
ignored -> { ignored -> {
@@ -289,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest {
); );
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(1, requests.get()); assertEquals(2, requests.get());
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json"))); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json"))); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
} finally { } finally {
@@ -319,14 +355,15 @@ public class DefaultPackBootstrapProvisionerTest {
refreshOptions refreshOptions
); );
assertEquals(1, requests.get()); assertEquals(3, requests.get());
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome")); assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json"))); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
Properties marker = new Properties(); Properties marker = new Properties();
try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) { try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
marker.load(input); marker.load(input);
} }
assertEquals("false", marker.getProperty("managedDefault")); assertEquals("false", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
} finally { } finally {
server.stop(0); server.stop(0);
delete(root); delete(root);
@@ -334,39 +371,185 @@ public class DefaultPackBootstrapProvisionerTest {
} }
@Test @Test
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception { public void underworldLocalEditRelinquishesOnlyUnderworldOwnership() throws Exception {
AtomicInteger requests = new AtomicInteger(); AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "first_biome"), requests); HttpServer server = server(packArchive("overworld", "overworld_managed"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-rollback"); Path root = Files.createTempDirectory("iris-bootstrap-underworld-edit");
try { try {
Path dataDirectory = root.resolve("plugins/Iris"); Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1)); DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision( server,
root,
Duration.ofHours(1)
);
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, initialOptions);
Path editedBiome = dataDirectory.resolve("packs/underworld/biomes/local.json");
Files.writeString(editedBiome, biomeJson("underworld_local_edit"), StandardCharsets.UTF_8);
DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO);
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory, dataDirectory,
ignored -> { ignored -> {
}, },
options refreshOptions
); );
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")); assertEquals(3, requests.get());
Path invalidPack = dataDirectory.resolve("packs/invalid"); assertTrue(Files.readString(editedBiome).contains("underworld_local_edit"));
Files.createDirectories(invalidPack.resolve("dimensions")); assertTrue(Files.isRegularFile(updated.datapackRoot()
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8); .resolve("data/underworld/worldgen/biome/underworld_local_edit.json")));
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
assertEquals("true", marker.getProperty("pack.overworld.managed"));
assertEquals("false", marker.getProperty("pack.underworld.managed"));
} finally {
server.stop(0);
delete(root);
}
}
@Test
public void betaPacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception {
byte[] overworld = packArchive("overworld", "overworld_first");
byte[] underworldFirst = underworldArchive("underworld_first");
AtomicInteger requests = new AtomicInteger();
HttpServer initialServer = server(overworld, underworldFirst, requests);
HttpServer updateServer = null;
Path root = Files.createTempDirectory("iris-bootstrap-independent-update");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(initialServer, root, Duration.ofHours(1))
);
initialServer.stop(0);
initialServer = null;
byte[] underworldSecond = underworldArchive("underworld_second");
updateServer = server(overworld, underworldSecond, requests);
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(updateServer, root, Duration.ZERO)
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(4, requests.get());
assertTrue(Files.readString(dataDirectory.resolve("packs/overworld/biomes/local.json"))
.contains("overworld_first"));
assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json"))
.contains("underworld_second"));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.resolve("data/overworld/worldgen/biome/overworld_first.json")));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.resolve("data/underworld/worldgen/biome/underworld_second.json")));
assertFalse(Files.exists(updated.datapackRoot()
.resolve("data/underworld/worldgen/biome/underworld_first.json")));
} finally {
if (initialServer != null) {
initialServer.stop(0);
}
if (updateServer != null) {
updateServer.stop(0);
}
delete(root);
}
}
@Test
public void invalidUnderworldArchivePublishesNeitherRequiredPack() throws Exception {
byte[] overworld = packArchive("overworld", "overworld_valid");
byte[] invalidUnderworld = packArchive("underworld_roof", "roof_only");
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(overworld, invalidUnderworld, requests);
Path root = Files.createTempDirectory("iris-bootstrap-underworld-invalid");
try {
Path dataDirectory = root.resolve("plugins/Iris");
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision( assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory, dataDirectory,
ignored -> { ignored -> {
}, },
options options(server, root, Duration.ZERO)
)); ));
assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")))); assertEquals(2, requests.get());
assertFalse(Files.exists(dataDirectory.resolve("packs/overworld")));
assertFalse(Files.exists(dataDirectory.resolve("packs/underworld")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties")));
assertFalse(Files.exists(root.resolve("datapacks/iris")));
} finally { } finally {
server.stop(0); server.stop(0);
delete(root); delete(root);
} }
} }
@Test
public void failedAggregateCompilationRollsBackBothPackUpdatesAndDatapack() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer initialServer = server(
packArchive("overworld", "overworld_first"),
underworldArchive("underworld_first"),
requests
);
HttpServer updateServer = null;
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
options(initialServer, root, Duration.ofHours(1))
);
initialServer.stop(0);
initialServer = null;
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
byte[] originalOverworld = Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"));
byte[] originalUnderworld = Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"));
Path invalidPack = dataDirectory.resolve("packs/invalid");
Files.createDirectories(invalidPack.resolve("dimensions"));
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
updateServer = server(
packArchive("overworld", "overworld_second"),
underworldArchive("underworld_second"),
requests
);
DefaultPackBootstrapProvisioner.ProvisionOptions updateOptions = options(
updateServer,
root,
Duration.ZERO
);
assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
},
updateOptions
));
assertTrue(Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"))));
assertTrue(Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"))));
assertTrue(Arrays.equals(originalOverworld,
Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json"))));
assertTrue(Arrays.equals(originalUnderworld,
Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json"))));
assertNoBootstrapTransactionPaths(dataDirectory.resolve("packs"));
assertNoBootstrapTransactionPaths(root.resolve("datapacks"));
} finally {
if (initialServer != null) {
initialServer.stop(0);
}
if (updateServer != null) {
updateServer.stop(0);
}
delete(root);
}
}
@Test @Test
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception { public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root"); Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
@@ -378,7 +561,7 @@ public class DefaultPackBootstrapProvisionerTest {
); );
assertEquals( assertEquals(
serverRoot.resolve("levels/primary").normalize(), serverRoot.toRealPath().resolve("levels/primary").normalize(),
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot) DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
); );
} finally { } finally {
@@ -391,9 +574,20 @@ public class DefaultPackBootstrapProvisionerTest {
Path serverRoot, Path serverRoot,
Duration refreshInterval Duration refreshInterval
) { ) {
URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip"); String sourceRoot = "http://127.0.0.1:" + server.getAddress().getPort();
return new DefaultPackBootstrapProvisioner.ProvisionOptions( return new DefaultPackBootstrapProvisioner.ProvisionOptions(
source, List.of(
new DefaultPackBootstrapProvisioner.PackSpec(
"overworld",
URI.create(sourceRoot + "/overworld.zip"),
"overworld"
),
new DefaultPackBootstrapProvisioner.PackSpec(
"underworld",
URI.create(sourceRoot + "/underworld.zip"),
"underworld"
)
),
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(), HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC), Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
refreshInterval, refreshInterval,
@@ -406,8 +600,17 @@ public class DefaultPackBootstrapProvisionerTest {
} }
private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException { private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException {
return server(response, underworldArchive("underworld_biome"), requests);
}
private static HttpServer server(
byte[] overworldResponse,
byte[] underworldResponse,
AtomicInteger requests
) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests)); server.createContext("/overworld.zip", exchange -> respond(exchange, overworldResponse, requests));
server.createContext("/underworld.zip", exchange -> respond(exchange, underworldResponse, requests));
server.start(); server.start();
return server; return server;
} }
@@ -428,6 +631,33 @@ public class DefaultPackBootstrapProvisionerTest {
return zip(files); return zip(files);
} }
private static byte[] underworldArchive(String biomeId) throws IOException {
LinkedHashMap<String, String> files = new LinkedHashMap<>();
files.put("dimensions/underworld.json", dimensionJson("underworld"));
files.put("dimensions/underworld_roof.json", dimensionJson("underworld_roof"));
files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
files.put("biomes/local.json", biomeJson(biomeId));
return zip(files);
}
private static Properties loadProperties(Path path) throws IOException {
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(path)) {
properties.load(input);
}
return properties;
}
private static void assertNoBootstrapTransactionPaths(Path root) throws IOException {
if (!Files.isDirectory(root)) {
return;
}
try (Stream<Path> stream = Files.list(root)) {
assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("-stage-")
|| path.getFileName().toString().contains("-backup-")));
}
}
private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException { private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException {
Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions")); Files.createDirectories(root.resolve("regions"));
@@ -462,8 +692,8 @@ public class DefaultPackBootstrapProvisionerTest {
if (!Files.exists(root)) { if (!Files.exists(root)) {
return; return;
} }
try (java.util.stream.Stream<Path> stream = Files.walk(root)) { try (Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) { for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path); Files.deleteIfExists(path);
} }
} }
@@ -49,6 +49,7 @@ import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -95,6 +96,60 @@ public class PackDownloaderTest {
PackDownloader.defaultOverworldReleaseUrl() PackDownloader.defaultOverworldReleaseUrl()
); );
assertTrue(PackDownloader.isDefaultOverworld("overworld")); assertTrue(PackDownloader.isDefaultOverworld("overworld"));
assertTrue(PackDownloader.isManagedBetaPack("overworld"));
assertEquals(List.of("overworld", "underworld"), PackDownloader.managedBetaPacks());
}
@Test
public void resolvesUnderworldBetaRelease() {
assertEquals(
"https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip",
PackDownloader.underworldReleaseUrl()
);
assertTrue(PackDownloader.isManagedBetaPack("underworld"));
}
@Test
public void managedPackPresenceRequiresItsPrimaryDimension() throws Exception {
File packsFolder = temp.newFolder("managed-presence");
Path dimensions = Files.createDirectories(
packsFolder.toPath().resolve("underworld/dimensions")
);
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld_roof");
assertTrue(PackDownloader.isPackPresent(packsFolder, "underworld"));
assertFalse(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
writeDimension(packsFolder.toPath().resolve("underworld"), "underworld");
assertTrue(Files.isDirectory(dimensions));
assertTrue(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld"));
}
@Test
public void repairsManagedFolderMissingItsPrimaryDimension() throws Exception {
File packsFolder = temp.newFolder("managed-repair-packs");
Path target = packsFolder.toPath().resolve("underworld");
Files.createDirectories(target.resolve("dimensions"));
writeDimension(target, "underworld_roof");
Files.writeString(target.resolve("partial.txt"), "partial", StandardCharsets.UTF_8);
File extracted = writePack(temp.newFolder("managed-repair-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"underworld",
ignored -> {
}
);
assertNotNull(result);
assertTrue(result.changed());
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld.json")));
assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld_roof.json")));
assertFalse(Files.exists(target.resolve("partial.txt")));
assertTransactionStateClean(packsFolder);
} }
@Test @Test
@@ -102,6 +157,9 @@ public class PackDownloaderTest {
assertFalse(PackDownloader.isDefaultOverworld("theend")); assertFalse(PackDownloader.isDefaultOverworld("theend"));
assertFalse(PackDownloader.isDefaultOverworld("")); assertFalse(PackDownloader.isDefaultOverworld(""));
assertFalse(PackDownloader.isDefaultOverworld(null)); assertFalse(PackDownloader.isDefaultOverworld(null));
assertFalse(PackDownloader.isManagedBetaPack("theend"));
assertFalse(PackDownloader.isManagedBetaPack(""));
assertFalse(PackDownloader.isManagedBetaPack(null));
} }
@Test @Test
@@ -345,6 +403,71 @@ public class PackDownloaderTest {
assertEquals(0, PackDownloader.downloadLockCount()); assertEquals(0, PackDownloader.downloadLockCount());
} }
@Test
public void importsExpectedDimensionFromMultiDimensionPack() throws Exception {
File packsFolder = temp.newFolder("multi-dimension-packs");
File extracted = writePack(temp.newFolder("multi-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"underworld",
ignored -> {
}
);
assertEquals("underworld", result.key());
assertTrue(result.changed());
assertTrue(Files.isRegularFile(
packsFolder.toPath().resolve("underworld/dimensions/underworld_roof.json")
));
assertTransactionStateClean(packsFolder);
}
@Test
public void rejectsMultiDimensionPackWithoutExpectedDimension() throws Exception {
File packsFolder = temp.newFolder("missing-dimension-packs");
File extracted = writePack(temp.newFolder("missing-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
"missing",
ignored -> {
}
));
assertTrue(failure.getMessage().contains("missing"));
assertTrue(failure.getMessage().contains("underworld"));
assertFalse(new File(packsFolder, "missing").exists());
assertTransactionStateClean(packsFolder);
}
@Test
public void rejectsAmbiguousMultiDimensionPackWithoutExpectedKey() throws Exception {
File packsFolder = temp.newFolder("ambiguous-dimension-packs");
File extracted = writePack(temp.newFolder("ambiguous-dimension-source").toPath(), "underworld", "new");
writeDimension(extracted.toPath(), "underworld_roof");
List<String> feedback = new ArrayList<>();
PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack(
packsFolder,
extracted,
false,
null,
feedback::add
);
assertNull(result);
assertFalse(feedback.isEmpty());
assertFalse(new File(packsFolder, "underworld").exists());
assertTransactionStateClean(packsFolder);
}
@Test @Test
public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception { public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception {
File packsFolder = temp.newFolder("concurrent-packs"); File packsFolder = temp.newFolder("concurrent-packs");
@@ -465,12 +588,7 @@ public class PackDownloaderTest {
Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions")); Files.createDirectories(root.resolve("regions"));
Files.createDirectories(root.resolve("biomes")); Files.createDirectories(root.resolve("biomes"));
Files.writeString( writeDimension(root, key);
root.resolve("dimensions/" + key + ".json"),
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
StandardCharsets.UTF_8
);
Files.writeString( Files.writeString(
root.resolve("regions/local.json"), root.resolve("regions/local.json"),
"{\"name\":\"Local\",\"landBiomes\":[\"local\"]}", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}",
@@ -485,6 +603,15 @@ public class PackDownloaderTest {
return root.toFile(); return root.toFile();
} }
private static void writeDimension(Path root, String key) throws IOException {
Files.writeString(
root.resolve("dimensions/" + key + ".json"),
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
StandardCharsets.UTF_8
);
}
private static void writeArchive(Path archive, Map<String, String> entries) throws IOException { private static void writeArchive(Path archive, Map<String, String> entries) throws IOException {
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) { try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
for (Map.Entry<String, String> entry : entries.entrySet()) { for (Map.Entry<String, String> entry : entries.entrySet()) {
@@ -0,0 +1,78 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.service;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
public class StudioSVCManagedBetaPackTest {
@Test
public void startupSelectsOnlyMissingManagedBetaPacks() throws IOException {
Path workspace = Files.createTempDirectory("iris-managed-beta-startup");
try {
assertEquals(
List.of("overworld", "underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createPack(workspace, "overworld");
assertEquals(
List.of("underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createDimension(workspace, "underworld", "underworld_roof");
assertEquals(
List.of("underworld"),
StudioSVC.missingManagedBetaPacks(workspace.toFile())
);
createPack(workspace, "underworld");
assertEquals(List.of(), StudioSVC.missingManagedBetaPacks(workspace.toFile()));
} finally {
deleteTree(workspace);
}
}
private static void createPack(Path workspace, String key) throws IOException {
createDimension(workspace, key, key);
}
private static void createDimension(Path workspace, String folder, String key) throws IOException {
Path dimensions = Files.createDirectories(workspace.resolve(folder).resolve("dimensions"));
Files.writeString(dimensions.resolve(key + ".json"), "{}", StandardCharsets.UTF_8);
}
private static void deleteTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
+13 -11
View File
@@ -1,13 +1,13 @@
# 01 - Installation & Platforms # 01 - Installation & Platforms
Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the default `overworld` pack is downloaded when missing; packs live under each platforms data directory. Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the managed `overworld` and `underworld` beta packs are downloaded when missing; packs live under each platforms data directory.
## Installation outcome ## Installation outcome
Complete one platform path below. A successful install has all three results: Complete one platform path below. A successful install has all three results:
1. Iris reaches its enabled/ready state without an exception. 1. Iris reaches its enabled/ready state without an exception.
2. The platform data directory contains `settings.json` and a loadable `packs/overworld/` directory. 2. The platform data directory contains `settings.json` and loadable `packs/overworld/` and `packs/underworld/` directories.
3. `/iris` prints help from the server console. On a modded client, the Iris keybind category is an additional client-side check, not a substitute for the server check. 3. `/iris` prints help from the server console. On a modded client, the Iris keybind category is an additional client-side check, not a substitute for the server check.
Keep the previous jar/mod and the entire Iris data directory until the new build passes these checks. Replacing the binary does not update pack snapshots already stored inside worlds. Keep the previous jar/mod and the entire Iris data directory until the new build passes these checks. Replacing the binary does not update pack snapshots already stored inside worlds.
@@ -22,7 +22,7 @@ Keep the previous jar/mod and the entire Iris data directory until the new build
| Fabric Loader | 0.19.3+ | | Fabric Loader | 0.19.3+ |
| Forge | 65.0.4+ | | Forge | 65.0.4+ |
| NeoForge | 26.2.0.12-beta+ | | NeoForge | 26.2.0.12-beta+ |
| Network | Outbound HTTPS on first boot for default pack download (GitHub IrisDimensions overworld release / pack install) | | Network | Outbound HTTPS on first boot for the GitHub IrisDimensions Overworld and Underworld beta release assets |
Before replacing an existing installation: Before replacing an existing installation:
@@ -37,7 +37,7 @@ Do not copy multiple Iris platform jars into the same `plugins/` or `mods/` dire
1. Place the CraftBukkit-labelled plugin jar into `plugins/`. 1. Place the CraftBukkit-labelled plugin jar into `plugins/`.
2. Start the server. Iris loads at `STARTUP` (`plugin.yml` / `paper-plugin.yml`). 2. Start the server. Iris loads at `STARTUP` (`plugin.yml` / `paper-plugin.yml`).
3. On first boot Iris provisions the default `overworld` pack into `plugins/Iris/packs/overworld` when missing (source: IrisDimensions overworld `beta` release zip). 3. On first boot Iris provisions `overworld` and `underworld` into `plugins/Iris/packs/` when missing from their IrisDimensions `beta` release ZIPs.
4. Settings are written at `plugins/Iris/settings.json` if absent (`IrisSettings.read()`). 4. Settings are written at `plugins/Iris/settings.json` if absent (`IrisSettings.read()`).
Validate the plugin install from the server console: Validate the plugin install from the server console:
@@ -45,6 +45,7 @@ Validate the plugin install from the server console:
```text ```text
/iris version /iris version
/iris pack validate pack=overworld /iris pack validate pack=overworld
/iris pack validate pack=underworld
``` ```
The first command must report the running Iris, platform, and Minecraft versions. The second must resolve the downloaded pack and finish without blocking validation errors. Then complete the disposable-world workflow in `02 - Getting Started.md`; a command response alone does not prove that the generator can create chunks. The first command must report the running Iris, platform, and Minecraft versions. The second must resolve the downloaded pack and finish without blocking validation errors. Then complete the disposable-world workflow in `02 - Getting Started.md`; a command response alone does not prove that the generator can create chunks.
@@ -66,16 +67,17 @@ Before creating a real world, run the Bukkit fresh-install smoke in `31 - Operat
1. Place the matching mod jar into `mods/`. 1. Place the matching mod jar into `mods/`.
2. Start the dedicated server (or a client for singleplayer; see below). 2. Start the dedicated server (or a client for singleplayer; see below).
3. The jar is self-contained: core, SPI, and required Fabric API modules are bundled where applicable. Mod id: `irisworldgen`. 3. The jar is self-contained: core, SPI, and required Fabric API modules are bundled where applicable. Mod id: `irisworldgen`.
4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default) and `defaultPack` (default `overworld`) is missing, Iris downloads `IrisDimensions/<pack>` (branch `master` for the auto-prefetch path) into the packs folder before the forced worldgen datapack is written. 4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default), Iris installs the managed `overworld` and `underworld` beta releases when missing, followed by a configured non-managed `defaultPack` when applicable, before the forced worldgen datapack is written.
Validate the server-side mod install: Validate the server-side mod install:
```text ```text
/iris version /iris version
/iris pack validate overworld /iris pack validate overworld
/iris pack validate underworld
``` ```
The install passes when Iris reports the expected loader/version, `config/irisworldgen/packs/overworld/dimensions/` contains a dimension JSON file, and validation has no blocking errors. Restart once before creating a world if the pack or its generated dimension-type datapack was installed during this boot. The install passes when Iris reports the expected loader/version, both managed pack directories contain their primary dimension JSON, and validation has no blocking errors. Restart once before creating a world if a pack or its generated dimension-type datapack was installed during this boot.
Packs installed later register custom dimension types (height ranges) and custom biomes through the forced datapack at server start. **Restart once after adding a pack** so worlds get full heights and biomes. Worlds created before that restart run with fallback heights. Packs installed later register custom dimension types (height ranges) and custom biomes through the forced datapack at server start. **Restart once after adding a pack** so worlds get full heights and biomes. Worlds created before that restart run with fallback heights.
@@ -118,7 +120,7 @@ Pack resolution for engines, commands, and the forced datapack uses `config/iris
| Key | Default | Effect | | Key | Default | Effect |
|---|---|---| |---|---|---|
| `defaultPack` | `overworld` | Pack auto-download and default create pack name | | `defaultPack` | `overworld` | Pack auto-download and default create pack name |
| `autoDownloadDefaultPack` | `true` | Async prefetch at boot when pack missing | | `autoDownloadDefaultPack` | `true` | Async prefetch of both managed beta packs and any configured non-managed default when missing |
| `primaryWorld` | `""` | Primary-world router target dimension id | | `primaryWorld` | `""` | Primary-world router target dimension id |
| `routePlayersToPrimaryWorld` | `true` | Route players from vanilla overworld when primary is set | | `routePlayersToPrimaryWorld` | `true` | Route players from vanilla overworld when primary is set |
| `mainWorldPack` | `""` | Main-world generator override pack ref | | `mainWorldPack` | `""` | Main-world generator override pack ref |
@@ -142,17 +144,17 @@ Full key list: `03 - Configuration.md`.
| Platform | Behavior | | Platform | Behavior |
|---|---| |---|---|
| Plugin | `DefaultPackBootstrapProvisioner` downloads `https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip` into `packs/overworld` when not already provisioned | | Plugin | `DefaultPackBootstrapProvisioner` independently manages the Overworld and Underworld beta assets under `packs/overworld` and `packs/underworld`, then compiles the aggregate datapack once |
| Mod | If `autoDownloadDefaultPack` and pack missing, async install of configured `defaultPack` into `config/irisworldgen/packs` | | Mod | If `autoDownloadDefaultPack` is enabled, async install of both managed beta packs plus any distinct configured default into `config/irisworldgen/packs` |
Manual install: `/iris download <pack>` (alias `dl`). Default overworld uses the beta-release path; other packs use `IrisDimensions/<pack>/<branch>` (plugin default branch `stable` for non-default; mod download defaults branch `stable` unless auto-prefetch uses `master` — see `25 - Pack Management.md`). Manual install: `/iris download <pack>` (alias `dl`). `overworld` and `underworld` use their beta-release assets and ignore the branch argument; other packs use `IrisDimensions/<pack>/<branch>` (default branch `stable` — see `25 - Pack Management.md`).
## Installation recovery ## Installation recovery
| Symptom | Check | Recovery | | Symptom | Check | Recovery |
|---|---|---| |---|---|---|
| Iris does not appear in `/iris version` | Wrong directory, wrong platform jar, duplicate jar, Java mismatch, or an enable exception | Stop the server, keep only the matching artifact, confirm Java 25, and fix the first Iris exception in the startup log | | Iris does not appear in `/iris version` | Wrong directory, wrong platform jar, duplicate jar, Java mismatch, or an enable exception | Stop the server, keep only the matching artifact, confirm Java 25, and fix the first Iris exception in the startup log |
| `settings.json` exists but `packs/overworld` does not | Default-pack download failed or is still incomplete | Restore outbound HTTPS or install a complete pack, then restart; do not create an empty `overworld` folder | | `settings.json` exists but a managed pack is absent | Managed beta download failed or is still incomplete | Restore outbound HTTPS or install the complete release pack, then restart; do not create an empty pack folder |
| Pack validates but modded height/biomes use fallbacks | Forced datapack was generated after registries loaded | Restart once with the pack already installed, then create a new disposable world | | Pack validates but modded height/biomes use fallbacks | Forced datapack was generated after registries loaded | Restart once with the pack already installed, then create a new disposable world |
| Bukkit command is denied for a non-op | `iris.all` is missing | Grant `iris.all`; `iris.treefeller` controls only survival tree felling | | Bukkit command is denied for a non-op | `iris.all` is missing | Grant `iris.all`; `iris.treefeller` controls only survival tree felling |
| Client HUD is absent but server commands work | Client mod missing, disabled keybind, or server capability not negotiated | Install the matching client mod, reconnect, and verify the Iris keybind category; server generation does not require the client HUD | | Client HUD is absent but server commands work | Client mod missing, disabled keybind, or server capability not negotiated | Install the matching client mod, reconnect, and verify the Iris keybind category; server generation does not require the client HUD |
+2 -2
View File
@@ -15,7 +15,7 @@ Treat each numbered section as a gate. Confirm the world is loaded before telepo
- Iris installed per `01 - Installation & Platforms.md` - Iris installed per `01 - Installation & Platforms.md`
- Java 25 server or mod instance running - Java 25 server or mod instance running
- Operator / gamemaster access (`iris` commands; modded mutating commands require permission level 2 / gamemasters) - Operator / gamemaster access (`iris` commands; modded mutating commands require permission level 2 / gamemasters)
- Default pack present (auto-downloaded on first boot) or an installed pack under the platform packs directory - Managed Overworld and Underworld packs present (auto-downloaded on first boot) or the required project pack installed under the platform packs directory
## Argument style ## Argument style
@@ -263,7 +263,7 @@ The session passes when the production world loads again after a clean restart a
| Bukkit optional args without `key=` | Parse error | Use `seed=1337`, not a bare second number for optional params | | Bukkit optional args without `key=` | Parse error | Use `seed=1337`, not a bare second number for optional params |
| Mod pregen while another job runs | Start fails | `/iris pregen stop` then start again | | Mod pregen while another job runs | Start fails | `/iris pregen stop` then start again |
| Studio closed mid-edit | World discarded | Edits on disk in `packs/` remain; reopen studio | | Studio closed mid-edit | World discarded | Edits on disk in `packs/` remain; reopen studio |
| Default pack download blocked | Create/open fails missing pack | Allow HTTPS or `/iris download overworld` offline install of a pack tree | | Managed pack download blocked | Startup or create/open fails with a missing pack | Allow HTTPS or install with `/iris download overworld` and `/iris download underworld`; an offline install must contain each complete pack tree |
| `type=default` vs pack key | Resolves via `generator.defaultWorldType` | Prefer explicit `type=overworld` or your pack key | | `type=default` vs pack key | Resolves via `generator.defaultWorldType` | Prefer explicit `type=overworld` or your pack key |
## Quick reference ## Quick reference
+2 -2
View File
@@ -244,8 +244,8 @@ Path: `<configDir>/irisworldgen/modded.json`. Written with defaults on first loa
| Key | Type | Default | Notes | | Key | Type | Default | Notes |
|-----|------|---------|-------| |-----|------|---------|-------|
| `defaultPack` | string | `"overworld"` | Default pack for bootstrap download/install | | `defaultPack` | string | `"overworld"` | Default create pack; a distinct non-managed value is also prefetched when enabled |
| `autoDownloadDefaultPack` | boolean | `true` | Download default pack when missing | | `autoDownloadDefaultPack` | boolean | `true` | Download missing managed Overworld/Underworld beta packs and any distinct configured default |
| `primaryWorld` | string | `""` | Primary Iris dimension id for player routing | | `primaryWorld` | string | `""` | Primary Iris dimension id for player routing |
| `routePlayersToPrimaryWorld` | boolean | `true` | Route players to primary when set | | `routePlayersToPrimaryWorld` | boolean | `true` | Route players to primary when set |
| `mainWorldPack` | string | `""` | Pack (or `pack:dimensionKey`) for main-world preset | | `mainWorldPack` | string | `""` | Pack (or `pack:dimensionKey`) for main-world preset |
+2 -2
View File
@@ -79,7 +79,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
| `load` | `import` | **Bukkit** | `<world>` | Load managed Iris world | | `load` | `import` | **Bukkit** | `<world>` | Load managed Iris world |
| `unload` | | **Bukkit** | `<world>` | Unload Iris world | | `unload` | | **Bukkit** | `<world>` | Unload Iris world |
| `debug` | | Both | — | Toggle `general.debug` and save settings | | `debug` | | Both | — | Toggle `general.debug` and save settings |
| `download` | `dl` | Both | `<pack> [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download pack project | | `download` | `dl` | Both | `<pack> [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download a pack; `overworld` and `underworld` resolve to managed beta release ZIPs |
| `metrics` | `measure` | Both | — | Generation metrics (player / current Iris level) | | `metrics` | `measure` | Both | — | Generation metrics (player / current Iris level) |
| `reload` | | Both | — | Reload `settings.json` and locale; modded also schedules forced datapack regeneration | | `reload` | | Both | — | Reload `settings.json` and locale; modded also schedules forced datapack regeneration |
| `seed` | | **Modded** | — | Print world/engine seeds (gamemaster) | | `seed` | | **Modded** | — | Print world/engine seeds (gamemaster) |
@@ -102,7 +102,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell
--- ---
On Bukkit, `overwrite=true` is deliberately restart-only. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required. On Paper-family servers, `overwrite=true` is deliberately restart-only; Spigot rejects it because it has no pre-registry plugin bootstrap. The exact target dimension folder must already exist; use ordinary `/iris create` for a new world. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required.
--- ---
+1 -1
View File
@@ -103,7 +103,7 @@ Live overworld also contains authoring-only or empty trees that loaders do not r
2. `dimensions/` is missing. 2. `dimensions/` is missing.
3. `dimensions/` has no `*.json` files. 3. `dimensions/` has no `*.json` files.
A downloaded archive is also rejected unless it contains exactly one loadable dimension (install key becomes that dimensions load key). Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file. A downloaded archive without an expected key is rejected unless it contains exactly one loadable dimension, whose load key becomes the install key. Managed-release and listing downloads carry an exact expected primary key, so their archive may retain additional dimension resources; the expected dimension selects the folder key and the entire pack still validates before publication. Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file.
## Snippets ## Snippets
+3 -3
View File
@@ -126,11 +126,11 @@ Runtime world creation is disabled on Folia. `/iris create` instead:
## Exact world-slot replacement ## Exact world-slot replacement
`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for restart on Bukkit-family servers, including Paper and Folia. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow. `overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for a complete Paper-family restart, including Paper, Purpur, Leaf, and Folia; Spigot has no early registry bootstrap and rejects this mode. The exact target dimension folder must already exist; ordinary create remains the path for a new world. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow.
The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, journals the original target state and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. During Iris `STARTUP`, before Bukkit loads worlds, each authorized transaction atomically moves the old exact dimension directory to a retained sibling backup and publishes its stage. There is no chunk merge: old region, entity, POI, and Iris data remain only in the backup, while the target starts with the staged pack snapshot. The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, binds its journal to the canonical level root and logical world name, records the original target and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. Paper bootstrap reconciles each authorized transaction before Iris compiles its aggregate datapack or Minecraft builds registries: it atomically moves the old exact dimension directory to a retained sibling backup and publishes the stage. The filesystem must support atomic replacement for the world directories, journal, and `bukkit.yml`; Iris refuses the operation without falling back to a non-atomic destructive move. Publication retains Paper's per-world `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` so the replacement keeps the exact slot metadata and authoritative seed. Old `region`, `entities`, `poi`, and Iris runtime data are never merged; they remain only in the backup while the target starts with the staged pack snapshot.
The backup is deleted only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint. A failed runtime check journals rollback, restores the prior `bukkit.yml` generator/seed with compare-and-swap semantics, requests another restart, and restores the retained directory before that restart loads worlds. A crash between either atomic move or journal write is retried idempotently. Conflicting manual configuration, changed staged bytes, unsafe storage, or corrupt journals block Iris world admission and preserve the stage/backup for operator recovery instead of guessing or deleting. The backup is eligible for asynchronous deletion only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint; cleanup failure retains its committed journal and retries without rolling back a verified world. A failed runtime check journals rollback and requests another restart, then cold bootstrap restores the retained directory and prior `bukkit.yml` generator/seed before registry or world loading. A crash between any move, configuration write, or journal phase is retried idempotently. Conflicting manual configuration, changed roots or logical names, changed staged bytes, unsafe storage, duplicate/corrupt journals, or irreconcilable transaction state abort the early bootstrap and preserve recoverable artifacts instead of guessing or deleting.
## Studio create ## Studio create
+5 -5
View File
@@ -34,13 +34,13 @@ If validation reports a missing edge, restore or repair that resource before pac
| Command | Behavior | | Command | Behavior |
|---------|----------| |---------|----------|
| Bukkit: `/iris download <pack> [branch=stable] [overwrite=false]`; modded: `/iris download <pack> [branch] [force]` | Download into packs root | | Bukkit: `/iris download <pack> [branch=stable] [overwrite=false]`; modded: `/iris download <pack> [branch] [force]` | Download into packs root |
| Default overworld special case | Pack name `overworld` uses IrisDimensions overworld **beta release zip** (`…/releases/download/beta/overworld.zip`), not an arbitrary branch zip | | Managed beta packs | Pack names `overworld` and `underworld` use their IrisDimensions **beta release ZIPs**, not arbitrary branch archives |
| Other packs | `IrisDimensions/<pack>/<branch>` GitHub archive search via `StudioSVC.downloadSearch` | | Other packs | `IrisDimensions/<pack>/<branch>` GitHub archive search via `StudioSVC.downloadSearch` |
| Param | Default | Notes | | Param | Default | Notes |
|-------|---------|-------| |-------|---------|-------|
| `pack` | required | Folder/key or repo short name | | `pack` | required | Folder/key or repo short name |
| `branch` | `stable` | GitHub ref when not default overworld | | `branch` | `stable` | GitHub ref when the pack is not a managed beta |
| `overwrite` | `false` | Force replace existing present pack | | `overwrite` | `false` | Force replace existing present pack |
### Install pipeline (`PackDownloader`) ### Install pipeline (`PackDownloader`)
@@ -48,12 +48,12 @@ If validation reports a missing edge, restore or repair that resource before pac
1. Per-key/ref download lock (concurrent startup and commands do not double-fetch). 1. Per-key/ref download lock (concurrent startup and commands do not double-fetch).
2. If pack present and not force → skip network. 2. If pack present and not force → skip network.
3. Download zip (size/entry limits: archive ≤512MiB, ≤100k entries, total uncompressed budget, per-file cap). 3. Download zip (size/entry limits: archive ≤512MiB, ≤100k entries, total uncompressed budget, per-file cap).
4. Unpack to temp; require single pack home directory. 4. Unpack to temp; require a single pack home directory.
5. Open as datapack-compiler `IrisData`; require **exactly one** dimension; key = that dimension load key. 5. Open as datapack-compiler `IrisData`. A normal download without an expected key requires exactly one dimension; a managed or listing download selects its exact expected dimension while retaining and validating any additional dimension resources in the same pack.
6. Run `PackValidator.validate`; blocking errors abort install. 6. Run `PackValidator.validate`; blocking errors abort install.
7. Publish into `packs/<key>/` with conflict checks (refuses symlink targets; detects dimension-key conflicts with other folders). 7. Publish into `packs/<key>/` with conflict checks (refuses symlink targets; detects dimension-key conflicts with other folders).
Default overworld repository constant: `IrisDimensions/overworld`. Managed beta sources are `IrisDimensions/overworld` (`overworld.zip`) and `IrisDimensions/underworld` (`underworld.zip`). Startup treats their ownership independently: an operator-edited or linked pack is preserved without preventing the other managed pack from updating.
## Validate ## Validate
+1 -1
View File
@@ -109,7 +109,7 @@ Worlds created from a pack store a **copy** at:
`StudioSVC.installIntoWorld` and `replaceIntoWorld` copy the source pack tree into that directory. Runtime generation for a normal world reads the world copy, not the global `packs/` tree. Studio worlds hotload the pack under `packs/` directly. `StudioSVC.installIntoWorld` and `replaceIntoWorld` copy the source pack tree into that directory. Runtime generation for a normal world reads the world copy, not the global `packs/` tree. Studio worlds hotload the pack under `packs/` directly.
First install often downloads the default overworld release into `packs/` (`downloadDefaultOverworld` / `/iris download` flows — see `02 - Getting Started.md`, `25 - Pack Management.md`). First install downloads the managed Overworld and Underworld beta releases into `packs/`; `/iris download overworld` uses the same Overworld asset (see `02 - Getting Started.md`, `25 - Pack Management.md`).
## High-level layout (shipping overworld) ## High-level layout (shipping overworld)
+3 -3
View File
@@ -41,7 +41,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
| Concern | Bukkit | Modded | | Concern | Bukkit | Modded |
|---------|--------|--------| |---------|--------|--------|
| Create | `/iris create` → managed world name, generator Iris, optional main-world; `overwrite=true` stages exact Iris/vanilla-slot replacement for restart | `/iris create` or `/iris world enable` → dimension id + pack injection | | Create | `/iris create` → managed world name, generator Iris, optional main-world; on Paper-family servers `overwrite=true` stages replacement of an existing exact Iris/vanilla slot for restart | `/iris create` or `/iris world enable` → dimension id + pack injection |
| Load / unload | `/iris load` (`import`), `/iris unload` | `/iris world disable` unloads; no separate load command | | Load / unload | `/iris load` (`import`), `/iris unload` | `/iris world disable` unloads; no separate load command |
| Remove / delete | `/iris remove` optional folder delete | `/iris world delete` wipes chunk/mantle data | | Remove / delete | `/iris remove` optional folder delete | `/iris world delete` wipes chunk/mantle data |
| Primary / main world | create `main=true` for a new level root, or name the configured main with `overwrite=true` for journaled in-place dimension replacement | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` | | Primary / main world | create `main=true` for a new level root, or name the configured main with `overwrite=true` for journaled in-place dimension replacement | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` |
@@ -49,7 +49,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
| Studio world | Transient studio world via StudioSVC; `/iris jigsaw` can select the Jigsaw Studio generator for one activation | Studio dimension under `irisworldgen:studio_*`; no Jigsaw Studio authoring command tree | | Studio world | Transient studio world via StudioSVC; `/iris jigsaw` can select the Jigsaw Studio generator for one activation | Studio dimension under `irisworldgen:studio_*`; no Jigsaw Studio authoring command tree |
| Folia | Regionized schedulers; pregen `runtimeSchedulerMode` forces `FOLIA` when regionized | N/A (not Bukkit Folia) | | Folia | Regionized schedulers; pregen `runtimeSchedulerMode` forces `FOLIA` when regionized | N/A (not Bukkit Folia) |
Default pack bootstrap still downloads the IrisDimensions overworld release into `packs/overworld` when missing (shared provisioner). Startup installs the IrisDimensions Overworld and Underworld beta releases into `packs/overworld` and `packs/underworld` when missing. Paper bootstrap publishes both in one rollback scope before compiling the aggregate datapack; legacy Bukkit and modded startup use the same managed release sources.
Modded startup quarantines a corrupt persistent-dimension registry as `iris-dimensions.json.broken-<timestamp>` and continues without those dynamic worlds. Recovery details are in `06 - Worlds & Lifecycle.md`. Modded startup quarantines a corrupt persistent-dimension registry as `iris-dimensions.json.broken-<timestamp>` and continues without those dynamic worlds. Recovery details are in `06 - Worlds & Lifecycle.md`.
@@ -76,7 +76,7 @@ Jigsaw pack resources are shared runtime data, but in-game Jigsaw Studio is not
| Jigsaw Studio create/grid/marker capture/rules/export | yes | no | no | no | | Jigsaw Studio create/grid/marker capture/rules/export | yes | no | no | no |
| Saved planar/spatial Iris jigsaw runtime | yes | yes | yes | yes | | Saved planar/spatial Iris jigsaw runtime | yes | yes | yes | yes |
| Pack validate / cleanup / download | yes | yes | yes | yes | | Pack validate / cleanup / download | yes | yes | yes | yes |
| Exact restart replacement of configured Overworld/Nether/End slots | yes | no | no | no | | Exact restart replacement of configured Overworld/Nether/End slots | Paper/Purpur/Leaf/Folia | no | no | no |
| Pregen | yes (Paper-like / Folia modes) | yes (`moddedPregenInFlight`) | yes | yes | | Pregen | yes (Paper-like / Folia modes) | yes (`moddedPregenInFlight`) | yes | yes |
| Studio open/close/vscode/package | yes | yes | yes | yes | | Studio open/close/vscode/package | yes | yes | yes | yes |
| Object wand / paste / save / undo | yes | yes | yes | yes | | Object wand / paste / save / undo | yes | yes | yes | yes |
+7 -7
View File
@@ -25,7 +25,7 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
## A. Fresh install and first world (Bukkit-family) ## A. Fresh install and first world (Bukkit-family)
1. Install the CraftBukkit-family jar into `plugins/` (Paper, Purpur, Folia, Spigot, Leaf, Canvas as advertised). Require Java 25. See `01 - Installation & Platforms.md`. 1. Install the CraftBukkit-family jar into `plugins/` (Paper, Purpur, Folia, Spigot, Leaf, Canvas as advertised). Require Java 25. See `01 - Installation & Platforms.md`.
2. Start the server once. Confirm Iris enables, default pack download completes when no pack is present, and `settings.json` is written under the Iris data directory. 2. Start the server once. Confirm Iris enables, managed `overworld` and `underworld` beta downloads complete when absent, and `settings.json` is written under the Iris data directory.
3. Create a world with a fixed seed and teleport into it: 3. Create a world with a fixed seed and teleport into it:
``` ```
@@ -36,21 +36,21 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`.
4. Join or teleport into the world. Confirm non-empty terrain, surface biomes, and no repeating console stack traces on first chunks. 4. Join or teleport into the world. Confirm non-empty terrain, surface biomes, and no repeating console stack traces on first chunks.
5. Gate: world is loaded as an Iris world; chunks generate without enable-time crash; console shows no fatal engine init failure. 5. Gate: world is loaded as an Iris world; chunks generate without enable-time crash; console shows no fatal engine init failure.
## A.1 Exact vanilla-slot replacement (Bukkit-family) ## A.1 Exact vanilla-slot replacement (Paper-family)
Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files before staging. Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files, and retain copies of `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` for comparison before staging.
1. Run `/iris create world_nether type=<nether-pack> seed=1337 overwrite=true`. Gate: the command says the replacement is staged, the loaded Nether and its files remain unchanged, `bukkit.yml` now names `Iris:<dimension>`, and one pending replacement journal plus one sibling stage exists. 1. Run `/iris create world_nether type=<nether-pack> seed=1337 overwrite=true`. Gate: the command says the replacement is staged, the loaded Nether and its files remain unchanged, `bukkit.yml` now names `Iris:<dimension>`, and one pending replacement journal plus one sibling stage exists.
2. Optionally stage the configured main name with a `NORMAL` pack and the End alias with a `THE_END` pack. Gate: each distinct slot gets its own transaction and no live dimension folder is moved. 2. Optionally stage the configured main name with a `NORMAL` pack and the End alias with a `THE_END` pack. Gate: each distinct slot gets its own transaction and no live dimension folder is moved.
3. Restart normally. Gate: Iris publishes before Bukkit world loading; `minecraft:the_nether` loads with the Iris generator, requested dimension and seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent. 3. Restart normally. Gate: Iris publishes before aggregate-datapack compilation and Bukkit world loading; `minecraft:the_nether` loads with the selected Iris dimension, the prior Paper per-world metadata files, and the authoritative shared level seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent.
4. Gate after `WorldLoad`: the retained sibling backup and journal disappear only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds. 4. Gate after `WorldLoad`: the journal advances to committed cleanup only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds; asynchronous cleanup then removes the retained sibling backup and journal without stalling the world thread.
5. Restart again and generate fresh Nether chunks. Gate: the exact vanilla identity and Iris generator persist, ordinary Nether portals still target `minecraft:the_nether`, and no pending stage/backup/journal returns. 5. Restart again and generate fresh Nether chunks. Gate: the exact vanilla identity and Iris generator persist, ordinary Nether portals still target `minecraft:the_nether`, and no pending stage/backup/journal returns.
6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: Iris refuses publication or world admission, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris restores the prior configuration, requests the controlled rollback restart, and restores the retained original directory before world load. 6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: early Paper bootstrap aborts before registry/world loading, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris journals rollback and requests the controlled restart, then restores the retained original directory and prior configuration before datapack compilation or world loading.
## B. Fresh install and first world (Fabric / Forge / NeoForge) ## B. Fresh install and first world (Fabric / Forge / NeoForge)
1. Install the matching mod jar into `mods/`. Fabric requires Loader ≥ declared floor; Forge/NeoForge require their declared floors. See `01 - Installation & Platforms.md` and `30 - Platform Differences.md`. 1. Install the matching mod jar into `mods/`. Fabric requires Loader ≥ declared floor; Forge/NeoForge require their declared floors. See `01 - Installation & Platforms.md` and `30 - Platform Differences.md`.
2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, default pack installs, and datapack/biome registration completes. 2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, both managed beta packs install, and datapack/biome registration completes.
3. Create a world with fixed seed (positional mod syntax): 3. Create a world with fixed seed (positional mod syntax):
``` ```
+1 -1
View File
@@ -270,7 +270,7 @@ Gate: every advertised server, loader, client, and content path completes the sa
### Confirmed release blockers and follow-ups ### Confirmed release blockers and follow-ups
- [ ] Freeze the default overworld pack to an immutable release input. The runtime downloader currently follows the mutable `master` branch, so any recorded tree checksum remains reproducible only while that upstream content is unchanged. Immutable branch/tag/commit URL resolution is implemented, but published commit `8e32852ee6ecd039fae27a36f701f57cdc02e83f` predates the five local slime-category and biome-tag corrections, the dormant standard entity resource restoration, and removal of the legacy default ambient-spawner attachments; publish those pack edits under a new commit/tag before pinning automatic installs. - [ ] Publish and retain anonymously downloadable `beta` assets for both managed pack repositories before shipping a build that requires dual bootstrap. Runtime uses the mutable Overworld and Underworld beta release URLs; record each downloaded asset checksum for a release baseline, and move production installs to immutable release inputs when the beta streams are promoted.
- [x] Make modded GoldenHash metadata use the active Iris engine seed. Fabric, Forge, and NeoForge generated identical output from Iris seed `1337`, but filenames and headers recorded each vanilla level seed, preventing one captured baseline file from being reused directly across loaders. - [x] Make modded GoldenHash metadata use the active Iris engine seed. Fabric, Forge, and NeoForge generated identical output from Iris seed `1337`, but filenames and headers recorded each vanilla level seed, preventing one captured baseline file from being reused directly across loaders.
- [x] Correct the default overworld pack's slime spawn category from implicit `MISC` to explicit `MONSTER` in `biomes/vanilla/mangrove_swamp.json`, `biomes/swamp/cambian-drift.json`, `biomes/swamp/cambian-drift-extended.json`, `biomes/swamp/marsh.json`, and `biomes/swamp/marsh-rotten.json`. NeoForge exposes the bad category at startup; all loaders generate the same bad datapack entry, which can affect natural slime spawning and mob-cap accounting. - [x] Correct the default overworld pack's slime spawn category from implicit `MISC` to explicit `MONSTER` in `biomes/vanilla/mangrove_swamp.json`, `biomes/swamp/cambian-drift.json`, `biomes/swamp/cambian-drift-extended.json`, `biomes/swamp/marsh.json`, and `biomes/swamp/marsh-rotten.json`. NeoForge exposes the bad category at startup; all loaders generate the same bad datapack entry, which can affect natural slime spawning and mob-cap accounting.
- [x] Extend `PackValidator` to reject authored custom-biome spawn categories that disagree with the live entity category instead of allowing the bad datapack to reach loader validation. - [x] Extend `PackValidator` to reject authored custom-biome spawn categories that disagree with the live entity category instead of allowing the bad datapack to reach loader validation.
+1 -1
View File
@@ -248,7 +248,7 @@ Paths relative to the loader config directory (`config/`):
Pack install root is `config/irisworldgen/packs`, not `config/iris`. Missing pack at world open is a hard failure with the expected absolute path (no silent vanilla terrain). Pack install root is `config/irisworldgen/packs`, not `config/iris`. Missing pack at world open is a hard failure with the expected absolute path (no silent vanilla terrain).
Async default-pack prefetch at boot when `autoDownloadDefaultPack` is set and `defaultPack` is missing (`IrisDimensions/<pack>` from `master`). Failures log a pointer to `/iris download <pack>`. When `autoDownloadDefaultPack` is enabled, startup asynchronously installs missing `overworld` and `underworld` managed beta releases, plus a distinct configured non-managed `defaultPack`. Failures log a pointer to `/iris download <pack>`; managed beta names always resolve to their release assets rather than the requested branch.
Forced datapack contributes presets, dimension types, and biomes under `irisworldgen` (ids from pack/dimension names). Regenerated on pack change / studio hotload. Failure to inject (mixin/event not applied) logs once: Forced datapack contributes presets, dimension types, and biomes under `irisworldgen` (ids from pack/dimension names). Regenerated on pack change / studio hotload. Failure to inject (mixin/event not applied) logs once: