This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 20:35:27 -04:00
parent 82290a3090
commit 623a02025c
76 changed files with 4820 additions and 280 deletions
@@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisStartupAdmissionListener;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.core.IrisWorldGeneratorResolver;
import art.arcane.iris.core.PendingWorldDeleteQueue;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.SettingsHotloadWatch;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.DatapackIngestService;
@@ -167,6 +168,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private volatile SettingsHotloadWatch settingsHotloadWatch;
public static VolmitSender getSender() {
@@ -595,6 +597,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
services.values().forEach(IrisService::onEnable);
services.values().forEach(this::registerListener);
addShutdownHook();
pendingWorldReplacements.processPendingStartupReplacements();
pendingWorldDeletes.processPendingStartupWorldDeletes();
WorldLifecycleService.get();
WorldRuntimeControlService.get();
@@ -604,6 +607,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
J.s(() -> {
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
@@ -642,6 +646,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return worldReconciler;
}
public PendingWorldReplacementManager pendingWorldReplacements() {
return pendingWorldReplacements;
}
private void autoStartStudio() {
if (IrisSettings.get().getStudio().isAutoStartDefaultStudio()) {
Iris.info("Starting up auto Studio!");
@@ -683,6 +691,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisPlatforms.bind(new BukkitPlatform());
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
enable();
BukkitGuiHost.install();
super.onEnable();
@@ -741,13 +741,19 @@ public final class BukkitWorldReconciler {
@Override
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
boolean snapshotPresent = snapshotRoot.isDirectory();
if (snapshotPresent) {
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
}
String worldName = IrisWorldStorage.logicalName(worldKey);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
if (irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
}
PackValidationRegistry.requireLoadable(
irisDimension.getLoader().getDataFolder().getName());
if (!snapshotPresent) {
PackValidationRegistry.requireLoadable(irisDimension.getLoader().getDataFolder().getName());
}
}
}
}
@@ -55,6 +55,8 @@ import java.util.function.Supplier;
* Bukkit plugin entry points delegate to.
*/
public final class IrisWorldGeneratorResolver {
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private final VolmitPlugin plugin;
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
@@ -134,6 +136,35 @@ public final class IrisWorldGeneratorResolver {
IrisStartupValidation.markPacksReady();
}
static PackValidationResult requireSnapshotLoadable(File packRoot) {
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
synchronized (SNAPSHOT_VALIDATION_LOCK) {
result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
}
PackValidationRegistry.publish(normalizedRoot, result);
}
}
}
return PackValidationRegistry.requireLoadable(normalizedRoot);
}
@Nullable
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
File pack = IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(worldName));
@@ -188,9 +219,17 @@ public final class IrisWorldGeneratorResolver {
if (dim == null) {
throw new RuntimeException("Can't find dimension " + id + "!");
}
String packName = dim.getLoader().getDataFolder().getName();
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
File dimensionPackRoot = dim.getLoader().getDataFolder();
String packName = dimensionPackRoot.getName();
try {
PackValidationRegistry.requireLoadable(packName);
if (snapshotRoot.toPath().toAbsolutePath().normalize()
.equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) {
requireSnapshotLoadable(snapshotRoot);
} else {
PackValidationRegistry.requireLoadable(packName);
}
} catch (BrokenPackException exception) {
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':");
for (String reason : exception.getReasons()) {
@@ -200,7 +239,6 @@ public final class IrisWorldGeneratorResolver {
}
Iris.debug("Assuming IrisDimension: " + dim.getName());
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
IrisWorld w = IrisWorld.builder()
.platformIdentity(worldKey.toString())
@@ -225,6 +263,7 @@ public final class IrisWorldGeneratorResolver {
} else {
dim = installedDimension;
}
requireSnapshotLoadable(ff);
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
}
@@ -0,0 +1,799 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEnvironment;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.world.WorldLoadEvent;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
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.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener {
private static final String JOURNAL_DIRECTORY = "pending-world-replacements";
private static final String JOURNAL_SUFFIX = ".properties";
private final Iris plugin;
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
String requested = Objects.requireNonNull(requestedName, "requestedName").trim();
if (requested.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
NamespacedKey worldKey = requested.contains(":")
? NamespacedKey.fromString(requested.toLowerCase(Locale.ENGLISH))
: IrisWorldStorage.keyFromName(requested);
if (worldKey == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
return worldKey;
}
public synchronized StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension,
long seed
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
ExactWorldSlotPathPolicy.Target resolvedTarget = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
requiredWorldKey
);
requireCompatibleEnvironment(resolvedTarget.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(resolvedTarget.slotKind(), seed);
String worldName = IrisWorldStorage.logicalName(requiredWorldKey);
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
requiredWorldKey.toString()
)) {
if (findTransaction(requiredWorldKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
}
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
}
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = replacementPaths(target, transactionId);
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
worldName
);
Transaction transaction = null;
boolean journalWritten = false;
boolean configurationApplied = false;
try {
Files.createDirectory(paths.stage());
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
requiredSender,
requiredDimension,
paths.stage().toFile()
);
if (installed == null) {
throw new IOException("Iris could not stage the dimension pack.");
}
File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
transaction = new Transaction(
transactionId,
requiredWorldKey,
installed.getLoadKey(),
effectiveSeed,
packFingerprint,
originalConfiguration,
targetPresent,
Phase.PREPARED
);
writeTransaction(transaction);
journalWritten = true;
GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
originalConfiguration,
installed.getLoadKey(),
effectiveSeed
);
if (!replacement.applied()) {
throw new IOException("bukkit.yml changed while the replacement was being staged.");
}
configurationApplied = true;
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
return new StagedReplacement(
requiredWorldKey,
worldName,
installed.getLoadKey(),
effectiveSeed,
targetPresent,
datapacks.restartRequired()
);
} catch (Throwable failure) {
if (configurationApplied && transaction != null) {
try {
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
worldName,
replacement,
originalConfiguration
)) {
failure.addSuppressed(new IOException(
"bukkit.yml changed before the failed replacement could be restored."));
}
} catch (Throwable restoreFailure) {
failure.addSuppressed(restoreFailure);
}
}
if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) {
try {
WorldReplacementFilesystem.discardStage(paths);
if (journalWritten) {
deleteJournal(transactionId);
}
} catch (Throwable cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
if (failure instanceof IOException ioFailure) {
throw ioFailure;
}
throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure);
}
}
}
public synchronized void processPendingStartupReplacements() {
ArrayList<String> failures = new ArrayList<>();
List<Transaction> transactions;
try {
transactions = loadTransactions();
} catch (Throwable failure) {
Iris.reportError("Failed to read pending Iris world replacements.", failure);
IrisStartupValidation.markPacksInvalid(List.of(
"Pending Iris world replacement journal validation failed: " + detail(failure)));
return;
}
for (Transaction transaction : transactions) {
try {
processStartupTransaction(transaction);
} catch (Throwable failure) {
String message = "Pending replacement for " + transaction.worldKey()
+ " failed safely: " + detail(failure);
failures.add(message);
Iris.reportError(message, failure);
}
}
if (!failures.isEmpty()) {
IrisStartupValidation.markPacksInvalid(failures);
}
}
public synchronized void verifyLoadedPublishedWorlds() {
try {
for (Transaction transaction : loadTransactions()) {
if (transaction.phase() != Phase.PUBLISHED) {
continue;
}
WorldIdentity.resolve(transaction.worldKey()).ifPresent(world -> verifyPublishedWorld(world, transaction));
}
} catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld();
J.s(() -> verifyPublishedWorldIfPending(world), 1);
}
private synchronized void verifyPublishedWorldIfPending(World world) {
try {
Transaction transaction = findTransaction(WorldIdentity.key(world));
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
verifyPublishedWorld(world, transaction);
}
} catch (Throwable failure) {
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
}
}
private void verifyPublishedWorld(World world, Transaction transaction) {
try {
if (!transaction.worldKey().equals(WorldIdentity.key(world))) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!IrisToolbelt.isIrisWorld(world)) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (world.getSeed() != transaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || !transaction.dimension().equals(
generator.getTarget().getDimension().getLoadKey())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
transaction.worldKey()
);
ReplacementPaths paths = replacementPaths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("The replacement pack changed before runtime verification.");
}
WorldReplacementFilesystem.cleanupBackup(paths);
deleteJournal(transaction.id());
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
} catch (Throwable failure) {
initiateRollback(transaction, failure);
}
}
private void initiateRollback(Transaction transaction, Throwable failure) {
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
+ "; the retained world will be restored on restart.", failure);
try {
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
writeTransaction(rollback);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
transaction.worldName(),
replacement,
transaction.originalConfiguration()
)) {
throw new IOException("bukkit.yml changed during replacement rollback.");
}
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw new IOException("bukkit.yml no longer matches either side of the replacement.");
}
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);
}
}
private void processStartupTransaction(Transaction transaction) throws IOException {
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
IrisWorldStorage.levelRoot().toPath(),
transaction.worldKey()
);
ReplacementPaths paths = replacementPaths(target, transaction.id());
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
transaction.worldName()
);
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
if (transaction.phase() == Phase.ROLLBACK_PENDING) {
processRollback(transaction, paths, current, replacement);
return;
}
if (transaction.phase() == Phase.PREPARED) {
if (current.matchesGeneratorAndSeed(replacement)) {
transaction = transaction.withPhase(Phase.ARMED);
writeTransaction(transaction);
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
WorldReplacementFilesystem.discardStage(paths);
deleteJournal(transaction.id());
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
return;
} else {
throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
}
}
if (transaction.phase() == Phase.ARMED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml no longer authorizes the armed replacement.");
}
WorldReplacementFilesystem.publish(
paths,
transaction.originalTargetPresent(),
transaction.packFingerprint()
);
transaction = transaction.withPhase(Phase.PUBLISHED);
writeTransaction(transaction);
Iris.success("Published Iris world replacement for " + transaction.worldKey()
+ "; waiting for runtime verification.");
}
if (transaction.phase() == Phase.PUBLISHED) {
if (!current.matchesGeneratorAndSeed(replacement)) {
throw new IOException("bukkit.yml changed after the replacement was published.");
}
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
if (!transaction.packFingerprint().equals(fingerprint)) {
throw new IOException("Published replacement pack fingerprint does not match its journal.");
}
}
}
private void processRollback(
Transaction transaction,
ReplacementPaths paths,
WorldGeneratorSnapshot current,
WorldGeneratorSnapshot replacement
) throws IOException {
if (current.matchesGeneratorAndSeed(replacement)) {
if (!BukkitWorldConfiguration.restoreIfMatching(
ServerProperties.BUKKIT_YML,
transaction.worldName(),
replacement,
transaction.originalConfiguration()
)) {
throw new IOException("bukkit.yml changed during startup rollback.");
}
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
throw new IOException("bukkit.yml conflicts with the pending world rollback.");
}
WorldReplacementFilesystem.rollback(paths, transaction.originalTargetPresent());
deleteJournal(transaction.id());
Iris.success("Restored the retained world for " + transaction.worldKey() + ".");
}
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException {
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
Path dimensions = target.levelRoot().resolve("dimensions");
createDirectoryIfMissing(dimensions);
createDirectoryIfMissing(target.namespaceRoot());
return ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
}
private static void createDirectoryIfMissing(Path directory) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World storage parent is unsafe: " + directory);
}
return;
}
Files.createDirectory(directory);
}
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
for (Transaction transaction : loadTransactions()) {
if (transaction.worldKey().equals(worldKey)) {
return transaction;
}
}
return null;
}
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
);
}
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());
}
private void deleteJournal(UUID id) throws IOException {
Path directory = journalDirectory(false);
if (directory == null) {
return;
}
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
forceDirectory(directory);
}
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 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 static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) {
try {
return BukkitWorldConfiguration.snapshot(ServerProperties.BUKKIT_YML, worldName)
.matchesGeneratorAndSeed(expected);
} catch (IOException failure) {
return false;
}
}
private static void requireCompatibleEnvironment(SlotKind slotKind, IrisEnvironment environment) {
IrisEnvironment expected = switch (slotKind) {
case VANILLA_OVERWORLD -> IrisEnvironment.NORMAL;
case VANILLA_NETHER -> IrisEnvironment.NETHER;
case VANILLA_END -> IrisEnvironment.THE_END;
case IRIS_MANAGED -> null;
};
if (expected != null && environment != expected) {
throw new IllegalArgumentException("The " + slotKind.name().toLowerCase(Locale.ENGLISH)
+ " slot requires a pack environment of " + expected.name() + ".");
}
}
private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException {
if (slotKind == SlotKind.IRIS_MANAGED) {
return requestedSeed;
}
CompletableFuture<VanillaLevelContext> contextFuture = J.sfut(() -> new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
.getSeed(),
Iris.instance.getServer().getAllowNether(),
Iris.instance.getServer().getAllowEnd()
));
if (contextFuture == null) {
throw new IOException("Could not schedule primary level-seed resolution.");
}
try {
VanillaLevelContext context = contextFuture.get(30L, TimeUnit.SECONDS);
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
}
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
}
return context.seed();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Primary level-seed resolution was interrupted.", failure);
} catch (ExecutionException | TimeoutException failure) {
throw new IOException("Could not resolve the authoritative primary level seed.", failure);
}
}
private static World.Environment expectedEnvironment(NamespacedKey worldKey) {
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
return World.Environment.NORMAL;
}
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
return World.Environment.NETHER;
}
if (NamespacedKey.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 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 String detail(Throwable failure) {
String message = failure.getMessage();
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
}
public record StagedReplacement(
NamespacedKey worldKey,
String worldName,
String dimension,
long seed,
boolean replacedExistingTarget,
boolean datapackRestartRequired
) {
public StagedReplacement {
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(worldName, "worldName");
Objects.requireNonNull(dimension, "dimension");
}
}
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) {
}
}
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.PendingWorldReplacementManager;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
@@ -124,17 +125,27 @@ public class CommandIris implements DirectorExecutor {
@Param(description = "The seed to generate the world with", descriptionKey = "iris.director.commandiris.param.seed_generate_world_with", defaultValue = "1337")
long seed,
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
boolean main
boolean main,
@Param(name = "overwrite", aliases = "force", description = "Replace the exact existing world slot on the next restart", descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart", defaultValue = "false")
boolean overwrite
) {
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
if (overwrite) {
worldKey = Iris.instance.pendingWorldReplacements().resolveRequestedWorldKey(name);
} else {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
}
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
String worldName = IrisWorldStorage.logicalName(worldKey);
if (overwrite && main && !NamespacedKey.minecraft("overworld").equals(worldKey)) {
sender().sendMessage(C.RED + "overwrite=true with main=true must target the configured main-world name.");
return;
}
if (worldName.equalsIgnoreCase("iris")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
@@ -147,7 +158,7 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (IrisWorldStorage.dimensionRoot(worldName).exists()) {
if (!overwrite && IrisWorldStorage.dimensionRoot(worldName).exists()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
return;
}
@@ -164,6 +175,27 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (overwrite) {
try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension, seed);
if (staged.seed() != seed) {
sender().sendMessage(C.YELLOW + "Exact vanilla slots preserve the shared level seed; using "
+ staged.seed() + " instead of " + seed + ".");
}
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
? failure.getClass().getSimpleName()
: failure.getMessage();
sender().sendMessage(C.RED + "Could not stage the world replacement: " + detail);
}
return;
}
if (J.isFolia()) {
if (stageFoliaWorldCreation(worldName, dimension, seed, main)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD, MessageArgument.untrusted("worldName", worldName)));
@@ -7,6 +7,7 @@ import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.Bukkit;
@@ -31,11 +32,11 @@ public final class DatapackStructureScopeSVC implements IrisService {
throw new IllegalStateException(
"Iris could not establish ownership for installed datapack structure sets", e);
}
if (scopeIndex.isEmpty()) {
return;
}
for (World world : Bukkit.getWorlds()) {
applyScope(world);
IrisImportedStructureControl importedStructures = importedStructures(world);
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
applyScope(world, importedStructures);
}
}
}
@@ -52,8 +53,10 @@ public final class DatapackStructureScopeSVC implements IrisService {
boolean studioEntryBootstrap = event.getWorld().getGenerator()
instanceof BukkitChunkGenerator generator
&& generator.isStudioEntryBootstrapActive();
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap)) {
applyScope(event.getWorld());
IrisImportedStructureControl importedStructures = importedStructures(event.getWorld());
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap,
importedStructures != null && importedStructures.hasFrequencyOverrides())) {
applyScope(event.getWorld(), importedStructures);
}
}
@@ -62,15 +65,19 @@ public final class DatapackStructureScopeSVC implements IrisService {
INMS.get().abandonStudioStructureBootstrap(event.getWorld());
}
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap) {
return !scopeIndexEmpty || studioEntryBootstrap;
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap,
boolean hasFrequencyOverrides) {
return !scopeIndexEmpty || studioEntryBootstrap || hasFrequencyOverrides;
}
private void applyScope(World world) {
private void applyScope(World world, IrisImportedStructureControl importedStructures) {
Set<String> declaredSources = declaredSources(world);
IrisImportedStructureControl activeControl = importedStructures == null
? new IrisImportedStructureControl()
: importedStructures;
try {
DatapackStructureScopeResult result = INMS.get().scopeDatapackStructures(
world, scopeIndex, declaredSources);
world, scopeIndex, declaredSources, activeControl);
IrisLogging.info("Scoped Iris-managed datapack structure sets for world '"
+ world.getName() + "': " + result.retainedManagedSets() + " retained, "
+ result.excludedManagedSets() + " excluded.");
@@ -80,6 +87,15 @@ public final class DatapackStructureScopeSVC implements IrisService {
}
}
private IrisImportedStructureControl importedStructures(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || generator.getTarget() == null
|| generator.getTarget().getDimension() == null) {
return null;
}
return generator.getTarget().getDimension().getImportedStructures();
}
private Set<String> declaredSources(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null) {
@@ -0,0 +1,83 @@
package art.arcane.iris.core;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class IrisWorldGeneratorResolverTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidationRegistry() {
PackValidationRegistry.clear();
}
@Test
public void snapshotValidationIsLazyAndExactRootScoped() throws Exception {
File packRoot = temporaryFolder.newFolder("world", "iris", "pack");
writeValidPack(packRoot.toPath());
PackValidationResult unrelatedNamedFailure = new PackValidationResult(
"pack", List.of("unrelated basename failure"), List.of(), 1L);
PackValidationRegistry.publish(unrelatedNamedFailure);
PackValidationResult result = IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot);
assertTrue(result.isLoadable());
assertEquals(result, PackValidationRegistry.get(packRoot.toPath()));
assertEquals(unrelatedNamedFailure, PackValidationRegistry.get("pack"));
}
@Test
public void invalidatedSnapshotIsValidatedAgainBeforeAuthorization() throws Exception {
File packRoot = temporaryFolder.newFolder("replace", "iris", "pack");
writeValidPack(packRoot.toPath());
assertTrue(IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot).isLoadable());
Files.writeString(
packRoot.toPath().resolve("dimensions/main.json"),
"{",
StandardCharsets.UTF_8);
PackValidationRegistry.remove(packRoot.toPath());
assertThrows(BrokenPackException.class,
() -> IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot));
PackValidationResult invalid = PackValidationRegistry.get(packRoot.toPath());
assertNotNull(invalid);
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
Files.createDirectories(packRoot.resolve("biomes"));
Files.writeString(
packRoot.resolve("dimensions/main.json"),
"{\"regions\":[\"region\"]}",
StandardCharsets.UTF_8);
Files.writeString(
packRoot.resolve("regions/region.json"),
"{\"landBiomes\":[\"biome\"]}",
StandardCharsets.UTF_8);
Files.writeString(
packRoot.resolve("biomes/biome.json"),
"{\"name\":\"Biome\"}",
StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class CommandIrisCreateOverwriteContractTest {
@Test
public void createExposesOptInRestartReplacementFlag() throws Exception {
Method command = CommandIris.class.getDeclaredMethod(
"create",
String.class,
String.class,
long.class,
boolean.class,
boolean.class
);
Parameter overwriteParameter = command.getParameters()[4];
Param overwrite = overwriteParameter.getAnnotation(Param.class);
assertEquals("overwrite", overwrite.name());
assertEquals("false", overwrite.defaultValue());
assertTrue(Arrays.asList(overwrite.aliases()).contains("force"));
assertEquals(
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
overwrite.descriptionKey()
);
}
}
@@ -34,8 +34,9 @@ public class DatapackStructureScopeSVCTest {
@Test
public void emptyScopeStillAppliesToJigsawStudioBootstrap() {
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true));
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true, false));
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, false, true));
}
}