Preserved merged tag order

- Merged datapack tags keep first-seen value order with duplicates removed instead of re-sorting, so world preset tag order is preserved
- NeoForge remove lists are kept as the deduplicated union of both tags instead of being dropped
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 21:00:02 -04:00
1 parent 038833e614
commit 56ba2e51b7
2 files changed
+80 -16

No files matched your search

@@ -25,12 +25,13 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
final class ModdedDatapackTagMerge {
private static final String REPLACE = "replace";
private static final String VALUES = "values";
private static final String REMOVE = "remove";
private ModdedDatapackTagMerge() {
}
@@ -45,19 +46,27 @@ final class ModdedDatapackTagMerge {
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);
tag.put(VALUES, union(existing, incoming, VALUES, source, destination));
if (existing.has(REMOVE) || incoming.has(REMOVE)) {
tag.put(REMOVE, union(existing, incoming, REMOVE, source, destination));
}
Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8);
}
private static JSONArray union(JSONObject existing, JSONObject incoming, String field, Path source, Path destination)
throws IOException {
Map<String, Object> entries = new LinkedHashMap<>();
collect(existing, field, entries, destination);
collect(incoming, field, entries, source);
JSONArray merged = new JSONArray();
for (Object entry : entries.values()) {
merged.put(entry);
}
return merged;
}
private static JSONObject read(Path tag) throws IOException {
try {
return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8));
@@ -66,18 +75,18 @@ final class ModdedDatapackTagMerge {
}
}
private static void collect(JSONObject tag, Map<String, Object> values, Path origin) throws IOException {
JSONArray entries = tag.optJSONArray(VALUES);
if (entries == null) {
private static void collect(JSONObject tag, String field, Map<String, Object> entries, Path origin) throws IOException {
JSONArray values = tag.optJSONArray(field);
if (values == null) {
return;
}
for (int index = 0; index < entries.length(); index++) {
Object entry = entries.get(index);
for (int index = 0; index < values.length(); index++) {
Object entry = values.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.");
throw new IOException("Iris cannot merge datapack tag " + origin + ": " + field + " entry " + index + " has no id.");
}
values.putIfAbsent(key, entry);
entries.putIfAbsent(key, entry);
}
}
}
@@ -19,6 +19,7 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.pack.datapack.v1217.DataFixerV1217;
@@ -291,6 +292,60 @@ public class ModdedForcedDatapackTest {
}
}
@Test
public void tagMergeKeepsFirstSeenOrderAndUnionsRemoveLists() throws IOException {
Path root = Files.createTempDirectory("iris-forced-pack-tag-order");
try {
Path tag = Path.of("data/minecraft/tags/worldgen/world_preset/normal.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(destination.resolve(tag),
"{\"replace\":false,\"values\":[\"irisworldgen:z\",\"irisworldgen:a\"],\"remove\":[\"minecraft:large_biomes\"]}",
StandardCharsets.UTF_8);
Files.writeString(source.resolve(tag),
"{\"replace\":false,\"values\":[\"irisworldgen:m\",\"irisworldgen:a\"],\"remove\":[\"minecraft:amplified\",\"minecraft:large_biomes\"]}",
StandardCharsets.UTF_8);
ModdedForcedDatapack.mergeDirectory(source, destination);
JSONObject merged = new JSONObject(Files.readString(destination.resolve(tag), StandardCharsets.UTF_8));
assertEquals(List.of("irisworldgen:z", "irisworldgen:a", "irisworldgen:m"), strings(merged.getJSONArray("values")));
assertEquals(List.of("minecraft:large_biomes", "minecraft:amplified"), strings(merged.getJSONArray("remove")));
} finally {
deleteTree(root);
}
}
@Test
public void tagMergeWithoutRemoveListsWritesNone() throws IOException {
Path root = Files.createTempDirectory("iris-forced-pack-tag-no-remove");
try {
Path tag = Path.of("data/minecraft/tags/worldgen/biome/is_overworld.json");
for (String side : List.of("source", "destination")) {
Files.createDirectories(root.resolve(side).resolve(tag).getParent());
Files.writeString(root.resolve(side).resolve(tag),
"{\"replace\":false,\"values\":[\"iris:biomes/" + side + "\"]}", StandardCharsets.UTF_8);
}
ModdedForcedDatapack.mergeDirectory(root.resolve("source"), root.resolve("destination"));
JSONObject merged = new JSONObject(Files.readString(root.resolve("destination").resolve(tag), StandardCharsets.UTF_8));
assertFalse(merged.has("remove"));
} finally {
deleteTree(root);
}
}
private static List<String> strings(JSONArray array) {
List<String> values = new ArrayList<>();
for (int index = 0; index < array.length(); index++) {
values.add(array.get(index).toString());
}
return values;
}
@Test
public void tagMergeKeepsStrictConflictsForNonTagFilesInTheSameMerge() throws IOException {
Path root = Files.createTempDirectory("iris-forced-pack-tag-strict");