Refused unstageable takeovers

- Datapack staging refuses a takeover it cannot stage when the level has applied takeover state or Iris generation history, instead of silently leaving the mod generator in place
- A level's retained generation history now stages its takeover even when the installed pack is gone
- Takeover pack names and dimension keys are resolved to the installed folder and file names before staging, reconciliation, and recording, and the takeover command stores that form
- Reconciliation refuses to switch an applied level to an uninstalled pack before moving any data, and leaves a never-applied level untouched until its pack is installed
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 20:59:09 -04:00
1 parent cf0587c112
commit 038833e614
7 files changed
+361 -71

No files matched your search

@@ -18,13 +18,16 @@
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -36,6 +39,8 @@ public final class ModdedDimensionTakeovers {
private static final String GENERATOR_TYPE = "irisworldgen:iris";
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
private static final String DIMENSIONS = "dimensions";
private static final String JSON = ".json";
private ModdedDimensionTakeovers() {
}
@@ -80,13 +85,12 @@ public final class ModdedDimensionTakeovers {
return output;
}
public static void writeLevelStems(Path datapackRoot, Map<String, String> configured,
Map<String, String> stagedDimensionTypes) throws IOException {
public static void writeLevelStems(Path datapackRoot, Map<String, String> configured, StagedTypes staged,
Path worldRoot) throws IOException {
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
String typeRef = stagedDimensionTypes.get(takeover.pack() + ":" + takeover.packDimension());
String typeRef = staged.typeRef(takeover, worldRoot);
if (typeRef == null) {
ModdedIrisLog.error("Iris skipped the dimension takeover of {}: pack '{}' dimension '{}' is not installed or failed validation, so the dimension keeps its own generator",
takeover.levelKey(), takeover.pack(), takeover.packDimension());
skipUnstaged(takeover, staged, worldRoot);
continue;
}
writeLevelStem(datapackRoot, takeover, typeRef);
@@ -95,6 +99,40 @@ public final class ModdedDimensionTakeovers {
}
}
public static Map<String, String> configured() throws IOException {
return canonical(ModdedModConfig.get().dimensionTakeovers(), IrisPlatforms.get().packsFolderNoCreate().toPath());
}
static Map<String, String> canonical(Map<String, String> configured, Path packsRoot) throws IOException {
Map<String, String> canonical = new TreeMap<>();
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
canonical.put(takeover.levelKey(), canonical(takeover, packsRoot).packRef());
}
return Map.copyOf(canonical);
}
public static ModdedDimensionTakeover canonical(ModdedDimensionTakeover takeover, Path packsRoot) throws IOException {
Path pack = packsRoot.resolve(takeover.pack());
if (!Files.isDirectory(pack)) {
return takeover;
}
String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), takeover.pack());
Path dimensions = pack.resolve(DIMENSIONS);
Path dimensionFile = dimensions.resolve(takeover.packDimension() + JSON);
String dimension = takeover.packDimension();
if (Files.isRegularFile(dimensionFile)) {
String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString()
.replace(File.separatorChar, '/');
dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), takeover.packDimension());
}
return new ModdedDimensionTakeover(takeover.levelKey(), packName, dimension);
}
public static boolean installed(ModdedDimensionTakeover takeover, Path packsRoot) {
return Files.isRegularFile(packsRoot.resolve(takeover.pack()).resolve(DIMENSIONS)
.resolve(takeover.packDimension() + JSON));
}
public static Set<String> changedLevels(Map<String, String> applied, Map<String, String> configured) {
Set<String> levels = new TreeSet<>(applied.keySet());
levels.addAll(configured.keySet());
@@ -107,6 +145,32 @@ public final class ModdedDimensionTakeovers {
return changed;
}
private static void skipUnstaged(ModdedDimensionTakeover takeover, StagedTypes staged, Path worldRoot) throws IOException {
String reason = "pack '" + takeover.pack() + "' dimension '" + takeover.packDimension()
+ "' is not installed or failed validation" + caseHint(takeover, staged);
if (worldRoot != null && ModdedTakeoverReconciler.hasTakeoverRecord(worldRoot, takeover.levelKey())) {
throw new IOException("Iris refused to stage the dimension takeover of " + takeover.levelKey() + ": " + reason
+ ", and the level already has an applied takeover or Iris generation history. Restore the pack, or run /iris world takeover "
+ takeover.levelKey() + " off to hand the level back to its own generator.");
}
ModdedIrisLog.error("Iris skipped the dimension takeover of {}: {}, so the dimension keeps its own generator and the takeover stays unapplied",
takeover.levelKey(), reason);
}
private static String caseHint(ModdedDimensionTakeover takeover, StagedTypes staged) {
String wanted = takeover.pack() + ":" + takeover.packDimension();
for (String installed : staged.installed().keySet()) {
if (installed.equalsIgnoreCase(wanted)) {
return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed;
}
}
return "";
}
private static String sameIgnoringCase(String actual, String configured) {
return actual.equalsIgnoreCase(configured) ? actual : configured;
}
public static String fingerprint(Map<String, String> configured) {
StringBuilder canonical = new StringBuilder();
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
@@ -114,4 +178,20 @@ public final class ModdedDimensionTakeovers {
}
return canonical.toString();
}
public record StagedTypes(Map<String, String> installed, Map<Path, String> retained) {
public static StagedTypes create() {
return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>());
}
String typeRef(ModdedDimensionTakeover takeover, Path worldRoot) {
if (worldRoot != null) {
String retainedType = retained.get(takeover.storageFolder(worldRoot).toAbsolutePath().normalize());
if (retainedType != null) {
return retainedType;
}
}
return installed.get(takeover.pack() + ":" + takeover.packDimension());
}
}
}
@@ -324,7 +324,7 @@ public final class ModdedForcedDatapack {
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
digest.update(ModdedDimensionTakeovers.fingerprint(ModdedModConfig.get().dimensionTakeovers())
digest.update(ModdedDimensionTakeovers.fingerprint(ModdedDimensionTakeovers.configured())
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest.digest());
}
@@ -335,7 +335,7 @@ public final class ModdedForcedDatapack {
int packCount = 0;
KList<String> presetIds = new KList<>();
Map<String, String> stagedDimensionTypes = new LinkedHashMap<>();
ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create();
Path worldRoot = worldRootOrNull();
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
List<File> packRoots = compilationPackRoots(worldRoot);
@@ -347,7 +347,7 @@ public final class ModdedForcedDatapack {
}
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
ModdedDimensionTakeovers.writeLevelStems(stagingDirectory,
ModdedModConfig.get().dimensionTakeovers(), stagedDimensionTypes);
ModdedDimensionTakeovers.configured(), stagedDimensionTypes, worldRoot);
}
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
@@ -370,7 +370,7 @@ public final class ModdedForcedDatapack {
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds,
Map<String, String> stagedDimensionTypes) throws IOException {
ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes) throws IOException {
File sourcePack = context.packRoot().toFile();
PackValidationResult validation;
try {
@@ -400,7 +400,7 @@ public final class ModdedForcedDatapack {
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
KList<String> packPresetIds = new KList<>();
Map<String, String> packDimensionTypes = new LinkedHashMap<>();
ModdedDimensionTakeovers.StagedTypes packDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create();
KList<File> packFolders = new KList<>();
packFolders.add(packStagingDirectory.toFile());
boolean installed;
@@ -423,7 +423,8 @@ public final class ModdedForcedDatapack {
mergeDirectory(packStagingDirectory, stagingDirectory);
mergeBiomes(seenBiomes, packBiomes);
presetIds.addAll(packPresetIds);
stagedDimensionTypes.putAll(packDimensionTypes);
stagedDimensionTypes.installed().putAll(packDimensionTypes.installed());
stagedDimensionTypes.retained().putAll(packDimensionTypes.retained());
return true;
} finally {
try {
@@ -483,7 +484,7 @@ public final class ModdedForcedDatapack {
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds,
Map<String, String> dimensionTypes) throws IOException {
ModdedDimensionTakeovers.StagedTypes dimensionTypes) throws IOException {
File packFolder = context.packRoot().toFile();
String packName = context.packName();
File dimensionsDirectory = new File(packFolder, "dimensions");
@@ -531,8 +532,11 @@ public final class ModdedForcedDatapack {
context
);
}
if (context.retainedDimensionRoot() != null) {
dimensionTypes.retained().put(context.retainedDimensionRoot(), typeRef);
}
if (context.emitPresets()) {
dimensionTypes.put(packName + ":" + dimensionKey, typeRef);
dimensionTypes.installed().put(packName + ":" + dimensionKey, typeRef);
if (!dimension.hasBorrowedDimensionType()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
@@ -830,13 +834,13 @@ public final class ModdedForcedDatapack {
GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
if (scoped.isEmpty()) {
return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained.epoch());
return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained);
}
ModdedWorldgenIds.ScopedDimension identity = scoped.get();
if (!identity.dimension().equals(contract.dimensionKey())) {
throw new IOException("Retained Iris dimension type identity does not match its epoch contract.");
}
return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch());
return PackStageContext.retained(normalizedRoot, identity.pack(), retained);
}
String packName = normalizedRoot.getFileName().toString();
@@ -918,7 +922,8 @@ public final class ModdedForcedDatapack {
boolean emitLegacyDimensionTypeAlias,
boolean required,
Set<String> legacyDimensions,
GenerationRegistryContract retainedContract
GenerationRegistryContract retainedContract,
Path retainedDimensionRoot
) implements IrisCustomBiomeAliasResolver {
private static PackStageContext installed(
Path packRoot,
@@ -934,6 +939,7 @@ public final class ModdedForcedDatapack {
true,
false,
Set.copyOf(legacyDimensions),
null,
null
);
}
@@ -941,8 +947,9 @@ public final class ModdedForcedDatapack {
private static PackStageContext retained(
Path packRoot,
String packName,
GenerationEpoch epoch
RetainedPack retained
) {
GenerationEpoch epoch = retained.epoch();
return new PackStageContext(
packRoot,
packName,
@@ -952,7 +959,8 @@ public final class ModdedForcedDatapack {
true,
true,
Set.of(),
epoch.registryContract()
epoch.registryContract(),
retained.history().paths().dimensionRoot().toAbsolutePath().normalize()
);
}
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
@@ -42,6 +43,7 @@ import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.BiPredicate;
import java.util.function.Predicate;
public final class ModdedTakeoverReconciler {
private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json";
@@ -53,7 +55,7 @@ public final class ModdedTakeoverReconciler {
public static void reconcileEarly() {
try {
ModdedModConfig config = ModdedModConfig.get();
Map<String, String> configured = config.dimensionTakeovers();
Map<String, String> configured = ModdedDimensionTakeovers.configured();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
@@ -63,17 +65,17 @@ public final class ModdedTakeoverReconciler {
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent);
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath();
Result result = reconcile(new Request(worldRoot, configured, config.takeoverProblems(),
(ModdedDimensionTakeover takeover) -> ModdedDimensionTakeovers.installed(takeover, packsRoot), recoveryParent));
if (!result.adoptedLevels().isEmpty()) {
ModdedIrisLog.info("Iris dimension takeover recorded for {}: their existing Iris generation history already uses the configured pack",
result.adoptedLevels());
}
Set<String> quarantined = new TreeSet<>(result.changedLevels());
quarantined.removeAll(result.adoptedLevels());
if (quarantined.isEmpty()) {
if (result.quarantinedLevels().isEmpty()) {
return;
}
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", quarantined,
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.quarantinedLevels(),
result.recovery() == null
? "no existing chunk data to quarantine"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
@@ -89,9 +91,8 @@ public final class ModdedTakeoverReconciler {
if (worldRoot.isEmpty()) {
return;
}
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
try {
recordApplied(worldRoot.get(), configured,
recordApplied(worldRoot.get(), ModdedDimensionTakeovers.configured(),
(String levelKey, String packRef) -> generatesFrom(server, levelKey, packRef));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension takeovers under {}; an unrecorded takeover would be quarantined on the next start",
@@ -118,42 +119,69 @@ public final class ModdedTakeoverReconciler {
return Map.copyOf(recorded);
}
static Result reconcile(Path worldRoot, Map<String, String> configured, List<String> configProblems,
Path recoveryParent) throws IOException {
requireReadableConfig(worldRoot, configProblems);
static Result reconcile(Request request) throws IOException {
Path worldRoot = request.worldRoot();
requireReadableConfig(worldRoot, request.configProblems());
Map<String, String> applied = readApplied(worldRoot);
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
if (changed.isEmpty()) {
return new Result(changed, Set.of(), null);
}
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, request.configured());
Map<String, String> state = new TreeMap<>(applied);
Set<String> quarantined = new TreeSet<>();
Set<String> adopted = new TreeSet<>();
List<Path> sources = new ArrayList<>();
for (String level : changed) {
Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level);
String packRef = configured.get(level);
if (packRef != null && historyMatches(storage, ModdedDimensionTakeover.parse(level, packRef))) {
state.put(level, packRef);
adopted.add(level);
continue;
}
String packRef = request.configured().get(level);
if (packRef == null) {
state.remove(level);
quarantined.add(level);
continue;
}
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(level, packRef);
Path storage = takeover.storageFolder(worldRoot);
if (historyMatches(storage, takeover)) {
state.put(level, packRef);
adopted.add(level);
} else if (request.installed().test(takeover)) {
quarantined.add(level);
} else {
refuseOrSkipUninstalled(worldRoot, applied, takeover);
}
}
List<Path> sources = new ArrayList<>();
for (String level : quarantined) {
Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level);
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
sources.add(storage.resolve(folder));
}
}
Path recovery = recoveryParent.resolve("takeover-recovery-" + UUID.randomUUID());
List<Path> moved = ModdedWorldQuarantine.move(worldRoot, sources, recovery);
Path recovery = request.recoveryParent().resolve("takeover-recovery-" + UUID.randomUUID());
List<Path> moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery);
if (!state.equals(applied)) {
writeApplied(worldRoot, state);
}
return new Result(changed, Set.copyOf(adopted), moved.isEmpty() ? null : recovery);
return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), moved.isEmpty() ? null : recovery);
}
static boolean hasTakeoverRecord(Path worldRoot, String levelKey) throws IOException {
return readApplied(worldRoot).containsKey(levelKey) || hasGenerationHistory(
ModdedDimensionTakeover.storageFolder(worldRoot, levelKey));
}
private static void refuseOrSkipUninstalled(Path worldRoot, Map<String, String> applied,
ModdedDimensionTakeover takeover) throws IOException {
if (applied.containsKey(takeover.levelKey()) || hasGenerationHistory(takeover.storageFolder(worldRoot))) {
throw new IOException("Iris refused to switch the dimension takeover of " + takeover.levelKey() + " to "
+ takeover.packRef() + ": pack '" + takeover.pack() + "' dimension '" + takeover.packDimension()
+ "' is not installed, and the level already has an applied takeover or Iris generation history. Install the pack or restore the previous takeover.");
}
ModdedIrisLog.error("Iris left {} untouched: its takeover pack '{}' dimension '{}' is not installed",
takeover.levelKey(), takeover.pack(), takeover.packDimension());
}
private static boolean hasGenerationHistory(Path storage) {
return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS);
}
static boolean historyMatches(Path storage, ModdedDimensionTakeover takeover) throws IOException {
if (!Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS)) {
if (!hasGenerationHistory(storage)) {
return false;
}
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
@@ -225,6 +253,10 @@ public final class ModdedTakeoverReconciler {
}
}
record Result(Set<String> changedLevels, Set<String> adoptedLevels, Path recovery) {
record Request(Path worldRoot, Map<String, String> configured, List<String> configProblems,
Predicate<ModdedDimensionTakeover> installed, Path recoveryParent) {
}
record Result(Set<String> quarantinedLevels, Set<String> adoptedLevels, Path recovery) {
}
}
@@ -27,6 +27,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.iris.modded.MainWorldService;
import art.arcane.iris.modded.ModdedDimensionTakeover;
import art.arcane.iris.modded.ModdedDimensionTakeovers;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.ModdedEngineBootstrap;
@@ -46,6 +47,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -320,12 +322,20 @@ public final class ModdedWorldCommands {
if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) {
return 0;
}
ModdedModConfig.setDimensionTakeover(takeover);
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + takeover.levelKey());
ModdedDimensionTakeover canonical;
try {
canonical = ModdedDimensionTakeovers.canonical(takeover, ModdedPackCommands.packsRoot().toPath());
} catch (IOException unreadable) {
ModdedIrisLog.error("Iris could not resolve the takeover pack {} for {}", takeover.packRef(), takeover.levelKey(), unreadable);
IrisModdedCommands.fail(source, IrisLanguage.errorDetail(unreadable));
return 0;
}
ModdedModConfig.setDimensionTakeover(canonical);
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + canonical.levelKey());
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_SET,
MessageArgument.untrusted("dimensionId", takeover.levelKey()),
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("packDimension", takeover.packDimension())));
MessageArgument.untrusted("dimensionId", canonical.levelKey()),
MessageArgument.untrusted("pack", canonical.pack()),
MessageArgument.untrusted("packDimension", canonical.packDimension())));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
return 1;
}