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
**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
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
pack is fully active immediately. Packs installed later register their custom dimension types
prefetches the managed `overworld` and `underworld` beta packs and rebuilds the worldgen datapack;
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
adding a pack so worlds get its full heights and biomes; worlds created before that restart run
with fallback heights.
@@ -1,9 +1,15 @@
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.ProvisionResult;
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
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.nio.file.Path;
@@ -12,16 +18,75 @@ import java.nio.file.Path;
public final class IrisBootstrap implements PluginBootstrap {
@Override
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();
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 {
return DefaultPackBootstrapProvisioner.provision(
context.getDataDirectory(),
message -> context.getLogger().info(message)
message -> context.getLogger().info(message),
startupPaths
);
} catch (IOException 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.LifecycleOperationCoordinator;
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.ReplacementPaths;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
@@ -68,7 +69,7 @@ public final class PendingWorldReplacementManager implements Listener {
if (worldKey == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), toWorldSlotKey(worldKey));
return worldKey;
}
@@ -80,8 +81,12 @@ public final class PendingWorldReplacementManager implements Listener {
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
}
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
@@ -89,13 +94,13 @@ public final class PendingWorldReplacementManager implements Listener {
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
)) {
if (findTransaction(requiredWorldKey) != null) {
if (findTransaction(requiredWorldSlotKey) != null) {
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());
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);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
@@ -103,7 +108,8 @@ public final class PendingWorldReplacementManager implements Listener {
UUID transactionId = UUID.randomUUID();
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(
ServerProperties.BUKKIT_YML,
worldName
@@ -127,7 +133,7 @@ public final class PendingWorldReplacementManager implements Listener {
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction(
transactionId,
requiredWorldKey,
requiredWorldSlotKey,
worldName,
target.levelRoot(),
installed.getLoadKey(),
@@ -163,7 +169,7 @@ public final class PendingWorldReplacementManager implements Listener {
} catch (Throwable failure) {
if (configurationApplied && transaction != null) {
try {
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
@@ -236,7 +242,7 @@ public final class PendingWorldReplacementManager implements Listener {
if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(transaction.worldKey())
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
.ifPresent(world -> verifyPublishedWorld(world, transaction));
}
}
@@ -266,7 +272,7 @@ public final class PendingWorldReplacementManager implements Listener {
private void verifyPublishedWorld(World world, Transaction transaction) {
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.");
}
if (!IrisToolbelt.isIrisWorld(world)) {
@@ -295,6 +301,13 @@ public final class PendingWorldReplacementManager implements Listener {
if (!transaction.packFingerprint().equals(fingerprint)) {
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) {
initiateRollback(transaction, failure);
return;
@@ -317,13 +330,14 @@ public final class PendingWorldReplacementManager implements Listener {
try {
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
writeTransaction(rollback);
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
} catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure);
IrisStartupValidation.markPacksInvalid(List.of(
"Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure)));
Iris.reportError("Failed to arm Iris world replacement rollback for "
+ 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.");
}
if (transaction.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(replacement)) {
writeTransaction(transaction.withPhase(Phase.ARMED));
throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable.");
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
WorldReplacementFilesystem.discardStage(paths);
deleteJournal(transaction.id());
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
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 {
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())) {
return;
}
try {
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) {
try {
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
synchronized (this) {
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);
deleteJournal(transaction.id());
Iris.success("Removed the retained backup for " + transaction.worldKey() + ".");
deleteJournal(current.id());
Iris.success("Removed the retained backup for " + current.worldKey() + ".");
}
} catch (Throwable failure) {
Iris.reportError("Could not clean the retained backup for " + transaction.worldKey()
+ "; 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();
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
Path dimensions = target.levelRoot().resolve("dimensions");
@@ -416,6 +452,10 @@ public final class PendingWorldReplacementManager implements Listener {
}
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
return findTransaction(toWorldSlotKey(worldKey));
}
private Transaction findTransaction(WorldSlotKey worldKey) throws IOException {
for (Transaction transaction : loadTransactions()) {
if (transaction.worldKey().equals(worldKey)) {
return transaction;
@@ -425,159 +465,23 @@ public final class PendingWorldReplacementManager implements Listener {
}
private List<Transaction> loadTransactions() throws IOException {
Path directory = journalDirectory(false);
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
);
return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath());
}
private void writeTransaction(Transaction transaction) throws IOException {
Path directory = Objects.requireNonNull(journalDirectory(true));
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());
WorldReplacementJournal.write(dataDirectory(), transaction);
}
private void deleteJournal(UUID id) throws IOException {
Path directory = journalDirectory(false);
if (directory == null) {
return;
}
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
forceDirectory(directory);
WorldReplacementJournal.delete(dataDirectory(), id);
}
private Path journalDirectory(boolean create) throws IOException {
Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).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 Path dataDirectory() {
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
}
private static void writeAtomic(Path target, byte[] content) throws IOException {
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
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 ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException {
return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
}
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) {
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
private static World.Environment expectedEnvironment(WorldSlotKey worldKey) {
if (WorldSlotKey.minecraft("overworld").equals(worldKey)) {
return World.Environment.NORMAL;
}
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
if (WorldSlotKey.minecraft("the_nether").equals(worldKey)) {
return World.Environment.NETHER;
}
if (NamespacedKey.minecraft("the_end").equals(worldKey)) {
if (WorldSlotKey.minecraft("the_end").equals(worldKey)) {
return World.Environment.THE_END;
}
return null;
}
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
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 WorldSlotKey toWorldSlotKey(NamespacedKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
return new WorldSlotKey(requiredWorldKey.getNamespace(), requiredWorldKey.getKey());
}
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
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 NamespacedKey toNamespacedKey(WorldSlotKey worldKey) {
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
return new NamespacedKey(requiredWorldKey.namespace(), requiredWorldKey.key());
}
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 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")
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) : "")));
Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite);
Iris.service(StudioSVC.class).downloadManagedBeta(sender(), pack, overwrite);
} 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) : "")));
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,
boolean forceOverwrite, Consumer<String> feedback) {
boolean forceOverwrite, boolean refreshDatapack,
Consumer<String> feedback) {
if (pack == null || !PACK_NAME.matcher(pack).matches()) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_PACK_NAME, MessageArgument.untrusted("pack", String.valueOf(pack))));
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))));
return false;
}
@@ -56,8 +58,8 @@ public final class ModdedPackInstaller {
synchronized (installLock) {
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
try {
PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack)
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback)
PackDownloader.PackInstallResult result = managedBeta
? PackDownloader.downloadManagedBeta(packs, pack, forceOverwrite, feedback)
: PackDownloader.download(
packs,
"IrisDimensions/" + pack,
@@ -67,7 +69,7 @@ public final class ModdedPackInstaller {
pack,
feedback);
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
// 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.
@@ -82,7 +84,8 @@ public final class ModdedPackInstaller {
}
return installed;
} 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(
PackDownloadMessages.DOWNLOAD_FAILED,
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.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -253,20 +254,39 @@ public final class ModdedStartup {
if (!config.autoDownloadDefaultPack()) {
return;
}
String pack = config.defaultPack();
Path configDir = ModdedEngineBootstrap.loader().configDir();
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
File packsRoot = ModdedPackCommands.packsRoot();
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;
}
String source = "master branch";
LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source);
boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
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(
configDir, pack, "master", false,
configDir, pack, branch, false, false,
(String line) -> LOGGER.info("Iris: {}", line));
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,
String branch, boolean forceOverwrite) {
boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack);
String baseDownloadSource = defaultOverworld ? "beta release" : "branch " + branch;
boolean managedBeta = PackDownloader.isManagedBetaPack(pack);
String baseDownloadSource = managedBeta ? "beta release" : "branch " + branch;
String downloadSource = forceOverwrite
? baseDownloadSource + IrisLanguage.plain(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX)
: baseDownloadSource;
@@ -274,7 +274,7 @@ public final class IrisModdedCommands {
}
scheduler.async(() -> {
boolean installed = ModdedPackInstaller.install(
ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite,
ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, true,
(String message) -> scheduler.global(() -> ok(source, message)));
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))));
@@ -383,7 +383,7 @@ public final class ModdedStudioCommands {
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
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))));
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)));
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))));
@@ -588,7 +588,7 @@ public final class ModdedStudioCommands {
File templateFolder = new File(packsRoot, template);
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))));
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)));
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))));
@@ -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)));
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)));
server.execute(() -> {
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)));
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)));
server.execute(() -> {
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 {
jvmArgs('--add-modules', 'jdk.incubator.vector')
classpath += files('src/main/resources')
classpath = files('src/main/resources') + classpath
}
configurations.matching { it.name.startsWith('slim') }.all { }
@@ -1,7 +1,5 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
@@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy {
private ExactWorldSlotPathPolicy() {
}
public static Target resolve(Path levelRoot, NamespacedKey worldKey) {
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
public static Target resolve(Path levelRoot, WorldSlotKey worldKey) {
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path canonicalLevelRoot = canonicalLevelRoot(levelRoot);
SlotKind slotKind = classify(requiredWorldKey);
Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions");
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize();
Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace());
Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize();
if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) {
throw new Rejection(
RejectionReason.PATH_TRAVERSAL,
@@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy {
return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory);
}
public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) {
public static Target validate(Path levelRoot, WorldSlotKey worldKey, Path candidate) {
Path requiredCandidate = Objects.requireNonNull(candidate, "candidate");
rejectTraversal(requiredCandidate, "World candidate");
Target target = resolve(levelRoot, worldKey);
@@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy {
}
}
private static SlotKind classify(NamespacedKey worldKey) {
if ("iris".equals(worldKey.getNamespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) {
private static SlotKind classify(WorldSlotKey worldKey) {
if ("iris".equals(worldKey.namespace())) {
if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) {
throw new Rejection(
RejectionReason.INVALID_IRIS_KEY,
"Iris world keys must be safe single path segments."
@@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy {
}
return SlotKind.IRIS_MANAGED;
}
if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) {
if (!"minecraft".equals(worldKey.namespace())) {
throw new Rejection(
RejectionReason.FOREIGN_NAMESPACE,
"Only Iris-managed and exact vanilla dimension slots can be replaced."
);
}
return switch (worldKey.getKey()) {
return switch (worldKey.key()) {
case "overworld" -> SlotKind.VANILLA_OVERWORLD;
case "the_nether" -> SlotKind.VANILLA_NETHER;
case "the_end" -> SlotKind.VANILLA_END;
@@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy {
}
public record Target(
NamespacedKey worldKey,
WorldSlotKey worldKey,
SlotKind slotKind,
Path levelRoot,
Path namespaceRoot,
@@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy {
namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize();
worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize();
SlotKind expectedSlotKind = classify(worldKey);
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey());
Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace());
Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key());
if (slotKind != expectedSlotKind
|| !namespaceRoot.equals(expectedNamespaceRoot)
|| !worldDirectory.equals(expectedWorldDirectory)) {
@@ -0,0 +1,41 @@
package art.arcane.iris.core;
import java.util.Objects;
import java.util.regex.Pattern;
public record WorldSlotKey(String namespace, String key) {
private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z0-9/._-]+$");
private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^[a-z0-9._-]+$");
public WorldSlotKey {
namespace = Objects.requireNonNull(namespace, "namespace");
key = Objects.requireNonNull(key, "key");
if (!NAMESPACE_PATTERN.matcher(namespace).matches()) {
throw new IllegalArgumentException("World slot namespace is invalid: " + namespace);
}
if (!KEY_PATTERN.matcher(key).matches()) {
throw new IllegalArgumentException("World slot key is invalid: " + key);
}
}
public static WorldSlotKey parse(String value) {
String requiredValue = Objects.requireNonNull(value, "value");
int separator = requiredValue.indexOf(':');
if (separator <= 0 || separator != requiredValue.lastIndexOf(':') || separator == requiredValue.length() - 1) {
throw new IllegalArgumentException("World slot key must use namespace:key syntax.");
}
return new WorldSlotKey(
requiredValue.substring(0, separator),
requiredValue.substring(separator + 1)
);
}
public static WorldSlotKey minecraft(String key) {
return new WorldSlotKey("minecraft", key);
}
@Override
public String toString() {
return namespace + ":" + key;
}
}
@@ -0,0 +1,370 @@
package art.arcane.iris.core.lifecycle;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
public final class BukkitStartupPaths {
private static final String DEFAULT_BUKKIT_CONFIGURATION = "bukkit.yml";
private static final String DEFAULT_LEVEL_NAME = "world";
private static final String DEFAULT_SERVER_PROPERTIES = "server.properties";
private static final String DEFAULT_WORLD_CONTAINER = ".";
private final Path bukkitConfiguration;
private final Path levelRoot;
private final String levelName;
private final Path serverProperties;
private final Path serverRoot;
private final Path worldContainer;
private BukkitStartupPaths(
Path serverRoot,
Path serverProperties,
Path bukkitConfiguration,
Path worldContainer,
String levelName,
Path levelRoot
) {
this.serverRoot = Objects.requireNonNull(serverRoot, "serverRoot");
this.serverProperties = Objects.requireNonNull(serverProperties, "serverProperties");
this.bukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration");
this.worldContainer = Objects.requireNonNull(worldContainer, "worldContainer");
this.levelName = Objects.requireNonNull(levelName, "levelName");
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot");
}
public static BukkitStartupPaths resolveCurrent() throws IOException {
return resolve(Path.of(""), currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory) throws IOException {
return resolve(serverWorkingDirectory, currentArguments());
}
public static BukkitStartupPaths resolve(Path serverWorkingDirectory, String[] processArguments)
throws IOException {
Path serverRoot = canonicalDirectory(
Objects.requireNonNull(serverWorkingDirectory, "serverWorkingDirectory").toAbsolutePath().normalize(),
"Server working directory"
);
StartupArguments arguments = parseArguments(processArguments);
Path serverProperties = resolveAgainstServerRoot(
serverRoot,
arguments.serverProperties(),
DEFAULT_SERVER_PROPERTIES,
"server properties"
);
Path bukkitConfiguration = resolveAgainstServerRoot(
serverRoot,
arguments.bukkitConfiguration(),
DEFAULT_BUKKIT_CONFIGURATION,
"Bukkit configuration"
);
String levelName = effectiveLevelName(serverProperties, arguments.levelName());
Path worldContainer = canonicalPotentialDirectory(
resolveAgainstServerRoot(
serverRoot,
arguments.worldContainer() == null
? BukkitWorldConfiguration.readWorldContainer(bukkitConfiguration.toFile())
: arguments.worldContainer(),
DEFAULT_WORLD_CONTAINER,
"world container"
),
"Configured world container"
);
Path configuredLevel = parsePath(levelName, "level name");
Path levelRoot = canonicalPotentialDirectory(
configuredLevel.isAbsolute()
? configuredLevel
: worldContainer.resolve(configuredLevel),
"Configured level root"
);
return new BukkitStartupPaths(
serverRoot,
serverProperties,
bukkitConfiguration,
worldContainer,
levelName,
levelRoot
);
}
public Path bukkitConfiguration() {
return bukkitConfiguration;
}
public Path levelRoot() {
return levelRoot;
}
public String levelName() {
return levelName;
}
public Path serverProperties() {
return serverProperties;
}
public Path serverRoot() {
return serverRoot;
}
public Path worldContainer() {
return worldContainer;
}
private static String[] currentArguments() {
return applicationArguments(ProcessHandle.current().info().arguments().orElse(new String[0]));
}
static String[] applicationArguments(String[] processArguments) {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
for (int index = 0; index < arguments.length; index++) {
if (arguments[index].equals("-jar")
|| arguments[index].equals("-m")
|| arguments[index].equals("--module")) {
int applicationStart = Math.min(arguments.length, index + 2);
return Arrays.copyOfRange(arguments, applicationStart, arguments.length);
}
}
Set<String> optionsWithValues = Set.of(
"-cp",
"-classpath",
"--class-path",
"-p",
"--module-path",
"--upgrade-module-path",
"--add-modules",
"--enable-native-access",
"--limit-modules",
"--add-exports",
"--add-opens",
"--add-reads",
"--patch-module",
"--source"
);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
if (optionsWithValues.contains(argument)) {
index++;
continue;
}
if (argument.startsWith("-") || argument.startsWith("@")) {
continue;
}
return Arrays.copyOfRange(arguments, index + 1, arguments.length);
}
return new String[0];
}
private static StartupArguments parseArguments(String[] processArguments) throws IOException {
String[] arguments = Objects.requireNonNull(processArguments, "processArguments");
String serverProperties = null;
String bukkitConfiguration = null;
String levelName = null;
String worldContainer = null;
for (int index = 0; index < arguments.length; index++) {
String argument = Objects.requireNonNull(arguments[index], "process argument");
if (argument.equals("--")) {
break;
}
ParsedArgument parsed = parseArgument(
argument,
index + 1 < arguments.length ? arguments[index + 1] : null
);
if (parsed == null) {
continue;
}
switch (parsed.kind()) {
case SERVER_PROPERTIES -> serverProperties = parsed.value();
case BUKKIT_CONFIGURATION -> bukkitConfiguration = parsed.value();
case LEVEL_NAME -> levelName = parsed.value();
case WORLD_CONTAINER -> worldContainer = parsed.value();
}
if (parsed.consumedFollowing()) {
index++;
}
}
return new StartupArguments(serverProperties, bukkitConfiguration, levelName, worldContainer);
}
private static ParsedArgument parseArgument(String argument, String following) throws IOException {
ParsedArgument parsed = parseArgument(
argument,
following,
ArgumentKind.SERVER_PROPERTIES,
"-c",
"--config"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.BUKKIT_CONFIGURATION,
"-b",
"--bukkit-settings"
);
if (parsed != null) {
return parsed;
}
parsed = parseArgument(
argument,
following,
ArgumentKind.WORLD_CONTAINER,
"-W",
"--world-dir",
"--universe",
"--world-container"
);
if (parsed != null) {
return parsed;
}
return parseArgument(
argument,
following,
ArgumentKind.LEVEL_NAME,
"-w",
"--world",
"--level-name"
);
}
private static ParsedArgument parseArgument(
String argument,
String following,
ArgumentKind kind,
String... keys
) throws IOException {
for (String key : keys) {
if (argument.equals(key)) {
if (following == null || following.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, following, true);
}
if (key.length() == 2 && argument.startsWith(key) && argument.length() > key.length()) {
String value = argument.substring(key.length());
if (value.startsWith("=")) {
value = value.substring(1);
}
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
String prefix = key + "=";
if (argument.startsWith(prefix)) {
String value = argument.substring(prefix.length());
if (value.isBlank()) {
throw new IOException("Startup argument " + key + " requires a value");
}
return new ParsedArgument(kind, value, false);
}
}
return null;
}
private static Path resolveAgainstServerRoot(
Path serverRoot,
String configuredValue,
String defaultValue,
String label
) throws IOException {
String value = configuredValue == null ? defaultValue : configuredValue;
Path configured = parsePath(value, label);
return configured.isAbsolute()
? configured.normalize()
: serverRoot.resolve(configured).normalize();
}
private static Path parsePath(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
try {
return Path.of(value);
} catch (InvalidPathException exception) {
throw new IOException("Configured " + label + " is invalid", exception);
}
}
private static String effectiveLevelName(Path serverProperties, String argumentOverride) throws IOException {
if (argumentOverride != null) {
return requireValue(argumentOverride, "level name");
}
if (!Files.exists(serverProperties, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_LEVEL_NAME;
}
if (!Files.isRegularFile(serverProperties)) {
throw new IOException("Configured server properties is not a regular file: " + serverProperties);
}
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(serverProperties)) {
properties.load(input);
}
return requireValue(properties.getProperty("level-name", DEFAULT_LEVEL_NAME), "level name");
}
private static String requireValue(String value, String label) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Configured " + label + " is empty");
}
return value;
}
private static Path canonicalDirectory(Path path, String label) throws IOException {
Path canonical = path.toRealPath();
if (!Files.isDirectory(canonical)) {
throw new IOException(label + " is not a directory: " + path);
}
return canonical;
}
private static Path canonicalPotentialDirectory(Path path, String label) throws IOException {
Path normalized = path.toAbsolutePath().normalize();
ArrayList<Path> missing = new ArrayList<>();
Path existing = normalized;
while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
Path fileName = existing.getFileName();
Path parent = existing.getParent();
if (fileName == null || parent == null) {
throw new IOException(label + " has no existing filesystem ancestor: " + normalized);
}
missing.add(fileName);
existing = parent;
}
Path canonical = canonicalDirectory(existing, label);
for (int index = missing.size() - 1; index >= 0; index--) {
canonical = canonical.resolve(missing.get(index));
}
return canonical.normalize();
}
private enum ArgumentKind {
SERVER_PROPERTIES,
BUKKIT_CONFIGURATION,
LEVEL_NAME,
WORLD_CONTAINER
}
private record ParsedArgument(ArgumentKind kind, String value, boolean consumedFollowing) {
}
private record StartupArguments(
String serverProperties,
String bukkitConfiguration,
String levelName,
String worldContainer
) {
}
}
@@ -9,14 +9,17 @@ import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Objects;
import java.util.function.Predicate;
public final class BukkitWorldConfiguration {
private static final String DEFAULT_WORLD_CONTAINER = ".";
private static final Object MUTATION_LOCK = new Object();
private BukkitWorldConfiguration() {
@@ -63,6 +66,31 @@ public final class BukkitWorldConfiguration {
}
}
public static String readWorldContainer(File configurationFile) throws IOException {
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
Path configurationPath = requiredConfigurationFile.toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return DEFAULT_WORLD_CONTAINER;
}
if (!Files.isRegularFile(configurationPath)) {
throw new IOException("Configured Bukkit configuration is not a regular file: " + configurationPath);
}
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(requiredConfigurationFile);
Object configured = configuration.get("settings.world-container");
if (configured == null) {
return DEFAULT_WORLD_CONTAINER;
}
if (!(configured instanceof String value)) {
throw new IOException("Bukkit settings.world-container must be a path string");
}
if (value.isBlank()) {
throw new IOException("Configured world container is empty");
}
return value;
}
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration {
return new GeneratorReplacement(false, current, replacement);
}
apply(configuration, requiredWorldName, replacement);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return new GeneratorReplacement(true, current, replacement);
}
}
@@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration {
return false;
}
apply(configuration, requiredWorldName, requiredRestoration);
saveAtomic(configurationFile.toPath(), configuration);
saveAtomic(configurationFile.toPath(), configuration, true);
return true;
}
}
@@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration {
}
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
saveAtomic(target, configuration, false);
}
private static void saveAtomic(
Path target,
YamlConfiguration configuration,
boolean requireAtomicReplacement
) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
requireRegularConfigurationFile(absoluteTarget);
Path parent = absoluteTarget.getParent();
if (parent == null) {
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
@@ -206,9 +243,16 @@ public final class BukkitWorldConfiguration {
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
requireRegularConfigurationFile(absoluteTarget);
try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
if (requireAtomicReplacement) {
throw new IOException(
"Exact world replacement requires atomic bukkit.yml publication on this filesystem.",
exception
);
}
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
@@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration {
}
private static YamlConfiguration load(File configurationFile) throws IOException {
requireRegularConfigurationFile(configurationFile.toPath());
YamlConfiguration configuration = new YamlConfiguration();
try {
configuration.load(configurationFile);
@@ -226,6 +271,18 @@ public final class BukkitWorldConfiguration {
}
}
private static void requireRegularConfigurationFile(Path configurationFile) throws IOException {
Path path = configurationFile.toAbsolutePath().normalize();
BasicFileAttributes attributes = Files.readAttributes(
path,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
throw new IOException("bukkit.yml must be an existing regular file and was not changed: " + path);
}
}
private static WorldGeneratorSnapshot snapshot(
YamlConfiguration configuration,
String worldName
@@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap {
feedback.accept("Restored the retained world for " + transaction.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement);
feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + ".");
Transaction active = transaction;
if (active.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled incomplete Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
if (!current.matchesGeneratorAndSeed(replacement)) {
throw conflict(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);
WorldReplacementJournal.write(dataDirectory, active);
}
if (active.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Cancelled Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap {
return ReconcileAction.PUBLISHED;
}
if (active.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
if (current.matchesGeneratorAndSeed(active.originalConfiguration())) {
rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement);
feedback.accept("Rolled back Iris world replacement for " + active.worldKey() + ".");
return ReconcileAction.ROLLED_BACK;
}
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
}
WorldReplacementFilesystem.publish(
paths,
active.originalTargetPresent(),
@@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap {
Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING
? transaction
: transaction.withPhase(Phase.ROLLBACK_PENDING);
if (rollback != transaction) {
if (transaction.phase() != Phase.ROLLBACK_PENDING) {
WorldReplacementJournal.write(dataDirectory, rollback);
}
WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent());
@@ -0,0 +1,16 @@
package art.arcane.iris.core.lifecycle;
public final class WorldReplacementBootstrapMarker {
private static volatile boolean bootstrappedThisProcess;
private WorldReplacementBootstrapMarker() {
}
public static boolean wasBootstrappedThisProcess() {
return bootstrappedThisProcess;
}
public static void markBootstrapped() {
bootstrappedThisProcess = true;
}
}
@@ -1,8 +1,10 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -26,6 +28,11 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WorldReplacementFilesystem {
private static final List<Path> PAPER_WORLD_METADATA = List.of(
Path.of("data/paper/metadata.dat"),
Path.of("data/paper/level_overrides.dat"),
Path.of("data/minecraft/world_gen_settings.dat")
);
private static final Pattern STAGE_NAME = Pattern.compile(
"^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$");
private static final Pattern BACKUP_NAME = Pattern.compile(
@@ -37,7 +44,7 @@ public final class WorldReplacementFilesystem {
public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) {
ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
UUID requiredId = Objects.requireNonNull(id, "id");
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId;
String artifactBase = ".iris-replace-" + requiredTarget.worldKey().key() + "-" + requiredId;
return new ReplacementPaths(
requiredTarget.worldDirectory(),
requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"),
@@ -45,6 +52,17 @@ public final class WorldReplacementFilesystem {
);
}
public static void requireExistingTarget(ReplacementPaths paths) throws IOException {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (!state.targetPresent()) {
throw new IOException("overwrite=true requires an existing exact world slot; use ordinary create for a new world.");
}
if (state.stagePresent() || state.backupPresent()) {
throw new IOException("A replacement artifact already exists for this transaction.");
}
}
public static void publish(
ReplacementPaths paths,
boolean originalTargetPresent,
@@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem {
if (state.stagePresent()) {
requireSafeTree(requiredPaths.stage(), "replacement stage");
requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
Path retainedWorld = state.targetPresent()
? requiredPaths.target()
: requiredPaths.backup();
preservePaperWorldMetadata(retainedWorld, requiredPaths.stage());
}
if (state.targetPresent()) {
if (state.backupPresent()) {
throw new IOException("Replacement target, stage, and backup are all present.");
@@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem {
}
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
if (originalTargetPresent) {
preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target());
requireSafeTree(requiredPaths.target(), "replacement target");
requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint);
}
}
public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException {
@@ -298,6 +327,71 @@ public final class WorldReplacementFilesystem {
}
}
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
requireDirectory(retainedWorld, "retained world");
requireDirectory(replacementWorld, "replacement world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
if (!sourceAttributes.isRegularFile()) {
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
}
}
ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
Path destination = replacementWorld.resolve(relative);
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes destinationAttributes = requireSafeEntry(destination);
if (!destinationAttributes.isRegularFile()) {
throw new IOException("Replacement Paper world metadata is not a regular file: " + relative);
}
continue;
}
copyMetadataFile(retainedWorld.resolve(relative), destination);
}
}
private static void ensureDirectory(Path directory, String label) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
requireDirectory(directory, label);
return;
}
Files.createDirectory(directory);
forceDirectoryRequired(directory.getParent());
}
private static void copyMetadataFile(Path source, Path destination) throws IOException {
Path temporary = destination.resolveSibling("." + destination.getFileName() + ".iris-replace.tmp");
if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attributes = requireSafeEntry(temporary);
if (!attributes.isRegularFile()) {
throw new IOException("Replacement metadata staging path is unsafe: " + temporary);
}
Files.delete(temporary);
}
try {
Files.copy(source, temporary, StandardCopyOption.COPY_ATTRIBUTES);
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException(
"Exact world replacement requires atomic Paper metadata publication on this filesystem.",
exception
);
}
forceDirectoryRequired(destination.getParent());
} finally {
Files.deleteIfExists(temporary);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
@@ -313,13 +407,37 @@ public final class WorldReplacementFilesystem {
}
private static void move(Path source, Path target) throws IOException {
forceDirectoryRequired(source.getParent());
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
throw new IOException(
"Exact world replacement requires atomic directory moves on this filesystem.",
exception
);
}
try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) {
forceDirectoryAfterCommit(source.getParent());
}
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement move completed, but its parent directory could not be durability-synced.",
failure
);
}
}
@@ -1,10 +1,12 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import org.bukkit.NamespacedKey;
import art.arcane.iris.spi.IrisLogging;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
@@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.UUID;
public final class WorldReplacementJournal {
@@ -46,6 +50,12 @@ public final class WorldReplacementJournal {
}
}
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
Set<WorldSlotKey> worldKeys = new HashSet<>();
for (Transaction transaction : transactions) {
if (!worldKeys.add(transaction.worldKey())) {
throw new IOException("Multiple replacement journals target " + transaction.worldKey() + ".");
}
}
return List.copyOf(transactions);
}
@@ -77,8 +87,9 @@ public final class WorldReplacementJournal {
if (directory == null) {
return;
}
forceDirectoryRequired(directory);
Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX));
forceDirectory(directory);
forceDirectoryAfterCommit(directory);
}
public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot)
@@ -91,31 +102,44 @@ public final class WorldReplacementJournal {
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
throw new IOException("The configured level root changed after the world replacement was staged.");
}
String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
String expectedWorldName;
try {
expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
} catch (IllegalArgumentException failure) {
throw new IOException("The replacement journal targets an ambiguous logical world name.", failure);
}
if (!expectedWorldName.equals(requiredTransaction.worldName())) {
throw new IOException("The logical world name changed after the world replacement was staged.");
}
return target;
}
public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) {
public static String logicalWorldName(Path levelRoot, WorldSlotKey worldKey) {
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if ("iris".equals(requiredWorldKey.getNamespace())) {
return requiredWorldKey.getKey();
}
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
Path fileName = requiredLevelRoot.getFileName();
if (fileName == null || fileName.toString().isBlank()) {
throw new IllegalArgumentException("Level root must have a logical world name.");
}
String levelName = fileName.toString();
if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) {
if ("iris".equals(requiredWorldKey.namespace())) {
String logicalName = requiredWorldKey.key();
if (logicalName.equals(levelName)
|| logicalName.equals(levelName + "_nether")
|| logicalName.equals(levelName + "_the_end")) {
throw new IllegalArgumentException(
"An Iris-managed world cannot use a configured vanilla world alias."
);
}
return logicalName;
}
if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) {
return levelName;
}
if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) {
return levelName + "_nether";
}
if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) {
if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) {
return levelName + "_the_end";
}
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
@@ -135,9 +159,11 @@ public final class WorldReplacementJournal {
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
throw new IOException("Replacement journal filename does not match its transaction id.");
}
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
if (worldKey == null) {
throw new IOException("Replacement journal contains an invalid world key.");
WorldSlotKey worldKey;
try {
worldKey = WorldSlotKey.parse(required(properties, "worldKey"));
} catch (IllegalArgumentException failure) {
throw new IOException("Replacement journal contains an invalid world key.", failure);
}
String worldName = exact(properties, "worldName");
Path recordedLevelRoot = recordedLevelRoot(properties);
@@ -300,26 +326,46 @@ public final class WorldReplacementJournal {
}
channel.force(true);
}
forceDirectoryRequired(parent);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException failure) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
throw new IOException(
"Exact world replacement requires atomic journal publication on this filesystem.",
failure
);
}
forceDirectory(parent);
forceDirectoryAfterCommit(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
private static void forceDirectory(Path directory) throws IOException {
private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
private static void forceDirectoryAfterCommit(Path directory) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement journal change completed, but its parent directory could not be durability-synced.",
failure
);
}
}
public record Transaction(
UUID id,
NamespacedKey worldKey,
WorldSlotKey worldKey,
String worldName,
Path levelRoot,
String dimension,
@@ -1,6 +1,7 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList;
@@ -27,7 +28,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
@@ -40,9 +43,20 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final List<PackSpec> DEFAULT_PACKS = List.of(
new PackSpec(
"overworld",
URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"),
"overworld"
),
new PackSpec(
"underworld",
URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"),
"underworld"
)
);
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 3;
private static final int MARKER_SCHEMA = 4;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
@@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner {
private DefaultPackBootstrapProvisioner() {
}
static List<PackSpec> defaultPacks() {
return DEFAULT_PACKS;
}
public static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback) throws IOException {
return provision(dataDirectory, feedback, BukkitStartupPaths.resolveCurrent());
}
public static ProvisionResult provision(
Path dataDirectory,
Consumer<String> feedback,
BukkitStartupPaths startupPaths
) throws IOException {
Objects.requireNonNull(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback");
Path serverRoot = Path.of("").toAbsolutePath().normalize();
Path levelRoot = resolveLevelRoot(serverRoot);
Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
ProvisionOptions options = new ProvisionOptions(
DEFAULT_SOURCE,
DEFAULT_PACKS,
client,
Clock.systemUTC(),
Duration.ofMinutes(30),
@@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner {
try {
return isProvisioned(
dataDirectory,
resolveLevelRoot(Path.of("").toAbsolutePath().normalize())
BukkitStartupPaths.resolveCurrent().levelRoot()
);
} catch (IOException | RuntimeException exception) {
return false;
@@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner {
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
try {
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path datapackRoot = worldDatapackRoot(normalizedLevel);
Path packRoot = normalizedData.resolve("packs/overworld");
Path markerFile = bootstrapRoot.resolve("provisioned.properties");
if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) {
if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) {
return false;
}
Properties marker = loadProperties(markerFile);
@@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner {
.equals(marker.getProperty("compilerIdentity"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
for (PackSpec spec : requiredPacks) {
Path packRoot = normalizedData.resolve("packs").resolve(spec.key());
if (!isPackRoot(packRoot, spec)
|| !spec.source().toString().equals(marker.getProperty(markerKey(spec, "source")))
|| !spec.requiredDimension().equals(marker.getProperty(markerKey(spec, "requiredDimension")))
|| !directoryFingerprint(packRoot).equals(marker.getProperty(markerKey(spec, "fingerprint")))) {
return false;
}
}
return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
normalizedData,
@@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner {
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
Path bootstrapRoot = normalizedData.resolve("bootstrap");
Path legacyDatapackRoot = bootstrapRoot.resolve("datapack");
Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks");
@@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner {
Files.createDirectories(datapacksRoot);
Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties();
boolean existingPack = isPackRoot(packRoot);
boolean existingDatapack = isDatapackRoot(datapackRoot);
String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault"));
boolean unchangedManagedPack = markerOwned
&& currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"));
boolean managedDefault = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managedDefault = false;
List<PackPlan> plans = new ArrayList<>(options.packs().size());
for (PackSpec spec : options.packs()) {
Path packRoot = packsRoot.resolve(spec.key()).normalize();
if (!Objects.equals(packRoot.getParent(), packsRoot)) {
throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot);
}
boolean existingPack = isPackRoot(packRoot, spec);
String currentFingerprint = existingPack ? directoryFingerprint(packRoot) : "";
boolean markerOwned = "true".equals(markerProperty(previousMarker, spec, "managed"));
boolean unchangedManagedPack = markerOwned
&& currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint"));
boolean managed = !existingPack || unchangedManagedPack;
if (Files.isSymbolicLink(packRoot)) {
managed = false;
}
String retainedSourceSha = markerProperty(previousMarker, spec, "sourceSha256");
Archive archive = managed
? acquireArchive(cacheRoot, previousMarker, spec, feedback, options)
: new Archive(null, retainedSourceSha == null || retainedSourceSha.isBlank()
? currentFingerprint
: retainedSourceSha);
boolean replacePack = !existingPack || managed
&& (!archive.sha256().equals(markerProperty(previousMarker, spec, "sourceSha256"))
|| !currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint")));
plans.add(new PackPlan(spec, packRoot, existingPack, managed, archive, replacePack));
}
Archive archive = managedDefault
? acquireArchive(cacheRoot, previousMarker, feedback, options)
: new Archive(null, currentPackFingerprint);
boolean replacePack = !existingPack || managedDefault
&& (!archive.sha256().equals(previousMarker.getProperty("sourceSha256"))
|| !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")));
Path stagedPack = null;
Path extractionRoot = null;
Map<PackPlan, Path> stagedPacks = new LinkedHashMap<>();
List<Path> extractionRoots = new ArrayList<>();
Path compileContainer = null;
Path packBackup = null;
Path datapackBackup = null;
boolean packReplaced = false;
boolean datapackReplaced = false;
List<PackPublication> packPublications = new ArrayList<>();
boolean committed = false;
try {
if (replacePack) {
extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID());
for (PackPlan plan : plans) {
if (!plan.replace()) {
continue;
}
Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID());
extractionRoots.add(extractionRoot);
Files.createDirectories(extractionRoot);
Path extractedPack = extractArchive(archive.path(), extractionRoot);
stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID());
Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec());
Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID());
copyDirectory(extractedPack, stagedPack);
validatePackRoot(stagedPack);
packBackup = replaceWithBackup(stagedPack, packRoot);
packReplaced = true;
validatePackRoot(stagedPack, plan.spec());
stagedPacks.put(plan, stagedPack);
}
for (Map.Entry<PackPlan, Path> entry : stagedPacks.entrySet()) {
Path backup = replaceWithBackup(entry.getValue(), entry.getKey().root());
packPublications.add(new PackPublication(entry.getKey().root(), backup));
}
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot());
@@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner {
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
boolean anyPackReplaced = !packPublications.isEmpty();
boolean rebuildDatapack = anyPackReplaced
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
@@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner {
datapackReplaced = true;
}
validatePackRoot(packRoot);
for (PackPlan plan : plans) {
validatePackRoot(plan.root(), plan.spec());
}
if (!isDatapackRoot(datapackRoot)) {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
options.levelRoot());
@@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner {
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
marker.setProperty("source", options.source().toString());
marker.setProperty("sourceSha256", archive.sha256());
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
for (PackPlan plan : plans) {
marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString());
marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256());
marker.setProperty(markerKey(plan.spec(), "managed"), Boolean.toString(plan.managed()));
marker.setProperty(markerKey(plan.spec(), "fingerprint"), directoryFingerprint(plan.root()));
marker.setProperty(markerKey(plan.spec(), "requiredDimension"), plan.spec().requiredDimension());
}
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
committed = true;
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
for (PackPublication publication : packPublications) {
deleteQuietly(publication.backup(), feedback);
}
deleteQuietly(datapackBackup, feedback);
deleteQuietly(legacyDatapackRoot, feedback);
boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed);
ProvisionStatus status;
if (!existingPack && !existingDatapack) {
if (everyPackMissing && !existingDatapack) {
status = ProvisionStatus.INSTALLED;
} else if (replacePack || rebuildDatapack) {
} else if (anyPackReplaced || rebuildDatapack) {
status = ProvisionStatus.UPDATED;
} else {
status = ProvisionStatus.UNCHANGED;
}
feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + ".");
return new ProvisionResult(packRoot, datapackRoot, status);
feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + ".");
Map<String, Path> provisionedPacks = new LinkedHashMap<>();
for (PackPlan plan : plans) {
provisionedPacks.put(plan.spec().key(), plan.root());
}
return new ProvisionResult(provisionedPacks, datapackRoot, status);
} catch (IOException failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
} catch (RuntimeException | LinkageError failure) {
IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced);
if (!committed) {
IOException rollbackFailure = rollback(
packPublications,
datapackRoot,
datapackBackup,
datapackReplaced
);
if (rollbackFailure != null) {
failure.addSuppressed(rollbackFailure);
}
}
throw new IOException("Iris bootstrap provisioning failed", failure);
} finally {
for (Path stagedPack : stagedPacks.values()) {
deleteQuietly(stagedPack, feedback);
}
for (Path extractionRoot : extractionRoots) {
deleteQuietly(extractionRoot, feedback);
}
deleteQuietly(compileContainer, feedback);
}
}
@@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner {
private static Archive acquireArchive(
Path cacheRoot,
Properties marker,
PackSpec spec,
Consumer<String> feedback,
ProvisionOptions options
) throws IOException {
Path archivePath = cacheRoot.resolve("default-overworld.zip");
Path metadataPath = cacheRoot.resolve("default-overworld.properties");
Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip");
Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties");
Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties();
boolean validCache = false;
if (Files.isRegularFile(archivePath)) {
try {
validCache = validateArchive(archivePath);
validCache = validateArchive(archivePath, spec);
} catch (IOException exception) {
feedback.accept("Cached default overworld archive is invalid; downloading a replacement.");
feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement.");
}
}
String cachedSource = metadata.getProperty("source", "");
boolean cacheMatchesSource = validCache && (spec.source().toString().equals(cachedSource)
|| cachedSource.isBlank() && "overworld".equals(spec.key()));
long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L);
boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
boolean fresh = cacheMatchesSource
&& options.clock().millis() - fetchedAt < options.refreshInterval().toMillis();
if (fresh) {
if (cachedSource.isBlank()) {
metadata.setProperty("source", spec.source().toString());
storePropertiesAtomic(metadataPath, metadata);
}
return new Archive(archivePath, sha256(archivePath));
}
IOException networkFailure = null;
for (int attempt = 1; attempt <= options.attempts(); attempt++) {
try {
HttpRequest.Builder request = HttpRequest.newBuilder(options.source())
HttpRequest.Builder request = HttpRequest.newBuilder(spec.source())
.timeout(options.requestTimeout())
.header("Accept", "application/octet-stream")
.header("User-Agent", "Iris-Bootstrap")
.GET();
if (validCache) {
if (cacheMatchesSource) {
String etag = metadata.getProperty("etag");
String lastModified = metadata.getProperty("lastModified");
if (etag != null && !etag.isBlank()) {
@@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner {
}
HttpResponse<InputStream> response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status == 304 && validCache) {
if (status == 304 && cacheMatchesSource) {
close(response.body());
metadata.setProperty("source", spec.source().toString());
metadata.setProperty("fetchedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(metadataPath, metadata);
return new Archive(archivePath, sha256(archivePath));
@@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner {
Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip");
try {
try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) {
copyLimited(input, output, options.maxArchiveBytes());
copyLimited(input, output, options.maxArchiveBytes(), spec);
}
if (!validateArchive(temporary)) {
throw new IOException("Downloaded default overworld archive is invalid");
if (!validateArchive(temporary, spec)) {
throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid");
}
move(temporary, archivePath, true);
} finally {
@@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner {
Properties updated = new Properties();
response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value));
response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value));
updated.setProperty("source", spec.source().toString());
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated);
feedback.accept("Downloaded the Iris default overworld beta archive.");
feedback.accept("Downloaded the Iris " + spec.key() + " beta archive.");
return new Archive(archivePath, updated.getProperty("sha256"));
}
close(response.body());
IOException statusFailure = new IOException("Default overworld download returned HTTP " + status);
IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status);
if (!retryableStatus(status)) {
networkFailure = statusFailure;
break;
@@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld download was interrupted", exception);
throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception);
} catch (IOException exception) {
networkFailure = exception;
}
@@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner {
}
}
if (validCache) {
feedback.accept("Default overworld download failed; using the validated cached archive.");
if (cacheMatchesSource) {
feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath));
}
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) {
String sourceSha = marker.getProperty("sourceSha256");
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
return new Archive(null, sourceSha);
}
throw networkFailure == null
? new IOException("Default overworld archive is unavailable and no valid cache exists")
: new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure);
? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists")
: new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure);
}
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) {
String sourceSha = marker.getProperty("sourceSha256");
String expectedFingerprint = marker.getProperty("defaultPackFingerprint");
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
String sourceSha = markerProperty(marker, spec, "sourceSha256");
String expectedFingerprint = markerProperty(marker, spec, "fingerprint");
if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) {
return false;
}
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld");
Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key());
try {
return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint);
return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static Path resolveLevelRoot(Path serverRoot) throws IOException {
Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize();
String levelName = readConfiguredLevelName(normalizedServerRoot);
Path configured = Path.of(levelName);
return configured.isAbsolute()
? configured.normalize()
: normalizedServerRoot.resolve(configured).normalize();
public static Path resolveLevelRoot(Path serverRoot) throws IOException {
return BukkitStartupPaths.resolve(serverRoot).levelRoot();
}
private static String readConfiguredLevelName(Path serverRoot) throws IOException {
String levelName = "world";
Path propertiesFile = serverRoot.resolve("server.properties");
if (Files.isRegularFile(propertiesFile)) {
Properties properties = loadProperties(propertiesFile);
levelName = properties.getProperty("level-name", levelName);
}
String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]);
for (int index = 0; index < arguments.length; index++) {
String argument = arguments[index];
String following = index + 1 < arguments.length ? arguments[index + 1] : null;
String parsed = parseLevelArgument(argument, following);
if (parsed != null) {
levelName = parsed;
}
}
if (levelName.isBlank()) {
throw new IOException("Configured level name is empty");
}
return levelName;
}
private static String parseLevelArgument(String argument, String following) {
for (String key : List.of("-w", "--level-name", "--world")) {
if (argument.equals(key) && following != null && !following.isBlank()) {
return following;
}
String prefix = key + "=";
if (argument.startsWith(prefix) && argument.length() > prefix.length()) {
return argument.substring(prefix.length());
}
}
return null;
}
private static boolean validateArchive(Path archive) throws IOException {
private static boolean validateArchive(Path archive, PackSpec spec) throws IOException {
int entries = 0;
long expanded = 0L;
boolean dimensionFound = false;
@@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) {
throw new IOException("Default overworld archive contains duplicate path " + name);
throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name);
}
if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) {
String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json";
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
dimensionFound = true;
}
if (!entry.isDirectory()) {
@@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
}
}
@@ -471,9 +526,9 @@ public final class DefaultPackBootstrapProvisioner {
return entries > 0 && dimensionFound;
}
private static Path extractArchive(Path archive, Path extractionRoot) throws IOException {
private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException {
if (archive == null) {
throw new IOException("Cached default pack archive is unavailable for required pack rebuild");
throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild");
}
long expanded = 0L;
int entries = 0;
@@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
entries++;
if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Default overworld archive contains too many entries");
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries");
}
String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) {
throw new IOException("Unsafe path in default overworld archive: " + entry.getName());
throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(output);
@@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Default overworld archive expands beyond the safety limit");
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit");
}
file.write(buffer, 0, read);
}
@@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner {
zip.closeEntry();
}
}
if (isPackRoot(extractionRoot)) {
if (isPackRoot(extractionRoot, spec)) {
return extractionRoot;
}
List<Path> candidates = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(extractionRoot)) {
for (Path child : children) {
if (Files.isDirectory(child) && isPackRoot(child)) {
if (Files.isDirectory(child) && isPackRoot(child, spec)) {
candidates.add(child);
}
}
}
if (candidates.size() != 1) {
throw new IOException("Default overworld archive has an invalid root layout");
throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout");
}
return candidates.getFirst();
}
private static String normalizedZipEntry(String raw) throws IOException {
if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) {
throw new IOException("Invalid path in default overworld archive");
throw new IOException("Invalid path in Iris bootstrap pack archive");
}
String normalized = raw.replace('\\', '/');
Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) {
throw new IOException("Unsafe path in default overworld archive: " + raw);
throw new IOException("Unsafe path in Iris bootstrap pack archive: " + raw);
}
return path.toString().replace('\\', '/');
}
private static boolean isPackRoot(Path path) {
return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json"));
private static boolean isPackRoot(Path path, PackSpec spec) {
return path != null
&& Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json"));
}
private static void validatePackRoot(Path path) throws IOException {
if (!isPackRoot(path)) {
throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path);
private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
if (!isPackRoot(path, spec)) {
throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/"
+ spec.requiredDimension() + ".json at " + path);
}
}
@@ -636,21 +693,21 @@ public final class DefaultPackBootstrapProvisioner {
}
private static IOException rollback(
Path packRoot,
Path packBackup,
boolean packReplaced,
List<PackPublication> packPublications,
Path datapackRoot,
Path datapackBackup,
boolean datapackReplaced
) {
IOException failure = null;
try {
restore(packRoot, packBackup, packReplaced);
restore(datapackRoot, datapackBackup, datapackReplaced);
} catch (IOException exception) {
failure = exception;
}
for (int index = packPublications.size() - 1; index >= 0; index--) {
PackPublication publication = packPublications.get(index);
try {
restore(datapackRoot, datapackBackup, datapackReplaced);
restore(publication.root(), publication.backup(), true);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
@@ -658,6 +715,7 @@ public final class DefaultPackBootstrapProvisioner {
failure.addSuppressed(exception);
}
}
}
return failure;
}
@@ -726,14 +784,19 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException {
private static void copyLimited(
InputStream input,
OutputStream output,
long limit,
PackSpec spec
) throws IOException {
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) {
throw new IOException("Default overworld archive exceeds the download size limit");
throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit");
}
output.write(buffer, 0, read);
}
@@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner {
Thread.sleep(duration.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IOException("Default overworld retry wait was interrupted", exception);
throw new IOException("Iris bootstrap pack retry wait was interrupted", exception);
}
}
@@ -787,6 +850,24 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static String markerKey(PackSpec spec, String property) {
return "pack." + spec.key() + "." + property;
}
private static String markerProperty(Properties marker, PackSpec spec, String property) {
String current = marker.getProperty(markerKey(spec, property));
if (current != null || !"overworld".equals(spec.key())) {
return current;
}
return switch (property) {
case "source" -> marker.getProperty("source");
case "sourceSha256" -> marker.getProperty("sourceSha256");
case "managed" -> marker.getProperty("managedDefault");
case "fingerprint" -> marker.getProperty("defaultPackFingerprint");
default -> null;
};
}
private static void close(InputStream input) {
try {
input.close();
@@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner {
UNCHANGED
}
public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) {
public record ProvisionResult(Map<String, Path> packRoots, Path datapackRoot, ProvisionStatus status) {
public ProvisionResult {
Objects.requireNonNull(packRoot, "packRoot");
packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots"));
Objects.requireNonNull(datapackRoot, "datapackRoot");
Objects.requireNonNull(status, "status");
}
}
record ProvisionOptions(
URI source,
List<PackSpec> packs,
HttpClient client,
Clock clock,
Duration refreshInterval,
@@ -820,18 +901,48 @@ public final class DefaultPackBootstrapProvisioner {
Path levelRoot
) {
ProvisionOptions {
Objects.requireNonNull(source, "source");
packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval");
Objects.requireNonNull(requestTimeout, "requestTimeout");
Objects.requireNonNull(retryDelay, "retryDelay");
Objects.requireNonNull(levelRoot, "levelRoot");
if (attempts < 1 || maxArchiveBytes < 1L) {
if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) {
throw new IllegalArgumentException("Invalid bootstrap provisioning options");
}
Set<String> keys = new HashSet<>();
for (PackSpec pack : packs) {
if (!keys.add(pack.key())) {
throw new IllegalArgumentException("Duplicate bootstrap pack key '" + pack.key() + "'");
}
}
}
}
record PackSpec(String key, URI source, String requiredDimension) {
PackSpec {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(source, "source");
Objects.requireNonNull(requiredDimension, "requiredDimension");
if (!key.matches("[a-z0-9_-]+") || !requiredDimension.matches("[a-z0-9_/-]+")) {
throw new IllegalArgumentException("Invalid bootstrap pack specification for '" + key + "'");
}
}
}
private record PackPlan(
PackSpec spec,
Path root,
boolean existed,
boolean managed,
Archive archive,
boolean replace
) {
}
private record PackPublication(Path root, Path backup) {
}
private record Archive(Path path, String sha256) {
private Archive {
@@ -36,10 +36,12 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -55,6 +57,14 @@ public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip";
private static final String UNDERWORLD_PACK = "underworld";
private static final String UNDERWORLD_REPOSITORY = "IrisDimensions/underworld";
private static final String UNDERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip";
private static final List<String> MANAGED_BETA_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK);
private static final Map<String, ManagedBetaPack> MANAGED_BETA_PACKS = Map.of(
DEFAULT_OVERWORLD_PACK, new ManagedBetaPack(DEFAULT_OVERWORLD_REPOSITORY, DEFAULT_OVERWORLD_RELEASE_URL),
UNDERWORLD_PACK, new ManagedBetaPack(UNDERWORLD_REPOSITORY, UNDERWORLD_RELEASE_URL)
);
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
@@ -74,6 +84,14 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_PACK.equals(pack);
}
public static boolean isManagedBetaPack(String pack) {
return pack != null && MANAGED_BETA_PACKS.containsKey(pack);
}
public static List<String> managedBetaPacks() {
return MANAGED_BETA_PACK_KEYS;
}
public static String defaultOverworldPack() {
return DEFAULT_OVERWORLD_PACK;
}
@@ -115,8 +133,40 @@ public final class PackDownloader {
}
}
public static boolean isManagedBetaPackPresent(File packsFolder, String key) {
if (!isManagedBetaPack(key) || !isPackPresent(packsFolder, key)) {
return false;
}
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
if (resolvedPack == null) {
return false;
}
Path primaryDimension = resolvedPack.toPath().toAbsolutePath().normalize()
.resolve("dimensions")
.resolve(key + ".json");
return !Files.isSymbolicLink(primaryDimension)
&& Files.isRegularFile(primaryDimension, LinkOption.NOFOLLOW_LINKS);
}
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
return downloadManagedBeta(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
}
public static PackInstallResult downloadManagedBeta(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback) throws IOException {
ManagedBetaPack managed = pack == null ? null : MANAGED_BETA_PACKS.get(pack);
if (managed == null) {
throw new IllegalArgumentException("Pack '" + pack + "' has no managed beta release");
}
return download(
packsFolder,
managed.repository(),
managed.releaseUrl(),
forceOverwrite,
true,
pack,
feedback
);
}
/**
@@ -135,7 +185,10 @@ public final class PackDownloader {
}
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
return withDownloadLock(lockKey, () -> {
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
boolean present = isManagedBetaPack(expectedKey)
? isManagedBetaPackPresent(packsFolder, expectedKey)
: isPackPresent(packsFolder, expectedKey);
if (!forceOverwrite && present) {
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false);
}
@@ -235,11 +288,11 @@ public final class PackDownloader {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null;
}
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback);
if (selectedDimension == null) {
return null;
}
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
IrisDimension dimension = data.getDimensionLoader().load(selectedDimension);
if (dimension == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null;
@@ -280,6 +333,29 @@ public final class PackDownloader {
return new PreparedPack(key, name, validation);
}
private static String selectDimensionKey(String[] dimensions, String expectedKey,
Consumer<String> feedback) throws IOException {
if (expectedKey == null || expectedKey.isBlank()) {
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
return null;
}
return dimensions[0];
}
int matches = 0;
for (String dimension : dimensions) {
if (expectedKey.equals(dimension)) {
matches++;
}
}
if (matches != 1) {
throw new IOException("Downloaded pack dimensions " + Arrays.toString(dimensions)
+ " do not contain exactly one requested key '" + expectedKey + "'");
}
return expectedKey;
}
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
Path target = packsRoot.resolve(prepared.key()).normalize();
@@ -299,15 +375,19 @@ public final class PackDownloader {
));
return null;
}
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
boolean present = isManagedBetaPack(prepared.key())
? isManagedBetaPackPresent(packsRoot.toFile(), prepared.key())
: isPackPresent(packsRoot.toFile(), prepared.key());
if (!forceOverwrite && present) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.PACK_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key())
));
return null;
}
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
if (!forceOverwrite && Files.exists(target) && !present) {
IrisLogging.warn("Replacing partial pack folder " + target
+ " (required primary dimension is missing).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
@@ -572,6 +652,10 @@ public final class PackDownloader {
return DEFAULT_OVERWORLD_RELEASE_URL;
}
static String underworldReleaseUrl() {
return UNDERWORLD_RELEASE_URL;
}
private static void validateGithubRef(String qualifiedRef) {
String refPath = qualifiedRef.startsWith("refs/heads/")
? qualifiedRef.substring("refs/heads/".length())
@@ -597,6 +681,9 @@ public final class PackDownloader {
private record PreparedPack(String key, String name, PackValidationResult validation) {
}
private record ManagedBetaPack(String repository, String releaseUrl) {
}
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
}
@@ -68,12 +68,13 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
@@ -97,21 +98,18 @@ public class StudioSVC implements IrisService {
@Override
public void onEnable() {
J.a(() -> {
String pack = IrisSettings.get().getGenerator().getDefaultWorldType();
VolmitSender console = BukkitPlatform.console();
runPackMutation(console, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"managed-beta-packs", () -> installMissingManagedBetaPacks(console),
"Failed to install Iris managed beta packs at startup.");
// Presence means a non-empty pack folder: an empty leftover folder must still
// trigger the install instead of shadowing it forever.
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) {
if (PackDownloader.isDefaultOverworld(pack)) {
IrisLogging.info("Downloading Default Pack " + pack + " (beta release)");
IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false);
} else {
IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack);
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
if (!PackDownloader.isManagedBetaPack(configuredPack)
&& !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
IrisLogging.warn("Default pack '" + configuredPack
+ "' is not installed. Please download it manually with /iris download " + configuredPack);
}
}
});
}
@Override
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))));
}
public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) {
String key = PackDownloader.defaultOverworldPack();
public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) {
if (!PackDownloader.isManagedBetaPack(key)) {
sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release.");
return;
}
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> {
DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite);
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite);
return finishStandalonePackMutation(sender, outcome);
}, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE));
}, "Failed to download IrisDimensions/" + key + " beta release.");
}
public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) {
@@ -380,8 +381,8 @@ public class StudioSVC implements IrisService {
}
private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException {
if (PackDownloader.isDefaultOverworld(key)) {
return downloadDefaultOverworldLocked(sender, forceOverwrite);
if (PackDownloader.isManagedBetaPack(key)) {
return downloadManagedBetaLocked(sender, key, forceOverwrite);
}
String descriptor = key.contains("/") ? key : getListing(false).get(key);
@@ -411,21 +412,52 @@ public class StudioSVC implements IrisService {
return new PackListingReference(repository, ref, expectedKey);
}
private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException {
String expectedKey = PackDownloader.defaultOverworldPack();
if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) {
private DownloadOutcome downloadManagedBetaLocked(
VolmitSender sender,
String expectedKey,
boolean forceOverwrite
) throws IOException {
if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return DownloadOutcome.notChanged();
}
PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld(
PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta(
getWorkspaceFolder(),
expectedKey,
forceOverwrite,
sender::sendMessage
);
return DownloadOutcome.from(result);
}
private boolean installMissingManagedBetaPacks(VolmitSender sender) {
boolean changed = false;
boolean restartRequired = false;
for (String key : missingManagedBetaPacks(getWorkspaceFolder())) {
IrisLogging.info("Downloading managed Iris pack " + key + " (beta release)");
try {
DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, false);
changed |= outcome.changed();
restartRequired |= outcome.restartRequired();
} catch (Throwable failure) {
IrisLogging.reportError("Failed to download IrisDimensions/" + key + " beta release.", failure);
sender.sendMessage("Failed to download IrisDimensions/" + key + " beta release. " + errorDetail(failure));
}
}
return finishStandalonePackMutation(sender, new DownloadOutcome(changed, restartRequired));
}
static List<String> missingManagedBetaPacks(File workspaceFolder) {
List<String> missing = new ArrayList<>();
for (String key : PackDownloader.managedBetaPacks()) {
if (!PackDownloader.isManagedBetaPackPresent(workspaceFolder, key)) {
missing.add(key);
}
}
return List.copyOf(missing);
}
private DownloadOutcome downloadLocked(
VolmitSender sender,
String repo,
@@ -1,7 +1,6 @@
package art.arcane.iris.util.common.misc;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import java.io.File;
import java.io.FileInputStream;
@@ -16,48 +15,19 @@ public class ServerProperties {
public static final String LEVEL_NAME;
static {
String[] args = ProcessHandle.current()
.info()
.arguments()
.orElse(new String[0]);
String propertiesPath = "server.properties";
String bukkitYml = "bukkit.yml";
String levelName = null;
for (int i = 0; i < args.length; i++) {
String arg = args[i];
String next = i < args.length - 1 ? args[i + 1] : null;
propertiesPath = parse(arg, next, propertiesPath, "-c", "--config");
bukkitYml = parse(arg, next, bukkitYml, "-b", "--bukkit-settings");
levelName = parse(arg, next, levelName, "-w", "--level-name", "--world");
BukkitStartupPaths startupPaths;
try {
startupPaths = BukkitStartupPaths.resolveCurrent();
} catch (IOException exception) {
throw new RuntimeException(exception);
}
SERVER_PROPERTIES = new File(propertiesPath);
BUKKIT_YML = new File(bukkitYml);
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)){
SERVER_PROPERTIES = startupPaths.serverProperties().toFile();
BUKKIT_YML = startupPaths.bukkitConfiguration().toFile();
try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) {
DATA.load(in);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (IOException exception) {
throw new RuntimeException(exception);
}
if (levelName != null) LEVEL_NAME = levelName;
else LEVEL_NAME = DATA.getProperty("level-name", "world");
}
private static String parse(
@NotNull String current,
@Nullable String next,
String fallback,
@NotNull String @NotNull ... keys
) {
for (String k : keys) {
if (current.equals(k) && next != null)
return next;
if (current.startsWith(k + "=") && current.length() > k.length() + 1)
return current.substring(k.length() + 1);
}
return fallback;
LEVEL_NAME = startupPaths.levelName();
}
}
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dimension oder Pack, mit der bzw. dem die Welt erstellt wird",
"iris.director.commandiris.param.seed_generate_world_with": "Seed für die Generierung der Welt",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ob man diese Welt automatisch als Hauptwelt benutzt oder nicht",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Den exakten vorhandenen Welt-Slot beim nächsten Neustart ersetzen",
"iris.director.commandiris.director.teleport_another_world": "Teleportieren in eine andere Welt",
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
"iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensión o el pack con el que se creará el mundo",
"iris.director.commandiris.param.seed_generate_world_with": "La semilla con la que se generará el mundo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indica si este mundo debe usarse automáticamente como mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Reemplazar el espacio exacto del mundo existente en el próximo reinicio",
"iris.director.commandiris.director.teleport_another_world": "Teletransportarse a otro mundo",
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
"iris.director.commandiris.param.player_teleport": "El jugador que se teletransportará",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Ulottuvuus / paketti luoda maailma",
"iris.director.commandiris.param.seed_generate_world_with": "Siemenet tuottaa maailman kanssa",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Käytetäänkö tätä maailmaa automaattisesti päämaailmana",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Korvaa täsmällinen olemassa oleva maailmapaikka seuraavalla uudelleenkäynnistyksellä",
"iris.director.commandiris.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
"iris.director.commandiris.param.world_teleport": "Maailman teleportata",
"iris.director.commandiris.param.player_teleport": "Pelaaja teleportata",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimension ou le pack avec lequel créer le monde",
"iris.director.commandiris.param.seed_generate_world_with": "La graine avec laquelle générer le monde",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indique si ce monde doit être utilisé automatiquement comme monde principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Remplacer 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.param.world_teleport": "Le monde vers lequel se téléporter",
"iris.director.commandiris.param.player_teleport": "Le joueur à téléporter",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
"iris.director.commandiris.param.seed_generate_world_with": "הזרע ליצור את העולם עם",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "בין אם להשתמש בעולם באופן אוטומטי כעולם הראשי",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "החלפת משבצת העולם הקיימת המדויקת בהפעלה מחדש הבאה",
"iris.director.commandiris.director.teleport_another_world": "טלפורט לעולם אחר",
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
"iris.director.commandiris.param.player_teleport": "שחקן לטלפורט",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensione o il Pack con cui creare il mondo",
"iris.director.commandiris.param.seed_generate_world_with": "Il seed con cui generare il mondo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Che sia o meno utilizzare automaticamente questo mondo come il mondo principale",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Sostituisci lo slot esatto del mondo esistente al prossimo riavvio",
"iris.director.commandiris.director.teleport_another_world": "Teletrasporto in un altro mondo",
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
"iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
"iris.director.commandiris.param.seed_generate_world_with": "ワールドの生成に使用するシード",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "このワールドをメインワールドとして自動設定するかどうか",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "次回の再起動時に既存の正確なワールドスロットを置き換える",
"iris.director.commandiris.director.teleport_another_world": "別のワールドへテレポートします",
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
"iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
"iris.director.commandiris.param.seed_generate_world_with": "월드 생성에 사용할 시드",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "이 월드를 메인 월드로 자동 사용할지 여부",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "다음 재시작 시 기존의 정확한 월드 슬롯 교체",
"iris.director.commandiris.director.teleport_another_world": "다른 월드로 순간이동합니다",
"iris.director.commandiris.param.world_teleport": "순간이동할 월드",
"iris.director.commandiris.param.player_teleport": "순간이동시킬 플레이어",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "dimensija / paketas sukurti pasaulį su",
"iris.director.commandiris.param.seed_generate_world_with": "Sėkla generuoti pasaulį su",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ar automatiškai naudoti šį pasaulį kaip pagrindinį pasaulį",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Per kitą paleidimą iš naujo pakeisti tikslų esamą pasaulio lizdą",
"iris.director.commandiris.director.teleport_another_world": "Teleportas į kitą pasaulį",
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
"iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "De dimensie/pack om de wereld te creëren met",
"iris.director.commandiris.param.seed_generate_world_with": "Het zaad om de wereld te genereren met",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Of deze wereld automatisch gebruikt moet worden als de belangrijkste wereld",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "De exacte bestaande wereldsleuf bij de volgende herstart vervangen",
"iris.director.commandiris.director.teleport_another_world": "Teleporteren naar een andere wereld",
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
"iris.director.commandiris.param.player_teleport": "Speler naar teleporteren",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Wymiar / pakiet do tworzenia świata z",
"iris.director.commandiris.param.seed_generate_world_with": "Nasienie do generowania świata z",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Czy automatycznie używać tego świata jako głównego świata",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Zastąp dokładny istniejący slot świata przy następnym restarcie",
"iris.director.commandiris.director.teleport_another_world": "Teleport do innego świata",
"iris.director.commandiris.param.world_teleport": "Świat teleportować do",
"iris.director.commandiris.param.player_teleport": "Gracz do teleportowania",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "A dimensão/pack para criar o mundo com",
"iris.director.commandiris.param.seed_generate_world_with": "A semente para gerar o mundo com",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Se deve ou não usar automaticamente este mundo como o mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Substituir o espaço exato do mundo existente no próximo reinício",
"iris.director.commandiris.director.teleport_another_world": "Teletransporte para outro mundo",
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
"iris.director.commandiris.param.player_teleport": "Jogador para teletransportar",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Размер/пакет для создания мира",
"iris.director.commandiris.param.seed_generate_world_with": "Семя, чтобы создать мир с",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Использовать или не использовать этот мир как основной",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Заменить точный существующий слот мира при следующем перезапуске",
"iris.director.commandiris.director.teleport_another_world": "Телепорт в другой мир",
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
"iris.director.commandiris.param.player_teleport": "Игрок телепортируется",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dünyayı yaratmak için boyut / paket",
"iris.director.commandiris.param.seed_generate_world_with": "Dünyayı üretmek için tohum",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Bu dünyayı ana dünya olarak otomatik olarak kullanıp kullanmayalım",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Bir sonraki yeniden başlatmada mevcut tam dünya yuvasını değiştir",
"iris.director.commandiris.director.teleport_another_world": "Teleport başka bir dünyaya",
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
"iris.director.commandiris.param.player_teleport": "Oyuncuya teleport",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "Kích thước/ lốc tạo ra thế giới với",
"iris.director.commandiris.param.seed_generate_world_with": "Hạt giống để tạo ra thế giới với",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Có nên tự động sử dụng thế giới này làm thế giới chính hay không",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Thay thế đúng vị trí thế giới hiện có vào lần khởi động lại tiếp theo",
"iris.director.commandiris.director.teleport_another_world": "Name",
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển",
"iris.director.commandiris.param.player_teleport": "Người chơi cần dịch chuyển",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用于创建世界的维度包",
"iris.director.commandiris.param.seed_generate_world_with": "用于生成世界的种子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自动将此世界设为主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重启时替换指定的现有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "传送到另一个世界",
"iris.director.commandiris.param.world_teleport": "要传送到的世界",
"iris.director.commandiris.param.player_teleport": "要传送的玩家",
@@ -697,6 +697,7 @@
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
"iris.director.commandiris.param.seed_generate_world_with": "用於生成世界的種子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自動將此世界設為主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重新啟動時取代指定的現有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "傳送到另一個世界",
"iris.director.commandiris.param.world_teleport": "要傳送到的世界",
"iris.director.commandiris.param.player_teleport": "要傳送的玩家",
@@ -1,6 +1,5 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -22,22 +21,22 @@ public class ExactWorldSlotPathPolicyTest {
Path canonicalRoot = levelRoot.toRealPath();
List<SlotExpectation> expectations = List.of(
new SlotExpectation(
new NamespacedKey("iris", "underworld"),
new WorldSlotKey("iris", "underworld"),
ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED,
"dimensions/iris/underworld"
),
new SlotExpectation(
NamespacedKey.minecraft("overworld"),
WorldSlotKey.minecraft("overworld"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD,
"dimensions/minecraft/overworld"
),
new SlotExpectation(
NamespacedKey.minecraft("the_nether"),
WorldSlotKey.minecraft("the_nether"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER,
"dimensions/minecraft/the_nether"
),
new SlotExpectation(
NamespacedKey.minecraft("the_end"),
WorldSlotKey.minecraft("the_end"),
ExactWorldSlotPathPolicy.SlotKind.VANILLA_END,
"dimensions/minecraft/the_end"
)
@@ -63,7 +62,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
levelRoot,
NamespacedKey.minecraft("the_nether")
WorldSlotKey.minecraft("the_nether")
);
assertEquals(worldDirectory.toRealPath(), target.worldDirectory());
@@ -75,15 +74,15 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection foreign = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world"))
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("foreign", "world"))
);
ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows(
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.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom"))
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, WorldSlotKey.minecraft("custom"))
);
assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason());
@@ -97,7 +96,7 @@ public class ExactWorldSlotPathPolicyTest {
@Test
public void validatesOnlyTheExactExpectedCandidate() throws Exception {
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");
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate(
@@ -136,7 +135,7 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
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 levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level");
Files.createSymbolicLink(levelLink, linkedLevelTarget);
assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld"));
assertSymbolicLinkRejected(levelLink, new WorldSlotKey("iris", "underworld"));
Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath();
Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath();
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 dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath();
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 namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft"));
Path externalTarget = temporaryFolder.newFolder("external-target").toPath();
Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget);
assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether"));
assertSymbolicLinkRejected(targetLevel, WorldSlotKey.minecraft("the_nether"));
}
@Test
public void rejectsNonDirectoryStorageEntries() throws Exception {
Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath();
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 dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions"));
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 namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris"));
Files.writeString(namespace.resolve("underworld"), "not a directory");
assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld"));
assertUnsafeEntryRejected(targetLevel, new WorldSlotKey("iris", "underworld"));
}
@Test
@@ -191,13 +190,13 @@ public class ExactWorldSlotPathPolicyTest {
ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld"))
() -> ExactWorldSlotPathPolicy.resolve(missing, new WorldSlotKey("iris", "underworld"))
);
ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(
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());
}
private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) {
private void assertSymbolicLinkRejected(Path levelRoot, WorldSlotKey worldKey) {
ExactWorldSlotPathPolicy.Rejection failure = assertThrows(
ExactWorldSlotPathPolicy.Rejection.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -214,7 +213,7 @@ public class ExactWorldSlotPathPolicyTest {
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.class,
() -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey)
@@ -224,7 +223,7 @@ public class ExactWorldSlotPathPolicyTest {
}
private record SlotExpectation(
NamespacedKey worldKey,
WorldSlotKey worldKey,
ExactWorldSlotPathPolicy.SlotKind slotKind,
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.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.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeNoException;
import static org.junit.Assume.assumeTrue;
public class BukkitWorldConfigurationTest {
@Rule
@@ -317,4 +324,48 @@ public class BukkitWorldConfigurationTest {
assertTrue(failure.getMessage().contains("generator"));
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 {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID);
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");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement", readPackContent(paths.target()));
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()));
}
@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
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
@@ -136,6 +204,37 @@ public class WorldReplacementFilesystemTest {
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
public void rejectsPackMutationBeforePublication() throws Exception {
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 {
Files.createDirectories(paths.target());
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 {
@@ -7,6 +7,7 @@ import org.junit.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
@@ -17,10 +18,14 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
@@ -31,6 +36,26 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
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
public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception {
byte[] archive = packArchive("overworld", "bootstrap_biome");
@@ -58,14 +83,23 @@ public class DefaultPackBootstrapProvisionerTest {
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
assertEquals(1, requests.get());
assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json")));
assertEquals(2, requests.get());
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());
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/underworld/worldgen/biome/underworld_biome.json")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
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 {
server.stop(0);
delete(root);
@@ -101,9 +135,10 @@ public class DefaultPackBootstrapProvisionerTest {
);
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("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
} finally {
if (!serverStopped) {
server.stop(0);
@@ -138,9 +173,10 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertEquals(1, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs")));
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")));
} finally {
server.stop(0);
@@ -165,7 +201,7 @@ public class DefaultPackBootstrapProvisionerTest {
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, options);
assertEquals(2, requests.get());
assertEquals(3, requests.get());
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) {
assertTrue(zip.getNextEntry() != null);
}
@@ -245,13 +281,13 @@ public class DefaultPackBootstrapProvisionerTest {
options
);
assertEquals(0, requests.get());
assertEquals(1, requests.get());
assertTrue(Files.isSymbolicLink(link));
assertEquals(target.toRealPath(), link.toRealPath());
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);
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
@@ -259,7 +295,7 @@ public class DefaultPackBootstrapProvisionerTest {
options
);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertEquals(0, requests.get());
assertEquals(2, requests.get());
assertTrue(Files.isSymbolicLink(link));
} finally {
server.stop(0);
@@ -280,7 +316,7 @@ public class DefaultPackBootstrapProvisionerTest {
writePack(dataDirectory.resolve("packs/second"), "second", "second_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(
dataDirectory,
ignored -> {
@@ -289,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest {
);
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/world_local/worldgen/biome/world_local_biome.json")));
} finally {
@@ -319,14 +355,15 @@ public class DefaultPackBootstrapProvisionerTest {
refreshOptions
);
assertEquals(1, requests.get());
assertEquals(3, requests.get());
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
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);
}
assertEquals("false", marker.getProperty("managedDefault"));
assertEquals("false", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
} finally {
server.stop(0);
delete(root);
@@ -334,39 +371,185 @@ public class DefaultPackBootstrapProvisionerTest {
}
@Test
public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception {
public void underworldLocalEditRelinquishesOnlyUnderworldOwnership() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(packArchive("overworld", "first_biome"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-rollback");
HttpServer server = server(packArchive("overworld", "overworld_managed"), requests);
Path root = Files.createTempDirectory("iris-bootstrap-underworld-edit");
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1));
DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision(
DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options(
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,
ignored -> {
},
options
refreshOptions
);
byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties"));
byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta"));
Path invalidPack = dataDirectory.resolve("packs/invalid");
Files.createDirectories(invalidPack.resolve("dimensions"));
Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8);
assertEquals(3, requests.get());
assertTrue(Files.readString(editedBiome).contains("underworld_local_edit"));
assertTrue(Files.isRegularFile(updated.datapackRoot()
.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(
dataDirectory,
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 {
server.stop(0);
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
public void resolvesConfiguredLevelRootFromServerProperties() throws Exception {
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root");
@@ -378,7 +561,7 @@ public class DefaultPackBootstrapProvisionerTest {
);
assertEquals(
serverRoot.resolve("levels/primary").normalize(),
serverRoot.toRealPath().resolve("levels/primary").normalize(),
DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot)
);
} finally {
@@ -391,9 +574,20 @@ public class DefaultPackBootstrapProvisionerTest {
Path serverRoot,
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(
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(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
refreshInterval,
@@ -406,8 +600,17 @@ public class DefaultPackBootstrapProvisionerTest {
}
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);
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();
return server;
}
@@ -428,6 +631,33 @@ public class DefaultPackBootstrapProvisionerTest {
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 {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions"));
@@ -462,8 +692,8 @@ public class DefaultPackBootstrapProvisionerTest {
if (!Files.exists(root)) {
return;
}
try (java.util.stream.Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) {
try (Stream<Path> stream = Files.walk(root)) {
for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
@@ -49,6 +49,7 @@ import java.util.zip.ZipOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -95,6 +96,60 @@ public class PackDownloaderTest {
PackDownloader.defaultOverworldReleaseUrl()
);
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
@@ -102,6 +157,9 @@ public class PackDownloaderTest {
assertFalse(PackDownloader.isDefaultOverworld("theend"));
assertFalse(PackDownloader.isDefaultOverworld(""));
assertFalse(PackDownloader.isDefaultOverworld(null));
assertFalse(PackDownloader.isManagedBetaPack("theend"));
assertFalse(PackDownloader.isManagedBetaPack(""));
assertFalse(PackDownloader.isManagedBetaPack(null));
}
@Test
@@ -345,6 +403,71 @@ public class PackDownloaderTest {
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
public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception {
File packsFolder = temp.newFolder("concurrent-packs");
@@ -465,12 +588,7 @@ public class PackDownloaderTest {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("regions"));
Files.createDirectories(root.resolve("biomes"));
Files.writeString(
root.resolve("dimensions/" + key + ".json"),
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}",
StandardCharsets.UTF_8
);
writeDimension(root, key);
Files.writeString(
root.resolve("regions/local.json"),
"{\"name\":\"Local\",\"landBiomes\":[\"local\"]}",
@@ -485,6 +603,15 @@ public class PackDownloaderTest {
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 {
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) {
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
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
Complete one platform path below. A successful install has all three results:
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.
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+ |
| Forge | 65.0.4+ |
| 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:
@@ -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/`.
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()`).
Validate the plugin install from the server console:
@@ -45,6 +45,7 @@ Validate the plugin install from the server console:
```text
/iris version
/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.
@@ -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/`.
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`.
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:
```text
/iris version
/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.
@@ -118,7 +120,7 @@ Pack resolution for engines, commands, and the forced datapack uses `config/iris
| Key | Default | Effect |
|---|---|---|
| `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 |
| `routePlayersToPrimaryWorld` | `true` | Route players from vanilla overworld when primary is set |
| `mainWorldPack` | `""` | Main-world generator override pack ref |
@@ -142,17 +144,17 @@ Full key list: `03 - Configuration.md`.
| Platform | Behavior |
|---|---|
| Plugin | `DefaultPackBootstrapProvisioner` downloads `https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip` into `packs/overworld` when not already provisioned |
| Mod | If `autoDownloadDefaultPack` and pack missing, async install of configured `defaultPack` into `config/irisworldgen/packs` |
| 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` 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
| 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 |
| `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 |
| 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 |
+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`
- Java 25 server or mod instance running
- 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
@@ -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 |
| 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 |
| 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 |
## 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 |
|-----|------|---------|-------|
| `defaultPack` | string | `"overworld"` | Default pack for bootstrap download/install |
| `autoDownloadDefaultPack` | boolean | `true` | Download default pack when missing |
| `defaultPack` | string | `"overworld"` | Default create pack; a distinct non-managed value is also prefetched when enabled |
| `autoDownloadDefaultPack` | boolean | `true` | Download missing managed Overworld/Underworld beta packs and any distinct configured default |
| `primaryWorld` | string | `""` | Primary Iris dimension id for player routing |
| `routePlayersToPrimaryWorld` | boolean | `true` | Route players to primary when set |
| `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 |
| `unload` | | **Bukkit** | `<world>` | Unload Iris world |
| `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) |
| `reload` | | Both | — | Reload `settings.json` and locale; modded also schedules forced datapack regeneration |
| `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.
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
+3 -3
View File
@@ -126,11 +126,11 @@ Runtime world creation is disabled on Folia. `/iris create` instead:
## 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
+5 -5
View File
@@ -34,13 +34,13 @@ If validation reports a missing edge, restore or repair that resource before pac
| Command | Behavior |
|---------|----------|
| 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` |
| Param | Default | Notes |
|-------|---------|-------|
| `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 |
### 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).
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).
4. Unpack to temp; require single pack home directory.
5. Open as datapack-compiler `IrisData`; require **exactly one** dimension; key = that dimension load key.
4. Unpack to temp; require a single pack home directory.
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.
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
+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.
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)
+3 -3
View File
@@ -41,7 +41,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale
| 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 |
| 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` |
@@ -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 |
| 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`.
@@ -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 |
| Saved planar/spatial Iris jigsaw runtime | 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 |
| Studio open/close/vscode/package | 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)
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:
```
@@ -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.
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.
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.
4. Gate after `WorldLoad`: the retained sibling backup and journal disappear only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds.
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 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.
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)
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):
```
+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
- [ ] 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] 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.
+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).
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: