Merged dimension takeover

This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 20:19:44 -04:00
commit 79e220298f
54 files changed
+1505 -95

No files matched your search

@@ -239,50 +239,21 @@ public final class MainWorldService {
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
List<Path> moved = new ArrayList<>();
List<Path> sources = new ArrayList<>();
for (String file : NativeServerWorldLayout.metadataFiles()) {
moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved);
sources.add(worldRoot.resolve(file));
}
for (String folder : NativeServerWorldLayout.primaryFolders()) {
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
sources.add(worldRoot.resolve(folder));
}
moveToRecovery(worldRoot, worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME), recovery, moved);
sources.add(worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME));
for (String folder : NativeServerWorldLayout.dimensionFolders()) {
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
}
if (moved.isEmpty()) {
Files.deleteIfExists(recovery);
sources.add(worldRoot.resolve(folder));
}
ModdedWorldQuarantine.move(worldRoot, sources, recovery);
return recovery;
}
private static void moveToRecovery(Path worldRoot, Path source, Path recovery,
List<Path> moved) throws IOException {
if (!Files.exists(source)) {
return;
}
Path relative = worldRoot.relativize(source);
Path target = recovery.resolve(relative);
Files.createDirectories(target.getParent());
try {
Files.move(source, target);
moved.add(relative);
} catch (IOException failure) {
for (int index = moved.size() - 1; index >= 0; index--) {
Path rollbackRelative = moved.get(index);
Path rollbackSource = recovery.resolve(rollbackRelative);
Path rollbackTarget = worldRoot.resolve(rollbackRelative);
try {
Files.createDirectories(rollbackTarget.getParent());
Files.move(rollbackSource, rollbackTarget);
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
private static String escape(String value) {
return value.replace(":", "\\:");
}
@@ -0,0 +1,83 @@
/*
* 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.volmlib.util.json.JSONArray;
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.Path;
import java.util.Map;
import java.util.TreeMap;
final class ModdedDatapackTagMerge {
private static final String REPLACE = "replace";
private static final String VALUES = "values";
private ModdedDatapackTagMerge() {
}
static boolean isTag(Path relative) {
return relative.getNameCount() >= 4
&& "data".equals(relative.getName(0).toString())
&& "tags".equals(relative.getName(2).toString())
&& relative.getFileName().toString().endsWith(".json");
}
static void merge(Path source, Path destination) throws IOException {
JSONObject incoming = read(source);
JSONObject existing = read(destination);
Map<String, Object> values = new TreeMap<>();
collect(existing, values, destination);
collect(incoming, values, source);
JSONArray merged = new JSONArray();
for (Object value : values.values()) {
merged.put(value);
}
JSONObject tag = new JSONObject();
tag.put(REPLACE, existing.optBoolean(REPLACE, false) || incoming.optBoolean(REPLACE, false));
tag.put(VALUES, merged);
Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8);
}
private static JSONObject read(Path tag) throws IOException {
try {
return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8));
} catch (RuntimeException invalid) {
throw new IOException("Iris cannot merge the invalid datapack tag " + tag + ".", invalid);
}
}
private static void collect(JSONObject tag, Map<String, Object> values, Path origin) throws IOException {
JSONArray entries = tag.optJSONArray(VALUES);
if (entries == null) {
return;
}
for (int index = 0; index < entries.length(); index++) {
Object entry = entries.get(index);
String key = entry instanceof JSONObject object ? object.optString("id", "") : entry.toString();
if (key.isBlank()) {
throw new IOException("Iris cannot merge datapack tag " + origin + ": entry " + index + " has no id.");
}
values.putIfAbsent(key, entry);
}
}
}
@@ -511,19 +511,21 @@ public final class ModdedDimensionManager {
.normalize();
PackValidationRegistry.requireLoadable(packRoot);
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
String dimensionTypeRef;
try {
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
if (dimension == null) {
throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '"
+ dimensionKey + "'.");
}
dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension);
} finally {
data.close();
}
return new RuntimePack(
packRoot,
dimensionKey,
ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey),
dimensionTypeRef,
ModdedGenerationMode.TRANSIENT_STUDIO
);
}
@@ -43,6 +43,10 @@ public final class ModdedDimensionStorage {
private ModdedDimensionStorage() {
}
static List<String> chunkDataFolders() {
return CHUNK_DATA_FOLDERS;
}
public static File storageFolder(NativeModdedServer server, String dimension) {
return server.dimensionFolder(dimension).toFile();
}
@@ -0,0 +1,84 @@
/*
* 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 java.nio.file.Path;
import java.util.regex.Pattern;
public record ModdedDimensionTakeover(String levelKey, String pack, String packDimension) {
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*");
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*");
public static ModdedDimensionTakeover parse(String levelKey, String packRef) {
String level = requireLevelKey(levelKey);
String reference = packRef == null ? "" : packRef.trim();
int packColon = reference.indexOf(':');
String pack = packColon >= 0 ? reference.substring(0, packColon) : reference;
String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference;
if (!PACK.matcher(pack).matches()
|| !PACK_DIMENSION.matcher(packDimension).matches()
|| pack.contains("..")
|| packDimension.contains("..")) {
throw new IllegalArgumentException("Invalid takeover pack reference '" + packRef
+ "' for " + level + ". Use pack or pack:dimensionKey.");
}
return new ModdedDimensionTakeover(level, pack, packDimension);
}
public String packRef() {
return pack.equals(packDimension) ? pack : pack + ":" + packDimension;
}
public String namespace() {
return levelKey.substring(0, levelKey.indexOf(':'));
}
public String path() {
return levelKey.substring(levelKey.indexOf(':') + 1);
}
public Path storageFolder(Path worldRoot) {
return storageFolder(worldRoot, levelKey);
}
public static Path storageFolder(Path worldRoot, String levelKey) {
String level = requireLevelKey(levelKey);
int colon = level.indexOf(':');
Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon));
for (String segment : level.substring(colon + 1).split("/")) {
folder = folder.resolve(segment);
}
return folder;
}
private static String requireLevelKey(String levelKey) {
String level = levelKey == null ? "" : levelKey.trim();
int colon = level.indexOf(':');
if (colon <= 0
|| !NAMESPACE.matcher(level.substring(0, colon)).matches()
|| !PATH.matcher(level.substring(colon + 1)).matches()
|| level.contains("..")) {
throw new IllegalArgumentException("Invalid takeover dimension key '" + levelKey
+ "'. Use namespace:path, for example aether:the_aether.");
}
return level;
}
}
@@ -0,0 +1,117 @@
/*
* 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.volmlib.util.json.JSONObject;
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.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
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 ModdedDimensionTakeovers() {
}
public static List<ModdedDimensionTakeover> parseAll(Map<String, String> configured) {
List<ModdedDimensionTakeover> takeovers = new ArrayList<>(configured.size());
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
try {
takeovers.add(ModdedDimensionTakeover.parse(entry.getKey(), entry.getValue()));
} catch (IllegalArgumentException invalid) {
ModdedIrisLog.error("Iris ignored dimension takeover {} -> {}: {}",
entry.getKey(), entry.getValue(), invalid.getMessage());
}
}
return List.copyOf(takeovers);
}
public static String levelStemJson(String dimensionTypeKey, ModdedDimensionTakeover takeover) {
JSONObject biomeSource = new JSONObject();
biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE);
biomeSource.put("biome", PLACEHOLDER_BIOME);
JSONObject generator = new JSONObject();
generator.put("type", GENERATOR_TYPE);
generator.put("biome_source", biomeSource);
generator.put("dimension", takeover.packRef());
JSONObject stem = new JSONObject();
stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey"));
stem.put("generator", generator);
return stem.toString(2) + "\n";
}
public static Path writeLevelStem(Path datapackRoot, ModdedDimensionTakeover takeover, String dimensionTypeKey)
throws IOException {
Path output = datapackRoot.resolve("data").resolve(takeover.namespace()).resolve("dimension");
String[] segments = takeover.path().split("/");
for (int index = 0; index < segments.length - 1; index++) {
output = output.resolve(segments[index]);
}
output = output.resolve(segments[segments.length - 1] + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, levelStemJson(dimensionTypeKey, takeover), StandardCharsets.UTF_8);
return output;
}
public static void writeLevelStems(Path datapackRoot, Map<String, String> configured,
Map<String, String> stagedDimensionTypes) throws IOException {
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
String typeRef = stagedDimensionTypes.get(takeover.pack() + ":" + takeover.packDimension());
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());
continue;
}
writeLevelStem(datapackRoot, takeover, typeRef);
ModdedIrisLog.info("Iris dimension takeover staged: {} generates from {} with dimension type {}",
takeover.levelKey(), takeover.packRef(), typeRef);
}
}
public static Set<String> changedLevels(Map<String, String> applied, Map<String, String> configured) {
Set<String> levels = new TreeSet<>(applied.keySet());
levels.addAll(configured.keySet());
Set<String> changed = new TreeSet<>();
for (String level : levels) {
if (!Objects.equals(applied.get(level), configured.get(level))) {
changed.add(level);
}
}
return changed;
}
public static String fingerprint(Map<String, String> configured) {
StringBuilder canonical = new StringBuilder();
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
canonical.append(entry.getKey()).append('=').append(entry.getValue()).append('\n');
}
return canonical.toString();
}
}
@@ -173,6 +173,9 @@ public final class ModdedEngineBootstrap {
bindWorldGenerators(server);
ModdedStartup.runOnce(server);
reconcileSpawn(server);
if (!loader().clientEnvironment()) {
ModdedTakeoverReconciler.recordApplied(server);
}
ModdedWorldCheck.serverStarted(server);
ModdedWorldgenCatalogs.capture(server);
}
@@ -369,6 +372,9 @@ public final class ModdedEngineBootstrap {
selfTest(moddedLoader.getClass().getClassLoader());
bind();
IrisLanguage.initialize();
if (!moddedLoader.clientEnvironment()) {
ModdedTakeoverReconciler.reconcileEarly();
}
ModdedStartup.prefetchStartupDatapack();
if (!moddedLoader.clientEnvironment()) {
MainWorldService.reconcileEarly();
@@ -324,6 +324,8 @@ public final class ModdedForcedDatapack {
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
digest.update(ModdedDimensionTakeovers.fingerprint(ModdedModConfig.get().dimensionTakeovers())
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest.digest());
}
@@ -333,15 +335,20 @@ public final class ModdedForcedDatapack {
int packCount = 0;
KList<String> presetIds = new KList<>();
Map<String, String> stagedDimensionTypes = new LinkedHashMap<>();
Path worldRoot = worldRootOrNull();
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
List<File> packRoots = compilationPackRoots(worldRoot);
for (File pack : packRoots) {
PackStageContext context = packStageContext(pack.toPath(), legacySelections);
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) {
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) {
packCount++;
}
}
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
ModdedDimensionTakeovers.writeLevelStems(stagingDirectory,
ModdedModConfig.get().dimensionTakeovers(), stagedDimensionTypes);
}
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
writePackMeta(stagingDirectory);
@@ -362,7 +369,8 @@ public final class ModdedForcedDatapack {
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException {
KList<String> presetIds,
Map<String, String> stagedDimensionTypes) throws IOException {
File sourcePack = context.packRoot().toFile();
PackValidationResult validation;
try {
@@ -392,11 +400,12 @@ 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<>();
KList<File> packFolders = new KList<>();
packFolders.add(packStagingDirectory.toFile());
boolean installed;
try {
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds);
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes);
} catch (Throwable installationFailure) {
if (context.required()) {
throw requiredPackFailure(context, "datapack serialization", installationFailure);
@@ -414,6 +423,7 @@ public final class ModdedForcedDatapack {
mergeDirectory(packStagingDirectory, stagingDirectory);
mergeBiomes(seenBiomes, packBiomes);
presetIds.addAll(packPresetIds);
stagedDimensionTypes.putAll(packDimensionTypes);
return true;
} finally {
try {
@@ -454,6 +464,8 @@ public final class ModdedForcedDatapack {
Files.createDirectories(destination);
} else if (!Files.exists(destination)) {
Files.copy(source, destination);
} else if (Files.isRegularFile(destination) && ModdedDatapackTagMerge.isTag(relative)) {
ModdedDatapackTagMerge.merge(source, destination);
} else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) {
throw new IOException("Conflicting Iris datapack output at " + destination + ".");
}
@@ -470,7 +482,8 @@ public final class ModdedForcedDatapack {
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds) throws IOException {
KList<String> presetIds,
Map<String, String> dimensionTypes) throws IOException {
File packFolder = context.packRoot().toFile();
String packName = context.packName();
File dimensionsDirectory = new File(packFolder, "dimensions");
@@ -504,18 +517,27 @@ public final class ModdedForcedDatapack {
biomesForNamespace(seenBiomes, "iris"),
context
);
writeDimensionType(
folders,
fixer,
dimension,
context.dimensionTypeRef(dimensionKey),
context.emitLegacyDimensionTypeAlias(),
context
);
String typeRef = context.dimensionTypeRef(dimensionKey);
if (dimension.hasBorrowedDimensionType()) {
typeRef = requireBorrowedType(context, dimension, typeRef);
} else {
requireScopedType(context, typeRef);
writeDimensionType(
folders,
fixer,
dimension,
typeRef,
context.emitLegacyDimensionTypeAlias(),
context
);
}
if (context.emitPresets()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
presetIds.add(presetRef);
dimensionTypes.put(packName + ":" + dimensionKey, typeRef);
if (!dimension.hasBorrowedDimensionType()) {
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
writeWorldPreset(folders, packName, dimensionKey, presetRef);
presetIds.add(presetRef);
}
}
installedDimensions++;
}
@@ -542,6 +564,22 @@ public final class ModdedForcedDatapack {
+ " (datapack source injection failed; see the Iris boot ERROR), so a restart alone will not register it.")));
}
private static void requireScopedType(PackStageContext context, String stagedTypeRef) throws IOException {
if (context.required() && ModdedWorldgenIds.scopedDimensionType(stagedTypeRef).isEmpty()) {
throw new IOException("Retained Iris dimension type key is not scoped: " + stagedTypeRef);
}
}
private static String requireBorrowedType(PackStageContext context, IrisDimension dimension, String stagedTypeRef)
throws IOException {
String borrowed = dimension.getBorrowedDimensionType().trim();
if (context.required() && !borrowed.equals(stagedTypeRef)) {
throw new IOException("Retained Iris generation pack at " + context.packRoot() + " borrows dimension type "
+ borrowed + ", but its generation history records " + stagedTypeRef + ".");
}
return borrowed;
}
private static void writeWorldPreset(KList<File> folders, String packName, String dimensionKey,
String presetRef) throws IOException {
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
@@ -789,11 +827,13 @@ public final class ModdedForcedDatapack {
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
RetainedPack retained = retainedPack(normalizedRoot);
if (retained != null) {
ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds
.scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey())
.orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: "
+ retained.epoch().dimensionContract().dimensionTypeKey()));
if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) {
GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
if (scoped.isEmpty()) {
return PackStageContext.retained(normalizedRoot, contract.dimensionKey(), retained.epoch());
}
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());
@@ -28,6 +28,7 @@ import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
final class ModdedGenerationHistoryStorage {
private ModdedGenerationHistoryStorage() {
@@ -75,7 +76,7 @@ final class ModdedGenerationHistoryStorage {
return new AdoptionSource(source, captureCandidate(
source,
selectedDimensionKey,
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
adoptionAliasPolicy(dimensionRoot)
));
},
ModdedGenerationHistoryStorage::requireRegistryDefinitions
@@ -170,6 +171,19 @@ final class ModdedGenerationHistoryStorage {
);
}
static GenerationRegistryContractFactory.CustomBiomeAliasPolicy adoptionAliasPolicy(Path dimensionRoot)
throws IOException {
Path regions = dimensionRoot.resolve("region");
if (!Files.isDirectory(regions)) {
return GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
}
try (Stream<Path> files = Files.list(regions)) {
return files.anyMatch((Path file) -> file.getFileName().toString().endsWith(".mca"))
? GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
: GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
}
}
static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot());
@@ -246,10 +260,9 @@ final class ModdedGenerationHistoryStorage {
PlatformGenerationRegistry generationRegistry = IrisPlatforms.get()
.registries()
.generationRegistry();
String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey(
source.getFileName().toString(),
dimension.getLoadKey(),
dimension.getDimensionTypeKey()
String dimensionTypeKey = dimension.dimensionTypeResourceKey(
generationRegistry,
source.getFileName().toString()
);
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
dimension,
@@ -24,9 +24,12 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
public final class ModdedModConfig {
private static final Object LOCK = new Object();
private static final String DIMENSION_TAKEOVERS = "dimensionTakeovers";
private static volatile ModdedModConfig instance;
private final String defaultPack;
@@ -35,15 +38,18 @@ public final class ModdedModConfig {
private final String mainWorldPack;
private final long mainWorldSeed;
private final boolean mainWorldAutoRestart;
private final Map<String, String> dimensionTakeovers;
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart) {
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
Map<String, String> dimensionTakeovers) {
this.defaultPack = defaultPack;
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
this.mainWorldSeed = mainWorldSeed;
this.mainWorldAutoRestart = mainWorldAutoRestart;
this.dimensionTakeovers = Map.copyOf(dimensionTakeovers);
}
public static ModdedModConfig get() {
@@ -64,7 +70,7 @@ public final class ModdedModConfig {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart);
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers);
instance = updated;
write(configFile(), updated);
}
@@ -74,12 +80,20 @@ public final class ModdedModConfig {
synchronized (LOCK) {
ModdedModConfig current = get();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart);
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers);
instance = updated;
write(configFile(), updated);
}
}
public static boolean setDimensionTakeover(ModdedDimensionTakeover takeover) {
return updateDimensionTakeover(takeover.levelKey(), takeover.packRef());
}
public static boolean clearDimensionTakeover(String levelKey) {
return updateDimensionTakeover(levelKey, null);
}
public String defaultPack() {
return defaultPack;
}
@@ -104,13 +118,33 @@ public final class ModdedModConfig {
return mainWorldAutoRestart;
}
public Map<String, String> dimensionTakeovers() {
return dimensionTakeovers;
}
private static boolean updateDimensionTakeover(String levelKey, String packRef) {
synchronized (LOCK) {
ModdedModConfig current = get();
Map<String, String> takeovers = new TreeMap<>(current.dimensionTakeovers);
String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef);
if (packRef == null ? previous == null : packRef.equals(previous)) {
return false;
}
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers);
instance = updated;
write(configFile(), updated);
return true;
}
}
private static Path configFile() {
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json");
}
private static ModdedModConfig load() {
Path file = configFile();
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false);
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of());
if (!Files.isRegularFile(file)) {
write(file, defaults);
return defaults;
@@ -123,13 +157,30 @@ public final class ModdedModConfig {
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart));
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file));
} catch (RuntimeException | IOException e) {
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
return defaults;
}
}
private static Map<String, String> readDimensionTakeovers(JSONObject json, Path file) {
if (json == null) {
return Map.of();
}
Map<String, String> takeovers = new TreeMap<>();
for (String levelKey : json.keySet()) {
try {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, ""));
takeovers.put(takeover.levelKey(), takeover.packRef());
} catch (IllegalArgumentException invalid) {
ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage());
}
}
return takeovers;
}
private static void write(Path file, ModdedModConfig config) {
JSONObject json = new JSONObject();
json.put("defaultPack", config.defaultPack);
@@ -138,6 +189,11 @@ public final class ModdedModConfig {
json.put("mainWorldPack", config.mainWorldPack);
json.put("mainWorldSeed", config.mainWorldSeed);
json.put("mainWorldAutoRestart", config.mainWorldAutoRestart);
JSONObject takeovers = new JSONObject();
for (Map.Entry<String, String> entry : new TreeMap<>(config.dimensionTakeovers).entrySet()) {
takeovers.put(entry.getKey(), entry.getValue());
}
json.put(DIMENSION_TAKEOVERS, takeovers);
try {
Files.createDirectories(file.getParent());
Files.writeString(file, json.toString(4), StandardCharsets.UTF_8);
@@ -0,0 +1,143 @@
/*
* 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.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
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.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
public final class ModdedTakeoverReconciler {
private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json";
private static final String TAKEOVERS = "takeovers";
private ModdedTakeoverReconciler() {
}
public static void reconcileEarly() {
try {
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
ModdedIrisLog.info("Iris dimension takeovers {} apply to a new world; nothing to quarantine",
configured.keySet());
}
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Result result = reconcile(worldRoot, configured, recoveryParent);
if (result.changedLevels().isEmpty()) {
return;
}
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.changedLevels(),
result.recovery() == null
? "no existing chunk data to quarantine"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
+ " so they regenerate (player data kept)");
} catch (Throwable failure) {
ModdedIrisLog.error("Iris dimension takeover reconciliation failed", failure);
throw new IllegalStateException("Iris refused startup after dimension takeover reconciliation failed", failure);
}
}
public static void recordApplied(NativeModdedServer server) {
Optional<Path> worldRoot = NativeWorldPaths.root(server);
if (worldRoot.isEmpty()) {
return;
}
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
try {
if (readApplied(worldRoot.get()).equals(configured)) {
return;
}
writeApplied(worldRoot.get(), configured);
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not record the applied dimension takeovers under {}", worldRoot.get(), failure);
}
}
static Result reconcile(Path worldRoot, Map<String, String> configured, Path recoveryParent) throws IOException {
Map<String, String> applied = readApplied(worldRoot);
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
if (changed.isEmpty()) {
return new Result(changed, null);
}
List<Path> sources = new ArrayList<>();
for (String level : changed) {
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);
writeApplied(worldRoot, configured);
return new Result(changed, moved.isEmpty() ? null : recovery);
}
static Map<String, String> readApplied(Path worldRoot) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.isRegularFile(state)) {
return Map.of();
}
JSONObject takeovers = new JSONObject(Files.readString(state, StandardCharsets.UTF_8)).optJSONObject(TAKEOVERS);
if (takeovers == null) {
return Map.of();
}
Map<String, String> applied = new TreeMap<>();
for (String level : takeovers.keySet()) {
applied.put(level, takeovers.getString(level));
}
return Map.copyOf(applied);
}
static void writeApplied(Path worldRoot, Map<String, String> takeovers) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
Files.createDirectories(state.getParent());
JSONObject entries = new JSONObject();
for (Map.Entry<String, String> entry : new TreeMap<>(takeovers).entrySet()) {
entries.put(entry.getKey(), entry.getValue());
}
JSONObject json = new JSONObject();
json.put(TAKEOVERS, entries);
Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID());
Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8);
try {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING);
}
}
record Result(Set<String> changedLevels, Path recovery) {
}
}
@@ -391,6 +391,15 @@ public final class ModdedWorldEngines {
(actualType.maximumHeight() - actualType.minimumHeight()),
actualType.logicalHeight());
String runtimeName = "Modded level '" + level.name() + "'";
if (dimension.hasBorrowedDimensionType()) {
if (!recorded.dimensionTypeKey().equals(dimension.getBorrowedDimensionType().trim())) {
throw new IllegalStateException("Immutable Iris dimension contract for '" + level.name()
+ "' records dimension type " + recorded.dimensionTypeKey() + ", but its pack borrows "
+ dimension.getBorrowedDimensionType().trim() + ".");
}
expected.requireBorrowedFit(runtimeName, actual);
return;
}
expected.requireExact(runtimeName, actual);
expected.requireHeight(runtimeName, level.minHeight(), (level.maxHeight() - level.minHeight()));
}
@@ -0,0 +1,73 @@
/*
* 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 java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
final class ModdedWorldQuarantine {
private ModdedWorldQuarantine() {
}
static List<Path> move(Path worldRoot, List<Path> sources, Path recovery) throws IOException {
List<Path> moved = new ArrayList<>(sources.size());
try {
for (Path source : sources) {
moveOne(worldRoot, source, recovery, moved);
}
} catch (IOException failure) {
rollback(worldRoot, recovery, moved, failure);
throw failure;
}
if (moved.isEmpty()) {
Files.deleteIfExists(recovery);
}
return List.copyOf(moved);
}
private static void moveOne(Path worldRoot, Path source, Path recovery, List<Path> moved) throws IOException {
if (!Files.exists(source)) {
return;
}
Path relative = worldRoot.relativize(source);
if (relative.isAbsolute() || relative.startsWith("..")) {
throw new IOException("Iris refuses to quarantine " + source + " outside the world " + worldRoot);
}
Path target = recovery.resolve(relative);
Files.createDirectories(target.getParent());
Files.move(source, target);
moved.add(relative);
}
private static void rollback(Path worldRoot, Path recovery, List<Path> moved, IOException failure) {
for (int index = moved.size() - 1; index >= 0; index--) {
Path relative = moved.get(index);
Path rollbackTarget = worldRoot.resolve(relative);
try {
Files.createDirectories(rollbackTarget.getParent());
Files.move(recovery.resolve(relative), rollbackTarget);
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
@@ -22,6 +23,12 @@ public final class ModdedWorldgenIds {
return NAMESPACE + ":" + scopedPath(pack, dimension) + "/dimension_type";
}
public static String dimensionTypeRef(String pack, IrisDimension dimension) {
return dimension.hasBorrowedDimensionType()
? dimension.getBorrowedDimensionType().trim()
: dimensionTypeRef(pack, dimension.getLoadKey());
}
public static String biomePathPrefix(String pack, String dimension) {
return scopedPath(pack, dimension) + "/biomes";
}
@@ -22,6 +22,7 @@ package art.arcane.iris.modded;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection.Dimension;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
@@ -34,6 +35,9 @@ final class WorldCheckDimensionContract {
static boolean checkDimensionType(NativeWorld level, IrisModdedChunkGenerator generator) {
try {
IrisDimension dimension = generator.commandEngine().getDimension();
if (dimension.hasBorrowedDimensionType()) {
return checkBorrowedDimensionType(level, dimension);
}
Dimension expected = expectedDimensionContract(dimension);
Dimension actual = new NativeWorldInspection(level).dimension();
boolean pass = matchesDimensionContract(level.minHeight(), (level.maxHeight() - level.minHeight()), expected, actual);
@@ -54,6 +58,24 @@ final class WorldCheckDimensionContract {
}
}
private static boolean checkBorrowedDimensionType(NativeWorld level, IrisDimension dimension) {
String borrowed = dimension.getBorrowedDimensionType().trim();
String actualKey = NativeWorldDimensions.settings(level).key();
boolean pass = borrowed.equals(actualKey)
&& dimension.getMinHeight() == level.minHeight()
&& dimension.getMaxHeight() <= level.maxHeight();
String detail = "borrowed=" + borrowed + ",actual=" + actualKey
+ ",packRange=" + dimension.getMinHeight() + ".." + dimension.getMaxHeight()
+ ",levelRange=" + level.minHeight() + ".." + level.maxHeight();
WorldCheckPredicates.qaEvent("dimension_type", dimension.getLoadKey(), pass, detail);
if (!pass) {
ModdedIrisLog.error("[worldcheck] borrowed dimension type mismatch for {}: {}", dimension.getLoadKey(), detail);
} else {
ModdedIrisLog.info("[worldcheck] borrowed dimension type contract: {}", detail);
}
return pass;
}
static Dimension expectedDimensionContract(IrisDimension dimension) {
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
return new Dimension(
@@ -175,6 +175,7 @@ final class ModdedCommandHelp {
Entry.command("enable", "<dimension> <pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, "create"),
Entry.command("replace-overworld", "<pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE),
Entry.command("mainworld", "<pack|pack:dimensionKey|off> [seed|random]", ModdedHelpMessages.COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET),
Entry.command("takeover", "<dimension> <pack|pack:dimensionKey|off>", ModdedHelpMessages.COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK),
Entry.command("disable", "<dimension>", ModdedHelpMessages.COMMAND_DISABLE_EVACUATE_AND_UNLOAD_AN_IRIS_DIMENSION_WORLD_DATA_ON_DISK_IS_KEPT),
Entry.command("delete", "<dimension>", ModdedHelpMessages.COMMAND_DELETE_DISABLE_AN_IRIS_DIMENSION_AND_WIPE_ITS_CHUNK_AND_MANTLE_DATA_FROM, "remove", "rm"),
Entry.command("list", "", ModdedHelpMessages.COMMAND_LIST_LIST_LOADED_IRIS_DIMENSIONS, "ls"),
@@ -163,6 +163,9 @@ public final class ModdedDatapackCommands {
continue;
}
IrisDimension dimension = engine.getDimension();
if (dimension.hasBorrowedDimensionType()) {
continue;
}
String json;
try {
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
@@ -26,6 +26,8 @@ import art.arcane.iris.generation.terrain.IrisDimension;
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.ModdedForcedDatapack;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedModConfig;
@@ -59,6 +61,7 @@ public final class ModdedWorldCommands {
private static final String DEFAULT_NAMESPACE = "irisworldgen";
private static final long DEFAULT_SEED = 1337L;
private static final SuggestionProvider<NativeCommandSource> LOADED_DIMENSIONS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(loadedIrisDimensions(context.getSource().server()), builder);
private static final SuggestionProvider<NativeCommandSource> ALL_DIMENSIONS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(allDimensions(context.getSource().server()), builder);
private ModdedWorldCommands() {
}
@@ -80,6 +83,7 @@ public final class ModdedWorldCommands {
root.then(updateTree());
root.then(replaceOverworldTree());
root.then(mainWorldTree());
root.then(takeoverTree());
root.then(disableTree());
root.then(deleteTree("delete"));
@@ -156,6 +160,18 @@ public final class ModdedWorldCommands {
StringArgumentType.getString(context, "seed"))))));
}
private static LiteralArgumentBuilder<NativeCommandSource> takeoverTree() {
return NativeCommandRegistration.literal("takeover")
.then(NativeCommandRegistration.argument("dimension", NativeCommandArguments.identifier()).suggests(ALL_DIMENSIONS)
.then(NativeCommandRegistration.literal("off")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> clearTakeover(context.getSource(),
dimensionArgument(context)))))
.then(NativeCommandRegistration.argument("pack", StringArgumentType.greedyString()).suggests(IrisModdedCommands.PACK_NAMES)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> takeover(context.getSource(),
dimensionArgument(context),
StringArgumentType.getString(context, "pack"))))));
}
public static int createWorld(NativeCommandSource source, String name, String pack, long seed) {
String[] packRef = parsePackRef(pack);
return enable(source, name, packRef[0], packRef[1], seed);
@@ -286,6 +302,54 @@ public final class ModdedWorldCommands {
return 1;
}
private static int takeover(NativeCommandSource source, String targetDimension, String packRaw) {
ModdedDimensionTakeover takeover;
try {
takeover = ModdedDimensionTakeover.parse(normalizeDimensionId(targetDimension), packRaw);
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, invalid.getMessage());
return 0;
}
String pack = takeover.pack();
if (!new File(ModdedPackCommands.packsRoot(), pack).isDirectory()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART,
MessageArgument.untrusted("pack", pack)));
return 0;
}
if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) {
return 0;
}
ModdedModConfig.setDimensionTakeover(takeover);
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + takeover.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())));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
return 1;
}
private static int clearTakeover(NativeCommandSource source, String targetDimension) {
String dimensionId;
try {
dimensionId = normalizeDimensionId(targetDimension);
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, invalid.getMessage());
return 0;
}
if (!ModdedModConfig.clearDimensionTakeover(dimensionId)) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
MessageArgument.untrusted("dimensionId", dimensionId)));
return 0;
}
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + dimensionId + " turned off");
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
MessageArgument.untrusted("dimensionId", dimensionId)));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
return 1;
}
private static int clearMainWorld(NativeCommandSource source) {
ModdedModConfig.setMainWorld("", 0L);
MainWorldService.clearOverride();
@@ -496,6 +560,15 @@ public final class ModdedWorldCommands {
return 1;
}
private static List<String> allDimensions(NativeModdedServer server) {
List<String> dimensions = new ArrayList<>();
for (NativeWorld level : server.worlds()) {
dimensions.add(level.name());
}
dimensions.addAll(ModdedModConfig.get().dimensionTakeovers().keySet());
return dimensions.stream().distinct().sorted().toList();
}
private static List<String> loadedIrisDimensions(NativeModdedServer server) {
List<String> dimensions = new ArrayList<>();
for (NativeWorld level : server.worlds()) {
@@ -1152,6 +1152,22 @@ public final class ModdedCommandMessages {
"iris.modded.moddedworldcommands.iris_main_world_override_cleared_overworld_keeps_its_current_generator",
"Iris main world override cleared. The overworld keeps its current generator; edit server.properties level-type and restart to change it back."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_SET = TextKey.of(
"iris.modded.moddedworldcommands.takeover_set",
"Iris takeover of " + "{dimensionId}" + " set to pack '" + "{pack}" + "', dimension '" + "{packDimension}" + "'."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_cleared",
"Iris takeover of " + "{dimensionId}" + " turned off; the dimension's own generator returns on the next restart."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_not_configured",
"No Iris takeover is configured for " + "{dimensionId}" + "."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_restart_required",
"Restart required: on the next start the dimension's chunk, entity, POI, and Iris Mantle data move to a recovery folder and regenerate. Player data is kept."
);
public static final TextKey MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM = TextKey.of(
"iris.modded.moddedworldcommands.invalid_seed_use_number_random",
"Invalid seed '" + "{seedRaw}" + "'. Use a number or 'random'."
@@ -1548,6 +1564,10 @@ public final class ModdedCommandMessages {
MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES,
MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE,
MODDED_WORLD_COMMANDS_IRIS_MAIN_WORLD_OVERRIDE_CLEARED_OVERWORLD_KEEPS_ITS_CURRENT_GENERATOR,
MODDED_WORLD_COMMANDS_TAKEOVER_SET,
MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED,
MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM,
MODDED_WORLD_COMMANDS_PACK_IS_NOT_READY_YET_STILL_LOADING_VALIDATING_TRY_COMMAND,
MODDED_WORLD_COMMANDS_FAILED_WRITE_SERVER_PROPERTIES_CHECK_FILE_PERMISSIONS_SET_LEVEL_TYPE,
@@ -390,6 +390,10 @@ public final class ModdedHelpMessages {
"iris.modded.help.entry.command.mainworld",
"Configure or clear the Iris primary-world preset used on restart"
);
public static final TextKey COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK = TextKey.of(
"iris.modded.help.entry.command.takeover",
"Take over an existing dimension with an Iris pack on the next restart, or turn it off"
);
public static final TextKey COMMAND_OBJECT_WE_BUKKIT_ONLY = TextKey.of(
"iris.modded.help.entry.command.object.we",
"Explain why WorldEdit selection import requires the Bukkit plugin"
@@ -516,6 +520,7 @@ public final class ModdedHelpMessages {
COMMAND_HEIGHT_PRINT_WORLD_HEIGHT,
COMMAND_WORLDS_LIST_WORLD_ACCESS,
COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET,
COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK,
COMMAND_OBJECT_WE_BUKKIT_ONLY,
COMMAND_OBJECT_STUDIO_BUKKIT_ONLY,
COMMAND_OBJECT_CONVERT_BUKKIT_ONLY,
@@ -0,0 +1,133 @@
/*
* 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.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedDimensionTakeoversTest {
@Test
public void parsesPackAndPackDimensionReferences() {
ModdedDimensionTakeover samePack = ModdedDimensionTakeover.parse("iristest:skylands", "skypack");
ModdedDimensionTakeover scoped = ModdedDimensionTakeover.parse("aether:the_aether", "overworld:floating/sky");
assertEquals("skypack", samePack.pack());
assertEquals("skypack", samePack.packDimension());
assertEquals("skypack", samePack.packRef());
assertEquals("overworld", scoped.pack());
assertEquals("floating/sky", scoped.packDimension());
assertEquals("overworld:floating/sky", scoped.packRef());
assertEquals("aether", scoped.namespace());
assertEquals("the_aether", scoped.path());
}
@Test
public void rejectsInvalidLevelKeysAndPackReferences() {
for (String levelKey : List.of("skylands", "Iristest:skylands", "iristest:", ":skylands", "iristest:sky/../x", "iristest:/sky")) {
assertThrows(levelKey, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse(levelKey, "overworld"));
}
for (String packRef : List.of("", " ", "../overworld", "overworld:", "over world", "overworld:a/../b")) {
assertThrows(packRef, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse("iristest:skylands", packRef));
}
}
@Test
public void levelStoragePathFollowsTheDimensionsLayout() {
Path worldRoot = Path.of("/srv/world");
assertEquals(worldRoot.resolve("dimensions").resolve("iristest").resolve("skylands"),
ModdedDimensionTakeover.parse("iristest:skylands", "overworld").storageFolder(worldRoot));
assertEquals(worldRoot.resolve("dimensions").resolve("aether").resolve("realm").resolve("upper"),
ModdedDimensionTakeover.parse("aether:realm/upper", "overworld").storageFolder(worldRoot));
}
@Test
public void levelStemUsesTheIrisGeneratorWithThePresetBiomeSourcePlaceholder() {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("iristest:skylands", "overworld:sky");
JSONObject stem = new JSONObject(ModdedDimensionTakeovers.levelStemJson("iristest:skylands", takeover));
assertEquals("iristest:skylands", stem.getString("type"));
JSONObject generator = stem.getJSONObject("generator");
assertEquals("irisworldgen:iris", generator.getString("type"));
assertEquals("overworld:sky", generator.getString("dimension"));
assertEquals("minecraft:fixed", generator.getJSONObject("biome_source").getString("type"));
assertEquals("minecraft:plains", generator.getJSONObject("biome_source").getString("biome"));
}
@Test
public void levelStemIsWrittenAtTheDatapackDimensionPathOfTheLevelKey() throws IOException {
Path datapack = Files.createTempDirectory("iris-takeover-stem");
try {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("aether:the_aether", "overworld");
Path written = ModdedDimensionTakeovers.writeLevelStem(datapack, takeover, "aether:the_aether");
assertEquals(datapack.resolve("data").resolve("aether").resolve("dimension").resolve("the_aether.json"), written);
assertEquals("aether:the_aether", new JSONObject(Files.readString(written)).getString("type"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(datapack);
}
}
@Test
public void changedLevelsCoverAddedChangedAndRemovedTakeovers() {
Map<String, String> applied = Map.of(
"iristest:kept", "overworld",
"iristest:changed", "overworld",
"iristest:removed", "overworld");
Map<String, String> configured = Map.of(
"iristest:kept", "overworld",
"iristest:changed", "overworld:sky",
"iristest:added", "overworld");
assertEquals(Set.of("iristest:changed", "iristest:removed", "iristest:added"),
ModdedDimensionTakeovers.changedLevels(applied, configured));
assertEquals(Set.of(), ModdedDimensionTakeovers.changedLevels(configured, configured));
}
@Test
public void fingerprintIsOrderIndependentAndTracksEveryEntry() {
Map<String, String> forward = new LinkedHashMap<>();
forward.put("iristest:a", "overworld");
forward.put("iristest:b", "overworld:sky");
Map<String, String> reversed = new LinkedHashMap<>();
reversed.put("iristest:b", "overworld:sky");
reversed.put("iristest:a", "overworld");
assertEquals(ModdedDimensionTakeovers.fingerprint(forward), ModdedDimensionTakeovers.fingerprint(reversed));
assertNotEquals(ModdedDimensionTakeovers.fingerprint(forward),
ModdedDimensionTakeovers.fingerprint(Map.of("iristest:a", "overworld")));
assertEquals("", ModdedDimensionTakeovers.fingerprint(Map.of()));
assertTrue(ModdedDimensionTakeovers.fingerprint(forward).contains("iristest:b=overworld:sky"));
}
}
@@ -260,6 +260,61 @@ public class ModdedForcedDatapackTest {
}
}
@Test
public void mergesTagFilesFromDifferentPacksIntoTheUnionOfTheirValues() throws IOException {
Path root = Files.createTempDirectory("iris-forced-pack-tag-merge");
try {
Path tag = Path.of("data/minecraft/tags/worldgen/biome/water_on_map_outlines.json");
Path source = root.resolve("source");
Path destination = root.resolve("destination");
Files.createDirectories(source.resolve(tag).getParent());
Files.createDirectories(destination.resolve(tag).getParent());
Files.writeString(source.resolve(tag),
"{\"replace\":false,\"values\":[\"iris:biomes/b\",\"iris:biomes/c\",{\"id\":\"mod:river\",\"required\":false}]}",
StandardCharsets.UTF_8);
Files.writeString(destination.resolve(tag),
"{\"replace\":false,\"values\":[\"iris:biomes/a\",\"iris:biomes/b\"]}",
StandardCharsets.UTF_8);
ModdedForcedDatapack.mergeDirectory(source, destination);
JSONObject merged = new JSONObject(Files.readString(destination.resolve(tag), StandardCharsets.UTF_8));
assertFalse(merged.getBoolean("replace"));
List<String> values = new ArrayList<>();
for (int index = 0; index < merged.getJSONArray("values").length(); index++) {
Object value = merged.getJSONArray("values").get(index);
values.add(value instanceof JSONObject entry ? entry.getString("id") : value.toString());
}
assertEquals(List.of("iris:biomes/a", "iris:biomes/b", "iris:biomes/c", "mod:river"), values);
} finally {
deleteTree(root);
}
}
@Test
public void tagMergeKeepsStrictConflictsForNonTagFilesInTheSameMerge() throws IOException {
Path root = Files.createTempDirectory("iris-forced-pack-tag-strict");
try {
Path tag = Path.of("data/minecraft/tags/worldgen/biome/is_overworld.json");
Path biome = Path.of("data/iris/worldgen/biome/shared.json");
for (String side : List.of("source", "destination")) {
Files.createDirectories(root.resolve(side).resolve(tag).getParent());
Files.createDirectories(root.resolve(side).resolve(biome).getParent());
Files.writeString(root.resolve(side).resolve(tag),
"{\"replace\":false,\"values\":[\"iris:biomes/" + side + "\"]}", StandardCharsets.UTF_8);
}
Files.writeString(root.resolve("source").resolve(biome), "{\"temperature\":1}", StandardCharsets.UTF_8);
Files.writeString(root.resolve("destination").resolve(biome), "{\"temperature\":2}", StandardCharsets.UTF_8);
assertThrows(
IOException.class,
() -> ModdedForcedDatapack.mergeDirectory(root.resolve("source"), root.resolve("destination"))
);
} finally {
deleteTree(root);
}
}
private List<String> directoryEntries(Path root) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.map((Path path) -> path.getFileName().toString())
@@ -5,6 +5,7 @@ import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.history.GenerationRegistryContract;
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import org.junit.Test;
import java.io.IOException;
@@ -28,6 +29,28 @@ public class ModdedGenerationHistoryStorageTest {
private static final String DEFINITION_FINGERPRINT = "22".repeat(32);
private static final int TRANSITION_WIDTH_BLOCKS = 256;
@Test
public void adoptionRetainsLegacyBiomeAliasesOnlyWhenTheLevelAlreadyHasChunks() throws IOException {
Path root = Files.createTempDirectory("iris-modded-history-alias-policy");
try {
Path fresh = root.resolve("fresh");
Path emptyRegion = root.resolve("empty-region");
Files.createDirectories(emptyRegion.resolve("region"));
Path legacy = root.resolve("legacy");
Files.createDirectories(legacy.resolve("region"));
Files.writeString(legacy.resolve("region").resolve("r.0.0.mca"), "chunk", StandardCharsets.UTF_8);
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY,
ModdedGenerationHistoryStorage.adoptionAliasPolicy(fresh));
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY,
ModdedGenerationHistoryStorage.adoptionAliasPolicy(emptyRegion));
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES,
ModdedGenerationHistoryStorage.adoptionAliasPolicy(legacy));
} finally {
deleteTree(root);
}
}
@Test
public void newDimensionPublishesAnImmutableWorldLocalPack() throws IOException {
Path root = Files.createTempDirectory("iris-modded-history-create");
@@ -0,0 +1,148 @@
/*
* 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.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.Map;
import java.util.Set;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class ModdedTakeoverReconcilerTest {
@Test
public void appliedStateRoundTripsAndDefaultsToEmpty() throws IOException {
Path worldRoot = Files.createTempDirectory("iris-takeover-state");
try {
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld:sky"));
assertEquals(Map.of("iristest:skylands", "overworld:sky"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(worldRoot);
}
}
@Test
public void newTakeoverQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-reconcile");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) {
touch(skylands.resolve(folder).resolve("payload.bin"));
}
touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"));
touch(worldRoot.resolve("players/data/player.dat"));
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
assertEquals(Set.of("iristest:skylands"), result.changedLevels());
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
assertFalse(folder, Files.exists(skylands.resolve(folder)));
assertTrue(folder, Files.isRegularFile(result.recovery()
.resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin")));
}
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));
} finally {
deleteTree(temporary);
}
}
@Test
public void unchangedTakeoverIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-cycle");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
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 unchanged = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
assertEquals(Set.of(), unchanged.changedLevels());
assertNull(unchanged.recovery());
assertTrue(Files.isRegularFile(region));
ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), removed.changedLevels());
assertFalse(Files.exists(region));
assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca")));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void changeWithoutChunkDataRecordsStateWithoutARecoveryFolder() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-empty");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Files.createDirectories(worldRoot);
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent);
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));
} finally {
deleteTree(temporary);
}
}
private static void touch(Path file) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, "x", StandardCharsets.UTF_8);
}
static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.terrain.IrisDimension;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
@@ -19,6 +20,19 @@ public class ModdedWorldgenIdsTest {
ModdedWorldgenIds.dimensionTypeRef("Pack/A-_", "É山")).orElseThrow());
}
@Test
public void borrowedDimensionTypeReplacesTheScopedGeneratedType() {
IrisDimension generated = new IrisDimension();
generated.setLoadKey("sky");
IrisDimension borrowed = new IrisDimension().setBorrowedDimensionType("iristest:skylands");
borrowed.setLoadKey("sky");
assertEquals(ModdedWorldgenIds.dimensionTypeRef("overworld", "sky"),
ModdedWorldgenIds.dimensionTypeRef("overworld", generated));
assertEquals("iristest:skylands", ModdedWorldgenIds.dimensionTypeRef("overworld", borrowed));
assertTrue(ModdedWorldgenIds.scopedDimensionType("iristest:skylands").isEmpty());
}
@Test
public void malformedIdentifiersRetainRejectionBehavior() {
assertTrue(ModdedWorldgenIds.scopedDimensionType(
@@ -173,6 +173,7 @@ public class IrisModdedCommandParityTest {
assertTrue(ModdedCommandHelp.documents("studio", "pkg"));
assertTrue(ModdedCommandHelp.documents("object", "we"));
assertTrue(ModdedCommandHelp.documents("world", "mainworld"));
assertTrue(ModdedCommandHelp.documents("world", "takeover"));
assertTrue(ModdedCommandHelp.documents("goto", "unregistered"));
assertTrue(ModdedCommandHelp.documents("pack", "compat"));
assertTrue(ModdedCommandHelp.documents("pack", "cp"));