Hardened takeover state recording

- A changed level whose existing Iris generation history already uses the configured pack is recorded as applied instead of quarantined
- New and changed takeovers are recorded only once their level generates from the configured pack; removals are recorded at reconciliation
- A changed takeover keeps its previous applied state until the new pack generates, so a later staging failure still sees the level as taken over
- Failing to write the applied takeover state stops the server instead of only logging
This commit is contained in:
Brian Neumann-Fopiano
2026-10-14 20:54:57 -04:00
parent a0c9d48601
commit cf0587c112
2 changed files with 222 additions and 14 deletions
@@ -18,13 +18,19 @@
package art.arcane.iris.modded;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
@@ -33,7 +39,9 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.BiPredicate;
public final class ModdedTakeoverReconciler {
private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json";
@@ -56,10 +64,16 @@ public final class ModdedTakeoverReconciler {
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent);
if (result.changedLevels().isEmpty()) {
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()) {
return;
}
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.changedLevels(),
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", quarantined,
result.recovery() == null
? "no existing chunk data to quarantine"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
@@ -77,34 +91,91 @@ public final class ModdedTakeoverReconciler {
}
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
try {
if (readApplied(worldRoot.get()).equals(configured)) {
return;
}
writeApplied(worldRoot.get(), configured);
recordApplied(worldRoot.get(), configured,
(String levelKey, String packRef) -> generatesFrom(server, levelKey, packRef));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not record the applied dimension takeovers under {}", worldRoot.get(), 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",
worldRoot.get(), failure);
server.halt(false);
}
}
static Map<String, String> recordApplied(Path worldRoot, Map<String, String> configured,
BiPredicate<String, String> generating) throws IOException {
Map<String, String> applied = readApplied(worldRoot);
Map<String, String> recorded = new TreeMap<>(applied);
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
if (generating.test(entry.getKey(), entry.getValue())) {
recorded.put(entry.getKey(), entry.getValue());
continue;
}
ModdedIrisLog.warn("Iris dimension takeover of {} is configured but the level does not generate from {}; it stays unapplied",
entry.getKey(), entry.getValue());
}
if (!recorded.equals(applied)) {
writeApplied(worldRoot, recorded);
}
return Map.copyOf(recorded);
}
static Result reconcile(Path worldRoot, Map<String, String> configured, List<String> configProblems,
Path recoveryParent) throws IOException {
requireReadableConfig(worldRoot, configProblems);
Map<String, String> applied = readApplied(worldRoot);
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
if (changed.isEmpty()) {
return new Result(changed, null);
return new Result(changed, Set.of(), null);
}
Map<String, String> state = new TreeMap<>(applied);
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;
}
if (packRef == null) {
state.remove(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);
writeApplied(worldRoot, configured);
return new Result(changed, moved.isEmpty() ? null : recovery);
if (!state.equals(applied)) {
writeApplied(worldRoot, state);
}
return new Result(changed, Set.copyOf(adopted), moved.isEmpty() ? null : recovery);
}
static boolean historyMatches(Path storage, ModdedDimensionTakeover takeover) throws IOException {
if (!Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS)) {
return false;
}
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
GenerationEpoch.DimensionContract contract = epoch.dimensionContract();
if (!contract.dimensionKey().equals(takeover.packDimension())) {
return false;
}
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
if (scoped.isPresent()) {
return scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(takeover.pack(), takeover.packDimension()));
}
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey());
return packs.isEmpty() || packs.contains(takeover.pack());
}
private static boolean generatesFrom(NativeModdedServer server, String levelKey, String packRef) {
NativeWorld world = server.world(levelKey);
if (world == null) {
return false;
}
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class);
return generator != null && packRef.equals(generator.dimensionKey());
}
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
@@ -154,6 +225,6 @@ public final class ModdedTakeoverReconciler {
}
}
record Result(Set<String> changedLevels, Path recovery) {
record Result(Set<String> changedLevels, Set<String> adoptedLevels, Path recovery) {
}
}
@@ -18,6 +18,9 @@
package art.arcane.iris.modded;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import org.junit.Test;
import java.io.IOException;
@@ -76,7 +79,7 @@ public class ModdedTakeoverReconcilerTest {
assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin")));
assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat")));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
@@ -112,7 +115,7 @@ public class ModdedTakeoverReconcilerTest {
}
@Test
public void changeWithoutChunkDataRecordsStateWithoutARecoveryFolder() throws IOException {
public void newTakeoverWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-empty");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
@@ -125,7 +128,7 @@ public class ModdedTakeoverReconcilerTest {
assertEquals(Set.of("aether:the_aether"), result.changedLevels());
assertNull(result.recovery());
assertFalse(Files.exists(recoveryParent));
assertEquals(Map.of("aether:the_aether", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
@@ -184,6 +187,140 @@ public class ModdedTakeoverReconcilerTest {
}
}
@Test
public void changedTakeoverKeepsThePreviousStateUntilTheNewPackGenerates() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-change");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "skypack"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.changedLevels());
assertFalse(Files.exists(region));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void existingHistoryOfTheConfiguredPackIsAdoptedWithoutQuarantine() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-match");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.adoptedLevels());
assertNull(result.recovery());
assertTrue(Files.isRegularFile(skylands.resolve("region/r.0.0.mca")));
assertEquals(Map.of("iristest:skylands", "skypack:sky"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void historyOfAnotherPackIsQuarantined() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-other");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of(), result.adoptedLevels());
assertFalse(Files.exists(skylands.resolve("region")));
assertFalse(Files.exists(skylands.resolve("iris")));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void borrowedHistoryMatchesOnItsRecordedAliasPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-borrowed");
Path worldRoot = temporary.resolve("world");
try {
Path aether = worldRoot.resolve("dimensions/aether/the_aether");
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
touch(aether.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result other = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("aether:the_aether", "otherpack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of(), other.adoptedLevels());
assertFalse(Files.exists(aether.resolve("region")));
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
ModdedTakeoverReconciler.Result matching = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("aether:the_aether", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("aether:the_aether"), matching.adoptedLevels());
assertTrue(Files.isDirectory(aether.resolve("iris")));
} finally {
deleteTree(temporary);
}
}
@Test
public void recordAppliedStoresOnlyTakeoversThatGenerateFromTheirPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-record");
Path worldRoot = temporary.resolve("world");
try {
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:kept", "overworld"));
Map<String, String> recorded = ModdedTakeoverReconciler.recordApplied(worldRoot,
Map.of("iristest:kept", "overworld", "iristest:live", "skypack", "iristest:skipped", "skypack"),
(String level, String packRef) -> !level.equals("iristest:skipped"));
Map<String, String> expected = Map.of("iristest:kept", "overworld", "iristest:live", "skypack");
assertEquals(expected, recorded);
assertEquals(expected, ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void recordAppliedFailsWhenTheStateCannotBeWritten() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-record-fail");
Path worldRoot = temporary.resolve("world");
try {
touch(worldRoot.resolve("data/irisworldgen/dimension-takeovers.json/blocker"));
assertThrows(IOException.class, () -> ModdedTakeoverReconciler.recordApplied(worldRoot,
Map.of("iristest:live", "skypack"), (String level, String packRef) -> true));
} finally {
deleteTree(temporary);
}
}
private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey,
List<String> biomeKeys) throws IOException {
Path source = Files.createTempDirectory(temporary, "pack");
touch(source.resolve("dimensions/" + dimensionKey + ".json"));
GenerationEpoch epoch = ModdedRetainedPackNameTest.epoch(dimensionTypeKey, dimensionKey, biomeKeys);
Files.createDirectories(dimensionRoot);
GenerationHistory.create(dimensionRoot, source,
GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), 5L,
epoch.dimensionContract(), epoch.registryContract());
}
private static void touch(Path file) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, "x", StandardCharsets.UTF_8);