diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java
new file mode 100644
index 000000000..241efeb91
--- /dev/null
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDatapackTagMerge.java
@@ -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 .
+ */
+
+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 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 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);
+ }
+ }
+}
diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
index 194cc2b8d..c90c15a32 100644
--- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
+++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java
@@ -464,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 + ".");
}
diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
index 8a016e11a..58697dc83 100644
--- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
+++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java
@@ -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 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 directoryEntries(Path root) throws IOException {
try (Stream entries = Files.list(root)) {
return entries.map((Path path) -> path.getFileName().toString())