From 31958aacd3b57b79b54270ada4408350f6ebbef4 Mon Sep 17 00:00:00 2001
From: Space Walker <spacedoesrs@gmail.com>
Date: Wed, 11 Sep 2024 18:49:10 +0200
Subject: [PATCH 1/3] only complete official namespaces for unobfuscated
 members

---
 .../mappings/tiny/MappingsMerger.java         |   5 +-
 .../tiny/UnobfuscatedMappingNsCompleter.java  | 231 ++++++++++++++++++
 2 files changed, 233 insertions(+), 3 deletions(-)
 create mode 100644 src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/UnobfuscatedMappingNsCompleter.java

diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
index e2f6ba67d..07207e620 100644
--- a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
+++ b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
@@ -40,7 +40,6 @@
 import net.fabricmc.loom.api.mappings.layered.MappingsNamespace;
 import net.fabricmc.loom.configuration.providers.mappings.IntermediateMappingsService;
 import net.fabricmc.loom.configuration.providers.minecraft.MinecraftProvider;
-import net.fabricmc.mappingio.adapter.MappingNsCompleter;
 import net.fabricmc.mappingio.adapter.MappingSourceNsSwitch;
 import net.fabricmc.mappingio.format.tiny.Tiny2FileReader;
 import net.fabricmc.mappingio.format.tiny.Tiny2FileWriter;
@@ -72,7 +71,7 @@ private static void mergeAndSaveMappings(Path from, Path out, IntermediateMappin
 		}
 
 		MemoryMappingTree officialTree = new MemoryMappingTree();
-		MappingNsCompleter nsCompleter = new MappingNsCompleter(officialTree, Map.of(MappingsNamespace.OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString()));
+		UnobfuscatedMappingNsCompleter nsCompleter = new UnobfuscatedMappingNsCompleter(officialTree, MappingsNamespace.NAMED.toString(), Map.of(MappingsNamespace.OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString()));
 		MappingSourceNsSwitch nsSwitch = new MappingSourceNsSwitch(nsCompleter, MappingsNamespace.OFFICIAL.toString());
 		intermediaryTree.accept(nsSwitch);
 
@@ -92,7 +91,7 @@ private static void legacyMergeAndSaveMappings(Path from, Path out, Intermediate
 		}
 
 		MemoryMappingTree officialTree = new MemoryMappingTree();
-		MappingNsCompleter nsCompleter = new MappingNsCompleter(officialTree, Map.of(MappingsNamespace.CLIENT_OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString(), MappingsNamespace.SERVER_OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString()));
+		UnobfuscatedMappingNsCompleter nsCompleter = new UnobfuscatedMappingNsCompleter(officialTree, MappingsNamespace.NAMED.toString(), Map.of(MappingsNamespace.CLIENT_OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString(), MappingsNamespace.SERVER_OFFICIAL.toString(), MappingsNamespace.INTERMEDIARY.toString()));
 		intermediaryTree.accept(nsCompleter);
 
 		// versions this old strip inner class attributes
diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/UnobfuscatedMappingNsCompleter.java b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/UnobfuscatedMappingNsCompleter.java
new file mode 100644
index 000000000..f51133fc5
--- /dev/null
+++ b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/UnobfuscatedMappingNsCompleter.java
@@ -0,0 +1,231 @@
+/*
+ * This file is part of fabric-loom, licensed under the MIT License (MIT).
+ *
+ * Copyright (c) 2024 FabricMC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package net.fabricmc.loom.configuration.providers.mappings.tiny;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.jetbrains.annotations.Nullable;
+
+import net.fabricmc.mappingio.MappedElementKind;
+import net.fabricmc.mappingio.MappingVisitor;
+import net.fabricmc.mappingio.adapter.ForwardingMappingVisitor;
+
+/**
+ * Adapted from {@link net.fabricmc.mappingio.adapter.MappingNsCompleter}.
+ * This visitor completes any empty namespace with some alternative namespace
+ * only if that alternative namespace is equal to some test namespace.
+ * Mostly this will be used to complete official namespaces with intermediary
+ * names only if those intermediary names are equal to the named names.
+ */
+public final class UnobfuscatedMappingNsCompleter extends ForwardingMappingVisitor {
+	private final String testNs;
+	private final Map<String, String> alternatives;
+	private int testNsId;
+	private int[] alternativesMapping;
+
+	private String srcName;
+	private String[] dstNames;
+	private boolean[] unobf;
+	private boolean[] lastMethodUnobf;
+
+	private boolean relayHeaderOrMetadata;
+
+	public UnobfuscatedMappingNsCompleter(MappingVisitor next, String testNs, Map<String, String> alternatives) {
+		super(next);
+
+		this.testNs = testNs;
+		this.alternatives = alternatives;
+	}
+
+	@Override
+	public boolean visitHeader() throws IOException {
+		relayHeaderOrMetadata = next.visitHeader();
+
+		return true;
+	}
+
+	@Override
+	public void visitNamespaces(String srcNamespace, List<String> dstNamespaces) throws IOException {
+		int count = dstNamespaces.size();
+		testNsId = -1;
+		alternativesMapping = new int[count];
+		dstNames = new String[count];
+		unobf = new boolean[count + 1]; // contains src ns as well
+		lastMethodUnobf = new boolean[count + 1]; // contains src ns as well
+
+		for (int i = 0; i < count; i++) {
+			String dst = dstNamespaces.get(i);
+
+			if (testNs.equals(dst)) {
+				testNsId = i;
+			}
+
+			String src = alternatives.get(dst);
+			int srcIdx;
+
+			if (src == null) {
+				srcIdx = i;
+			} else if (src.equals(srcNamespace)) {
+				srcIdx = -1;
+			} else {
+				srcIdx = dstNamespaces.indexOf(src);
+				if (srcIdx < 0) throw new RuntimeException("invalid alternative mapping ns "+src+": not in "+dstNamespaces+" or "+srcNamespace);
+			}
+
+			alternativesMapping[i] = srcIdx;
+		}
+
+		if (testNsId == -1 && !testNs.equals(srcNamespace)) throw new RuntimeException("test namespace " + testNs + " not present in src and dst namespaces!");
+
+		if (relayHeaderOrMetadata) next.visitNamespaces(srcNamespace, dstNamespaces);
+	}
+
+	@Override
+	public void visitMetadata(String key, @Nullable String value) throws IOException {
+		if (relayHeaderOrMetadata) next.visitMetadata(key, value);
+	}
+
+	@Override
+	public boolean visitContent() throws IOException {
+		relayHeaderOrMetadata = true; // for in-content metadata
+
+		return next.visitContent();
+	}
+
+	@Override
+	public boolean visitClass(String srcName) throws IOException {
+		this.srcName = srcName;
+
+		return next.visitClass(srcName);
+	}
+
+	@Override
+	public boolean visitField(String srcName, @Nullable String srcDesc) throws IOException {
+		this.srcName = srcName;
+
+		return next.visitField(srcName, srcDesc);
+	}
+
+	@Override
+	public boolean visitMethod(String srcName, @Nullable String srcDesc) throws IOException {
+		this.srcName = srcName;
+
+		return next.visitMethod(srcName, srcDesc);
+	}
+
+	@Override
+	public boolean visitMethodArg(int argPosition, int lvIndex, @Nullable String srcName) throws IOException {
+		this.srcName = srcName;
+
+		return next.visitMethodArg(argPosition, lvIndex, srcName);
+	}
+
+	@Override
+	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, @Nullable String srcName) throws IOException {
+		this.srcName = srcName;
+
+		return next.visitMethodVar(lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcName);
+	}
+
+	@Override
+	public void visitDstName(MappedElementKind targetKind, int namespace, String name) {
+		dstNames[namespace] = name;
+	}
+
+	@Override
+	public boolean visitElementContent(MappedElementKind targetKind) throws IOException {
+		for (int ns : alternativesMapping) {
+			int idx = ns + 1; // offset by 1 bc src ns is -1
+
+			if (targetKind == MappedElementKind.METHOD_ARG || targetKind == MappedElementKind.METHOD_VAR) {
+				unobf[idx] = lastMethodUnobf[idx];
+			} else if (ns == testNsId) {
+				unobf[idx] = true;
+
+				if (targetKind == MappedElementKind.METHOD) {
+					lastMethodUnobf[idx] = true;
+				}
+			} else if (!unobf[idx]) { // only check each ns once
+				String name = ns == -1 ? srcName : dstNames[ns];
+				String testName = dstNames[testNsId];
+
+				if (testName != null && testName.equals(name)) {
+					unobf[idx] = true;
+
+					if (targetKind == MappedElementKind.METHOD) {
+						lastMethodUnobf[idx] = true;
+					}
+				}
+			}
+		}
+
+		nsLoop: for (int i = 0; i < dstNames.length; i++) {
+			String name = dstNames[i];
+
+			if (name == null) {
+				int src = i;
+				long visited = 1L << src;
+
+				do {
+					int newSrc = alternativesMapping[src];
+
+					if (newSrc < 0) { // mapping to src name
+						if (unobf[newSrc + 1]) {
+							name = srcName;
+							break; // srcName must never be null
+						} else {
+							continue nsLoop;
+						}
+					} else if (newSrc == src) { // no-op (identity) mapping, explicit in case src > 64
+						continue nsLoop; // always null
+					} else if ((visited & 1L << newSrc) != 0) { // cyclic mapping
+						continue nsLoop; // always null
+					} else {
+						if (unobf[newSrc + 1]) {
+							src = newSrc;
+							name = dstNames[src];
+							visited |= 1L << src;
+						} else {
+							continue nsLoop;
+						}
+					}
+				} while (name == null);
+
+				assert name != null;
+			}
+
+			next.visitDstName(targetKind, i, name);
+		}
+
+		Arrays.fill(dstNames, null);
+		Arrays.fill(unobf, false);
+		Arrays.fill(lastMethodUnobf, false);
+
+		return next.visitElementContent(targetKind);
+	}
+}

From 481ad6d00e4b2679b83789d338c564af62108a2f Mon Sep 17 00:00:00 2001
From: Space Walker <spacedoesrs@gmail.com>
Date: Wed, 11 Sep 2024 19:39:08 +0200
Subject: [PATCH 2/3] TinyRemapperHelper.create does not like null names

---
 .../loom/util/TinyRemapperHelper.java         | 43 ++++++++++++++++---
 1 file changed, 36 insertions(+), 7 deletions(-)

diff --git a/src/main/java/net/fabricmc/loom/util/TinyRemapperHelper.java b/src/main/java/net/fabricmc/loom/util/TinyRemapperHelper.java
index 62d722889..4024d4bfc 100644
--- a/src/main/java/net/fabricmc/loom/util/TinyRemapperHelper.java
+++ b/src/main/java/net/fabricmc/loom/util/TinyRemapperHelper.java
@@ -111,22 +111,51 @@ public static IMappingProvider create(MappingTree mappings, String from, String
 
 			for (MappingTree.ClassMapping classDef : mappings.getClasses()) {
 				String className = classDef.getName(fromId);
-				String dstName = classDef.getName(toId);
 
-				if (dstName == null) {
+				if (className == null) {
+					continue;
+				}
+
+				String dstClassName = classDef.getName(toId);
+
+				if (dstClassName == null) {
 					// Unsure if this is correct, should be better than crashing tho.
-					dstName = className;
+					dstClassName = className;
 				}
 
-				acceptor.acceptClass(className, dstName);
+				acceptor.acceptClass(className, dstClassName);
 
 				for (MappingTree.FieldMapping field : classDef.getFields()) {
-					acceptor.acceptField(memberOf(className, field.getName(fromId), field.getDesc(fromId)), field.getName(toId));
+					String fieldName = field.getName(fromId);
+
+					if (fieldName == null) {
+						continue;
+					}
+
+					String dstFieldName = field.getName(toId);
+
+					if (dstFieldName == null) {
+						dstFieldName = fieldName;
+					}
+
+					acceptor.acceptField(memberOf(className, fieldName, field.getDesc(fromId)), dstFieldName);
 				}
 
 				for (MappingTree.MethodMapping method : classDef.getMethods()) {
-					IMappingProvider.Member methodIdentifier = memberOf(className, method.getName(fromId), method.getDesc(fromId));
-					acceptor.acceptMethod(methodIdentifier, method.getName(toId));
+					String methodName = method.getName(fromId);
+
+					if (methodName == null) {
+						continue;
+					}
+
+					String dstMethodName = method.getName(toId);
+
+					if (dstMethodName == null) {
+						dstMethodName = methodName;
+					}
+
+					IMappingProvider.Member methodIdentifier = memberOf(className, methodName, method.getDesc(fromId));
+					acceptor.acceptMethod(methodIdentifier, dstMethodName);
 
 					if (remapLocalVariables) {
 						for (MappingTree.MethodArgMapping parameter : method.getArgs()) {

From 3bb19d035da5866dba013f818f2d5a835ec1c0b3 Mon Sep 17 00:00:00 2001
From: Space Walker <spacedoesrs@gmail.com>
Date: Fri, 20 Sep 2024 19:30:31 +0200
Subject: [PATCH 3/3] add MappingsMergerTest

---
 .../mappings/tiny/MappingsMerger.java         |   7 +-
 .../fabricmc/loom/test/unit/LoomMocks.groovy  |  18 ++
 .../loom/test/unit/MappingsMergerTest.groovy  | 291 ++++++++++++++++++
 3 files changed, 314 insertions(+), 2 deletions(-)
 create mode 100644 src/test/groovy/net/fabricmc/loom/test/unit/MappingsMergerTest.groovy

diff --git a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
index 07207e620..5827a437d 100644
--- a/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
+++ b/src/main/java/net/fabricmc/loom/configuration/providers/mappings/tiny/MappingsMerger.java
@@ -34,6 +34,7 @@
 import java.util.regex.Pattern;
 
 import com.google.common.base.Stopwatch;
+import org.jetbrains.annotations.VisibleForTesting;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -62,7 +63,8 @@ public static void mergeAndSaveMappings(Path from, Path out, MinecraftProvider m
 		LOGGER.info(":merged mappings in " + stopwatch.stop());
 	}
 
-	private static void mergeAndSaveMappings(Path from, Path out, IntermediateMappingsService intermediateMappingsService) throws IOException {
+	@VisibleForTesting
+	public static void mergeAndSaveMappings(Path from, Path out, IntermediateMappingsService intermediateMappingsService) throws IOException {
 		MemoryMappingTree intermediaryTree = new MemoryMappingTree();
 		intermediateMappingsService.getMemoryMappingTree().accept(new MappingSourceNsSwitch(intermediaryTree, MappingsNamespace.INTERMEDIARY.toString()));
 
@@ -82,7 +84,8 @@ private static void mergeAndSaveMappings(Path from, Path out, IntermediateMappin
 		}
 	}
 
-	private static void legacyMergeAndSaveMappings(Path from, Path out, IntermediateMappingsService intermediateMappingsService) throws IOException {
+	@VisibleForTesting
+	public static void legacyMergeAndSaveMappings(Path from, Path out, IntermediateMappingsService intermediateMappingsService) throws IOException {
 		MemoryMappingTree intermediaryTree = new MemoryMappingTree();
 		intermediateMappingsService.getMemoryMappingTree().accept(intermediaryTree);
 
diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/LoomMocks.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/LoomMocks.groovy
index 1e0c41161..dd417fe98 100644
--- a/src/test/groovy/net/fabricmc/loom/test/unit/LoomMocks.groovy
+++ b/src/test/groovy/net/fabricmc/loom/test/unit/LoomMocks.groovy
@@ -24,9 +24,11 @@
 
 package net.fabricmc.loom.test.unit
 
+import java.nio.file.Path
 import java.util.function.Function
 
 import net.fabricmc.loom.configuration.providers.mappings.IntermediaryMappingsProvider
+import net.fabricmc.loom.configuration.providers.mappings.IntermediateMappingsService
 import net.fabricmc.loom.test.util.GradleTestUtil
 import net.fabricmc.loom.util.download.Download
 
@@ -49,4 +51,20 @@ class LoomMocks {
 		when(mock.getRefreshDeps()).thenReturn(refreshDeps)
 		return mock
 	}
+
+	static IntermediateMappingsService.Options intermediateMappingsServiceOptionsMock(Path intermediaryTiny, String expectedSrcNs) {
+		def intermediaryTinyProperty = GradleTestUtil.mockProperty(intermediaryTiny)
+		def expectedSrcNsProperty = GradleTestUtil.mockProperty(expectedSrcNs)
+
+		def mock = spy(IntermediateMappingsService.Options.class)
+		when(mock.getIntermediaryTiny()).thenReturn(intermediaryTinyProperty)
+		when(mock.getExpectedSrcNs()).thenReturn(expectedSrcNsProperty)
+		return mock
+	}
+
+	static IntermediateMappingsService intermediateMappingsServiceMock(IntermediateMappingsService.Options options) {
+		def mock = spy(IntermediateMappingsService.class)
+		when(mock.getOptions()).thenReturn(options)
+		return mock
+	}
 }
diff --git a/src/test/groovy/net/fabricmc/loom/test/unit/MappingsMergerTest.groovy b/src/test/groovy/net/fabricmc/loom/test/unit/MappingsMergerTest.groovy
new file mode 100644
index 000000000..8bf8db99c
--- /dev/null
+++ b/src/test/groovy/net/fabricmc/loom/test/unit/MappingsMergerTest.groovy
@@ -0,0 +1,291 @@
+/*
+ * This file is part of fabric-loom, licensed under the MIT License (MIT).
+ *
+ * Copyright (c) 2024 FabricMC
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package net.fabricmc.loom.test.unit
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+import spock.lang.TempDir
+
+import net.fabricmc.loom.api.mappings.layered.MappingsNamespace
+import net.fabricmc.loom.configuration.providers.mappings.IntermediateMappingsService
+import net.fabricmc.loom.configuration.providers.mappings.tiny.MappingsMerger
+import net.fabricmc.mappingio.MappingReader
+import net.fabricmc.mappingio.adapter.MappingSourceNsSwitch
+import net.fabricmc.mappingio.tree.MemoryMappingTree
+
+import static org.junit.jupiter.api.Assertions.*
+
+class MappingsMergerTest {
+	@TempDir
+	Path tempDir
+
+	def "mappings merger"() {
+		given:
+		Path intermediaryTiny = tempDir.resolve("intermediary.tiny")
+		Path mappingsTiny = tempDir.resolve("mappings.tiny")
+		Path mergedMappingsTiny = tempDir.resolve("merged_mappings.tiny")
+
+		Files.writeString(intermediaryTiny, INTERMEDIARY_MAPPINGS)
+		Files.writeString(mappingsTiny, NAMED_MAPPINGS)
+
+		IntermediateMappingsService.Options intermediateMappingsServiceOptions = LoomMocks.intermediateMappingsServiceOptionsMock(intermediaryTiny, OFFICIAL)
+		IntermediateMappingsService intermediateMappingsService = LoomMocks.intermediateMappingsServiceMock(intermediateMappingsServiceOptions)
+
+		when:
+		MappingsMerger.mergeAndSaveMappings(mappingsTiny, mergedMappingsTiny, intermediateMappingsService)
+
+		def mappings = new MemoryMappingTree()
+		MappingReader.read(mergedMappingsTiny, mappings)
+
+		then:
+		mappings.srcNamespace == OFFICIAL
+		mappings.dstNamespaces == [INTERMEDIARY, NAMED]
+		def namedNs = mappings.getNamespaceId(NAMED)
+		mappings.classes.size() == 2
+		mappings.classes[0].srcName == "a"
+		mappings.classes[0].getDstName(namedNs) == "net/fabricmc/loom/test/unit/ObfuscatedClass"
+		mappings.classes[0].comment == "class comment"
+		mappings.classes[0].fields.size() == 1
+		mappings.classes[0].fields[0].srcName == "a"
+		mappings.classes[0].fields[0].getDstDesc(namedNs) == "obfuscatedField"
+		mappings.classes[0].fields[0].comment == "field comment"
+		mappings.classes[0].methods.size() == 1
+		mappings.classes[0].methods[0].srcName == "a"
+		mappings.classes[0].methods[0].getDstDesc(namedNs) == "obfuscatedMethod"
+		mappings.classes[0].methods[0].comment == "method comment"
+		mappings.classes[0].methods[0].args.size() == 1
+		mappings.classes[0].methods[1].args[0].getDstName(namedNs) == "obfuscatedMethodParameter"
+		mappings.classes[1].srcName == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		mappings.classes[1].getDstName(namedNs) == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		mappings.classes[1].comment == "class comment"
+		mappings.classes[1].fields.size() == 1
+		mappings.classes[1].fields[0].srcName == "unobfuscatedField"
+		mappings.classes[1].fields[0].getDstDesc(namedNs) == "unobfuscatedField"
+		mappings.classes[1].fields[0].comment == "field comment"
+		mappings.classes[1].methods.size() == 1
+		mappings.classes[1].methods[0].srcName == "unobfuscatedMethod"
+		mappings.classes[1].methods[0].getDstDesc(namedNs) == "unobfuscatedMethod"
+		mappings.classes[1].methods[0].comment == "method comment"
+		mappings.classes[1].methods[0].args.size() == 1
+		mappings.classes[1].methods[1].args[0].getDstName(namedNs) == "unobfuscatedMethodParameter"
+	}
+
+	def "mappings merger legacy"() {
+		given:
+		Path intermediaryTiny = tempDir.resolve("intermediary.tiny")
+		Path mappingsTiny = tempDir.resolve("mappings.tiny")
+		Path mergedMappingsTiny = tempDir.resolve("merged_mappings.tiny")
+
+		Files.writeString(intermediaryTiny, LEGACY_INTERMEDIARY_MAPPINGS)
+		Files.writeString(mappingsTiny, LEGACY_NAMED_MAPPINGS)
+
+		IntermediateMappingsService.Options intermediateMappingsServiceOptions = LoomMocks.intermediateMappingsServiceOptionsMock(intermediaryTiny, INTERMEDIARY)
+		IntermediateMappingsService intermediateMappingsService = LoomMocks.intermediateMappingsServiceMock(intermediateMappingsServiceOptions)
+
+		when:
+		MappingsMerger.legacyMergeAndSaveMappings(mappingsTiny, mergedMappingsTiny, intermediateMappingsService)
+
+		def mappings = new MemoryMappingTree()
+		MappingReader.read(mergedMappingsTiny, mappings)
+
+		def clientMappings = new MemoryMappingTree()
+		def serverMappings = new MemoryMappingTree()
+
+		mappings.accept(new MappingSourceNsSwitch(clientMappings, CLIENT_OFFICIAL, true))
+		mappings.accept(new MappingSourceNsSwitch(serverMappings, SERVER_OFFICIAL, true))
+
+		then:
+		clientMappings.srcNamespace == CLIENT_OFFICIAL
+		clientMappings.dstNamespaces == [
+			INTERMEDIARY,
+			SERVER_OFFICIAL,
+			NAMED
+		]
+		def clientNamedNs = clientMappings.getNamespaceId(NAMED)
+		clientMappings.classes.size() == 3
+		clientMappings.classes[0].srcName == "a"
+		clientMappings.classes[0].getDstName(namedNs) == "net/fabricmc/loom/test/unit/CommonObfuscatedClass"
+		clientMappings.classes[0].comment == "class comment"
+		clientMappings.classes[0].fields.size() == 1
+		clientMappings.classes[0].fields[0].srcName == "a"
+		clientMappings.classes[0].fields[0].getDstDesc(namedNs) == "commonObfuscatedField"
+		clientMappings.classes[0].fields[0].comment == "field comment"
+		clientMappings.classes[0].methods.size() == 1
+		clientMappings.classes[0].methods[0].srcName == "a"
+		clientMappings.classes[0].methods[0].getDstDesc(namedNs) == "commonObfuscatedMethod"
+		clientMappings.classes[0].methods[0].comment == "method comment"
+		clientMappings.classes[0].methods[0].args.size() == 1
+		clientMappings.classes[0].methods[1].args[0].getDstName(namedNs) == "commonObfuscatedMethodParameter"
+		clientMappings.classes[1].srcName == "b"
+		clientMappings.classes[1].getDstName(namedNs) == "net/fabricmc/loom/test/unit/ClientObfuscatedClass"
+		clientMappings.classes[1].comment == "class comment"
+		clientMappings.classes[1].fields.size() == 1
+		clientMappings.classes[1].fields[0].srcName == "a"
+		clientMappings.classes[1].fields[0].getDstDesc(namedNs) == "clientObfuscatedField"
+		clientMappings.classes[1].fields[0].comment == "field comment"
+		clientMappings.classes[1].methods.size() == 1
+		clientMappings.classes[1].methods[0].srcName == "a"
+		clientMappings.classes[1].methods[0].getDstDesc(namedNs) == "clientObfuscatedMethod"
+		clientMappings.classes[1].methods[0].comment == "method comment"
+		clientMappings.classes[1].methods[0].args.size() == 1
+		clientMappings.classes[1].methods[1].args[0].getDstName(namedNs) == "clientObfuscatedMethodParameter"
+		clientMappings.classes[2].srcName == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		clientMappings.classes[2].getDstName(namedNs) == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		clientMappings.classes[2].comment == "class comment"
+		clientMappings.classes[2].fields.size() == 1
+		clientMappings.classes[2].fields[0].srcName == "unobfuscatedField"
+		clientMappings.classes[2].fields[0].getDstDesc(namedNs) == "unobfuscatedField"
+		clientMappings.classes[2].fields[0].comment == "field comment"
+		clientMappings.classes[2].methods.size() == 1
+		clientMappings.classes[2].methods[0].srcName == "unobfuscatedMethod"
+		clientMappings.classes[2].methods[0].getDstDesc(namedNs) == "unobfuscatedMethod"
+		clientMappings.classes[2].methods[0].comment == "method comment"
+		clientMappings.classes[2].methods[0].args.size() == 1
+		clientMappings.classes[2].methods[1].args[0].getDstName(namedNs) == "unobfuscatedMethodParameter"
+
+		serverMappings.srcNamespace == SERVER_OFFICIAL
+		serverMappings.dstNamespaces == [
+			INTERMEDIARY,
+			CLIENT_OFFICIAL,
+			NAMED
+		]
+		def serverNamedNs = serverMappings.getNamespaceId(NAMED)
+		serverMappings.classes.size() == 3
+		serverMappings.classes[0].srcName == "a"
+		serverMappings.classes[0].getDstName(namedNs) == "net/fabricmc/loom/test/unit/CommonObfuscatedClass"
+		serverMappings.classes[0].comment == "class comment"
+		serverMappings.classes[0].fields.size() == 1
+		serverMappings.classes[0].fields[0].srcName == "a"
+		serverMappings.classes[0].fields[0].getDstDesc(namedNs) == "commonObfuscatedField"
+		serverMappings.classes[0].fields[0].comment == "field comment"
+		serverMappings.classes[0].methods.size() == 1
+		serverMappings.classes[0].methods[0].srcName == "a"
+		serverMappings.classes[0].methods[0].getDstDesc(namedNs) == "commonObfuscatedMethod"
+		serverMappings.classes[0].methods[0].comment == "method comment"
+		serverMappings.classes[0].methods[0].args.size() == 1
+		serverMappings.classes[0].methods[1].args[0].getDstName(namedNs) == "commonObfuscatedMethodParameter"
+		serverMappings.classes[1].srcName == "b"
+		serverMappings.classes[1].getDstName(namedNs) == "net/fabricmc/loom/test/unit/ClientObfuscatedClass"
+		serverMappings.classes[1].comment == "class comment"
+		serverMappings.classes[1].fields.size() == 1
+		serverMappings.classes[1].fields[0].srcName == "a"
+		serverMappings.classes[1].fields[0].getDstDesc(namedNs) == "clientObfuscatedField"
+		serverMappings.classes[1].fields[0].comment == "field comment"
+		serverMappings.classes[1].methods.size() == 1
+		serverMappings.classes[1].methods[0].srcName == "a"
+		serverMappings.classes[1].methods[0].getDstDesc(namedNs) == "clientObfuscatedMethod"
+		serverMappings.classes[1].methods[0].comment == "method comment"
+		serverMappings.classes[1].methods[0].args.size() == 1
+		serverMappings.classes[1].methods[1].args[0].getDstName(namedNs) == "clientObfuscatedMethodParameter"
+		serverMappings.classes[2].srcName == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		serverMappings.classes[2].getDstName(namedNs) == "net/fabricmc/loom/test/unit/UnobfuscatedClass"
+		serverMappings.classes[2].comment == "class comment"
+		serverMappings.classes[2].fields.size() == 1
+		serverMappings.classes[2].fields[0].srcName == "unobfuscatedField"
+		serverMappings.classes[2].fields[0].getDstDesc(namedNs) == "unobfuscatedField"
+		serverMappings.classes[2].fields[0].comment == "field comment"
+		serverMappings.classes[2].methods.size() == 1
+		serverMappings.classes[2].methods[0].srcName == "unobfuscatedMethod"
+		serverMappings.classes[2].methods[0].getDstDesc(namedNs) == "unobfuscatedMethod"
+		serverMappings.classes[2].methods[0].comment == "method comment"
+		serverMappings.classes[2].methods[0].args.size() == 1
+		serverMappings.classes[2].methods[1].args[0].getDstName(namedNs) == "unobfuscatedMethodParameter"
+	}
+
+	private static final String OFFICIAL = MappingsNamespace.OFFICIAL.toString()
+	private static final String CLIENT_OFFICIAL = MappingsNamespace.CLIENT_OFFICIAL.toString()
+	private static final String SERVER_OFFICIAL = MappingsNamespace.SERVER_OFFICIAL.toString()
+	private static final String INTERMEDIARY = MappingsNamespace.INTERMEDIARY.toString()
+	private static final String NAMED = MappingsNamespace.NAMED.toString()
+
+	private static final String INTERMEDIARY_MAPPINGS = """
+tiny\t2\t0\tofficial\tintermediary
+c\ta\tclass_1
+\tf\tZ\ta\tfield_1
+\tm\t(Z)V\ta\tmethod_1
+""".trim()
+	private static final String NAMED_MAPPINGS = """
+tiny\t2\t0\tintermediary\tnamed
+c\tclass_1\tnet/fabricmc/loom/test/unit/ObfuscatedClass
+\tc\tclass comment
+\tf\tZ\tfield_1\tobfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tmethod_1\tobfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tobfuscatedMethodParameter
+c\tnet/fabricmc/loom/test/unit/UnobfuscatedClass\tnet/fabricmc/loom/test/unit/UnobfuscatedClass
+\tc\tclass comment
+\tf\tZ\tunobfuscatedField\tunobfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tunobfuscatedMethod\tunobfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tunobfuscatedMethodParameter
+""".trim()
+
+	private static final String LEGACY_INTERMEDIARY_MAPPINGS = """
+tiny\t2\t0\tintermediary\tclientOfficial\tserverOfficial
+c\tclass_1\ta\ta
+\tf\tZ\tfield_1\ta\ta
+\tm\t(Z)V\tmethod_1\ta\ta
+c\tclass_2\tc\t
+\tf\tZ\tfield_2\ta\t
+\tm\t(Z)V\tmethod_2\ta\t
+c\tclass_3\t\tc
+\tf\tZ\tfield_3\t\ta
+\tm\t(Z)V\tmethod_3\t\ta
+""".trim()
+	private static final String LEGACY_NAMED_MAPPINGS = """
+tiny\t2\t0\tintermediary\tnamed
+c\tclass_1\tnet/fabricmc/loom/test/unit/CommonObfuscatedClass
+\tc\tclass comment
+\tf\tZ\tfield_1\tcommonObfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tmethod_1\tcommonObfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tcommonObfuscatedMethodParameter
+c\tclass_2\tnet/fabricmc/loom/test/unit/ClientObfuscatedClass
+\tc\tclass comment
+\tf\tZ\tfield_2\tclientObfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tmethod_2\tclientObfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tclientObfuscatedMethodParameter
+c\tclass_3\tnet/fabricmc/loom/test/unit/ServerObfuscatedClass
+\tc\tclass comment
+\tf\tZ\tfield_3\tserverObfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tmethod_3\tserverObfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tserverObfuscatedMethodParameter
+c\tnet/fabricmc/loom/test/unit/UnobfuscatedClass\tnet/fabricmc/loom/test/unit/UnobfuscatedClass
+\tc\tclass comment
+\tf\tZ\tunobfuscatedField\tunobfuscatedField
+\t\tc\tfield comment
+\tm\t(Z)V\tunobfuscatedMethod\tunobfuscatedMethod
+\t\tc\tmethod comment
+\t\tp\t0\t\t\tunobfuscatedMethodParameter
+""".trim()
+}