diff --git a/src/main/java/net/fabricmc/mappingio/FlatMappingVisitor.java b/src/main/java/net/fabricmc/mappingio/FlatMappingVisitor.java
index 056020ed..da650649 100644
--- a/src/main/java/net/fabricmc/mappingio/FlatMappingVisitor.java
+++ b/src/main/java/net/fabricmc/mappingio/FlatMappingVisitor.java
@@ -57,16 +57,23 @@ default boolean visitContent() throws IOException {
 	}
 
 	boolean visitClass(String srcName, String[] dstNames) throws IOException;
+	void visitClassMetadata(String srcName, String[] dstNames, String propertyKey, String[] propertyValues) throws IOException;
 	void visitClassComment(String srcName, String[] dstNames, String comment) throws IOException;
 
 	boolean visitField(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs) throws IOException;
+	void visitFieldMetadata(String srcClsName, String srcName, String srcDesc,
+			String[] dstClsNames, String[] dstNames, String[] dstDescs,
+			String propertyKey, String[] propertyValues) throws IOException;
 	void visitFieldComment(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs,
 			String comment) throws IOException;
 
 	boolean visitMethod(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs) throws IOException;
+	void visitMethodMetadata(String srcClsName, String srcName, String srcDesc,
+			String[] dstClsNames, String[] dstNames, String[] dstDescs,
+			String propertyKey, String[] propertyValues) throws IOException;
 	void visitMethodComment(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs,
 			String comment) throws IOException;
@@ -74,6 +81,10 @@ void visitMethodComment(String srcClsName, String srcName, String srcDesc,
 	boolean visitMethodArg(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstArgNames) throws IOException;
+	void visitMethodArgMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int argPosition, int lvIndex, String srcArgName,
+			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstArgNames,
+			String propertyKey, String[] propertyValues) throws IOException;
 	void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstArgNames,
@@ -82,6 +93,10 @@ void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMe
 	boolean visitMethodVar(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstVarNames) throws IOException;
+	void visitMethodVarMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
+			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstVarNames,
+			String propertyKey, String[] propertyValues) throws IOException;
 	void visitMethodVarComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstVarNames,
@@ -135,37 +150,53 @@ default boolean visitMethodVar(String srcClsName, String srcMethodName, String s
 
 	// convenience / potentially higher efficiency visit methods for only one dst name
 
+	// Class
 	default boolean visitClass(String srcName, String dstName) throws IOException {
 		return visitClass(srcName, toArray(dstName));
 	}
-
+	default void visitClassMetadata(String srcName, String propertyKey, String[] propertyValues) throws IOException {
+		visitClassMetadata(srcName, (String) null, propertyKey, propertyValues);
+	}
+	default void visitClassMetadata(String srcName, String dstName, String propertyKey, String[] propertyValues) throws IOException {
+		visitClassMetadata(srcName, toArray(dstName), propertyKey, propertyValues);
+	}
 	default void visitClassComment(String srcName, String comment) throws IOException {
 		visitClassComment(srcName, (String) null, comment);
 	}
-
 	default void visitClassComment(String srcName, String dstName, String comment) throws IOException {
 		visitClassComment(srcName, toArray(dstName), comment);
 	}
 
+	// Field
 	default boolean visitField(String srcClsName, String srcName, String srcDesc,
 			String dstName) throws IOException {
 		return visitField(srcClsName, srcName, srcDesc,
 				null, dstName, null);
 	}
-
 	default boolean visitField(String srcClsName, String srcName, String srcDesc,
 			String dstClsName, String dstName, String dstDesc) throws IOException {
 		return visitField(srcClsName, srcName, srcDesc,
 				toArray(dstClsName), toArray(dstName), toArray(dstDesc));
 	}
-
+	default void visitFieldMetadata(String srcClsName, String srcName, String srcDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitFieldMetadata(srcClsName, srcName, srcDesc,
+				(String) null, null, null,
+				propertyKey, propertyValues);
+	}
+	default void visitFieldMetadata(String srcClsName, String srcName, String srcDesc,
+			String dstClsName, String dstName, String dstDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitFieldMetadata(srcClsName, srcName, srcDesc,
+				toArray(dstClsName), toArray(dstName), toArray(dstDesc),
+				propertyKey, propertyValues);
+	}
 	default void visitFieldComment(String srcClsName, String srcName, String srcDesc,
 			String comment) throws IOException {
 		visitFieldComment(srcClsName, srcName, srcDesc,
 				(String) null, null, null,
 				comment);
 	}
-
 	default void visitFieldComment(String srcClsName, String srcName, String srcDesc,
 			String dstClsName, String dstName, String dstDesc,
 			String comment) throws IOException {
@@ -174,25 +205,36 @@ default void visitFieldComment(String srcClsName, String srcName, String srcDesc
 				comment);
 	}
 
+	// Method
 	default boolean visitMethod(String srcClsName, String srcName, String srcDesc,
 			String dstName) throws IOException {
 		return visitMethod(srcClsName, srcName, srcDesc,
 				null, dstName, null);
 	}
-
 	default boolean visitMethod(String srcClsName, String srcName, String srcDesc,
 			String dstClsName, String dstName, String dstDesc) throws IOException {
 		return visitMethod(srcClsName, srcName, srcDesc,
 				toArray(dstClsName), toArray(dstName), toArray(dstDesc));
 	}
-
+	default void visitMethodMetadata(String srcClsName, String srcName, String srcDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodMetadata(srcClsName, srcName, srcDesc,
+				(String) null, null, null,
+				propertyKey, propertyValues);
+	}
+	default void visitMethodMetadata(String srcClsName, String srcName, String srcDesc,
+			String dstClsName, String dstName, String dstDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodMetadata(srcClsName, srcName, srcDesc,
+				toArray(dstClsName), toArray(dstName), toArray(dstDesc),
+				propertyKey, propertyValues);
+	}
 	default void visitMethodComment(String srcClsName, String srcName, String srcDesc,
 			String comment) throws IOException {
 		visitMethodComment(srcClsName, srcName, srcDesc,
 				(String) null, null, null,
 				comment);
 	}
-
 	default void visitMethodComment(String srcClsName, String srcName, String srcDesc,
 			String dstClsName, String dstName, String dstDesc,
 			String comment) throws IOException {
@@ -201,6 +243,7 @@ default void visitMethodComment(String srcClsName, String srcName, String srcDes
 				comment);
 	}
 
+	// Method Arg
 	default boolean visitMethodArg(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String dstArgName) throws IOException {
@@ -208,7 +251,6 @@ default boolean visitMethodArg(String srcClsName, String srcMethodName, String s
 				argPosition, lvIndex, srcArgName,
 				null, null, null, dstArgName);
 	}
-
 	default boolean visitMethodArg(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String dstClsName, String dstMethodName, String dstMethodDesc, String dstArgName) throws IOException {
@@ -216,7 +258,22 @@ default boolean visitMethodArg(String srcClsName, String srcMethodName, String s
 				argPosition, lvIndex, srcArgName,
 				toArray(dstClsName), toArray(dstMethodName), toArray(dstMethodDesc), toArray(dstArgName));
 	}
-
+	default void visitMethodArgMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int argPosition, int lvIndex, String srcArgName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodArgMetadata(srcClsName, srcMethodName, srcMethodDesc,
+				argPosition, lvIndex, srcArgName,
+				(String) null, null, null, null,
+				propertyKey, propertyValues);
+	}
+	default void visitMethodArgMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int argPosition, int lvIndex, String srcArgName,
+			String dstClsName, String dstMethodName, String dstMethodDesc, String dstArgName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodArgMetadata(srcClsName, srcMethodName, srcMethodDesc, argPosition, lvIndex, srcArgName,
+				toArray(dstClsName), toArray(dstMethodName), toArray(dstMethodDesc), toArray(dstArgName),
+				propertyKey, propertyValues);
+	}
 	default void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String comment) throws IOException {
@@ -225,7 +282,6 @@ default void visitMethodArgComment(String srcClsName, String srcMethodName, Stri
 				(String) null, null, null, null,
 				comment);
 	}
-
 	default void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int argPosition, int lvIndex, String srcArgName,
 			String dstClsName, String dstMethodName, String dstMethodDesc, String dstArgName,
@@ -235,6 +291,7 @@ default void visitMethodArgComment(String srcClsName, String srcMethodName, Stri
 				comment);
 	}
 
+	// Method Var
 	default boolean visitMethodVar(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String dstVarName) throws IOException {
@@ -242,7 +299,6 @@ default boolean visitMethodVar(String srcClsName, String srcMethodName, String s
 				lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
 				null, null, null, dstVarName);
 	}
-
 	default boolean visitMethodVar(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String dstClsName, String dstMethodName, String dstMethodDesc, String dstVarName) throws IOException {
@@ -250,7 +306,23 @@ default boolean visitMethodVar(String srcClsName, String srcMethodName, String s
 				lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
 				toArray(dstClsName), toArray(dstMethodName), toArray(dstMethodDesc), toArray(dstVarName));
 	}
-
+	default void visitMethodVarMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodVarMetadata(srcClsName, srcMethodName, srcMethodDesc,
+				lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
+				(String) null, null, null, null,
+				propertyKey, propertyValues);
+	}
+	default void visitMethodVarMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
+			String dstClsName, String dstMethodName, String dstMethodDesc, String dstVarName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		visitMethodVarMetadata(srcClsName, srcMethodName, srcMethodDesc,
+				lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
+				toArray(dstClsName), toArray(dstMethodName), toArray(dstMethodDesc), toArray(dstVarName),
+				propertyKey, propertyValues);
+	}
 	default void visitMethodVarComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String comment) throws IOException {
@@ -259,7 +331,6 @@ default void visitMethodVarComment(String srcClsName, String srcMethodName, Stri
 				(String) null, null, null, null,
 				comment);
 	}
-
 	default void visitMethodVarComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
 			String dstClsName, String dstMethodName, String dstMethodDesc, String dstVarName,
diff --git a/src/main/java/net/fabricmc/mappingio/MappingVisitor.java b/src/main/java/net/fabricmc/mappingio/MappingVisitor.java
index 5c2455ce..f14752ee 100644
--- a/src/main/java/net/fabricmc/mappingio/MappingVisitor.java
+++ b/src/main/java/net/fabricmc/mappingio/MappingVisitor.java
@@ -27,11 +27,11 @@
  * 
- overall: header -> content -> End -> overall
  * 
- header: Header -> Namespaces [-> Metadata]*
  * 
- content: Content [-> class|Metadata]*
- * 
- class: Class [-> DstName]* -> ElementContent [-> field|method|Comment]*
- * 
- field: Field [-> DstName|DstDesc]* -> ElementContent [-> Comment]
- * 
- method: Method [-> DstName|DstDesc]* -> ElementContent [-> arg|var|Comment]*
- * 
- arg: Arg [-> DstName]* -> ElementContent [-> Comment]
- * 
- var: Var [-> DstName]* -> ElementContent [-> Comment]
+ * 
- class: Class [-> DstName]* -> ElementContent [-> ElementMetadata|field|method|Comment]*
+ * 
- field: Field [-> DstName|DstDesc]* -> ElementContent [-> ElementMetadata|Comment]*
+ * 
- method: Method [-> DstName|DstDesc]* -> ElementContent [-> ElementMetadata|arg|var|Comment]*
+ * 
- arg: Arg [-> DstName]* -> ElementContent [-> ElementMetadata|Comment]*
+ * 
- var: Var [-> DstName]* -> ElementContent [-> ElementMetadata|Comment]*
  * 
*
  *The elements with a skip-return (Header/Content/Class/Field/Method/Arg/Var/ElementContent) abort processing the
@@ -118,6 +118,11 @@ default boolean visitElementContent(MappedElementKind targetKind) throws IOExcep
 		return true;
 	}
 
+	/**
+	 * Metadata for the specified element (last content-visited or any parent).
+	 */
+	default void visitElementMetadata(MappedElementKind targetKind, String key, int namespace, String value) throws IOException { }
+
 	/**
 	 * Comment for the specified element (last content-visited or any parent).
 	 *
diff --git a/src/main/java/net/fabricmc/mappingio/adapter/FlatAsRegularMappingVisitor.java b/src/main/java/net/fabricmc/mappingio/adapter/FlatAsRegularMappingVisitor.java
index 34da310f..b16ac554 100644
--- a/src/main/java/net/fabricmc/mappingio/adapter/FlatAsRegularMappingVisitor.java
+++ b/src/main/java/net/fabricmc/mappingio/adapter/FlatAsRegularMappingVisitor.java
@@ -17,8 +17,11 @@
 package net.fabricmc.mappingio.adapter;
 
 import java.io.IOException;
+import java.util.AbstractMap.SimpleEntry;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import net.fabricmc.mappingio.FlatMappingVisitor;
@@ -75,7 +78,8 @@ public boolean visitContent() throws IOException {
 	}
 
 	@Override
-	public boolean visitClass(String srcName) {
+	public boolean visitClass(String srcName) throws IOException {
+		relayPendingElementMetadata();
 		this.srcClsName = srcName;
 
 		Arrays.fill(dstNames, null);
@@ -85,7 +89,8 @@ public boolean visitClass(String srcName) {
 	}
 
 	@Override
-	public boolean visitField(String srcName, String srcDesc) {
+	public boolean visitField(String srcName, String srcDesc) throws IOException {
+		relayPendingElementMetadata();
 		this.srcMemberName = srcName;
 		this.srcMemberDesc = srcDesc;
 
@@ -97,7 +102,8 @@ public boolean visitField(String srcName, String srcDesc) {
 	}
 
 	@Override
-	public boolean visitMethod(String srcName, String srcDesc) {
+	public boolean visitMethod(String srcName, String srcDesc) throws IOException {
+		relayPendingElementMetadata();
 		this.srcMemberName = srcName;
 		this.srcMemberDesc = srcDesc;
 
@@ -109,7 +115,8 @@ public boolean visitMethod(String srcName, String srcDesc) {
 	}
 
 	@Override
-	public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) {
+	public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) throws IOException {
+		relayPendingElementMetadata();
 		this.srcMemberSubName = srcName;
 		this.argIdx = argPosition;
 		this.lvIndex = lvIndex;
@@ -120,7 +127,8 @@ public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) {
 	}
 
 	@Override
-	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcName) {
+	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcName) throws IOException {
+		relayPendingElementMetadata();
 		this.srcMemberSubName = srcName;
 		this.argIdx = lvtRowIndex;
 		this.lvIndex = lvIndex;
@@ -134,6 +142,7 @@ public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int
 
 	@Override
 	public boolean visitEnd() throws IOException {
+		relayPendingElementMetadata();
 		return next.visitEnd();
 	}
 
@@ -149,6 +158,7 @@ public void visitDstDesc(MappedElementKind targetKind, int namespace, String des
 
 	@Override
 	public boolean visitElementContent(MappedElementKind targetKind) throws IOException {
+		currentElementKind = targetKind;
 		boolean relay;
 
 		switch (targetKind) {
@@ -181,8 +191,36 @@ public boolean visitElementContent(MappedElementKind targetKind) throws IOExcept
 		return relay;
 	}
 
+	@Override
+	public void visitElementMetadata(MappedElementKind targetKind, String key, int namespace, String value) throws IOException {
+		if (!key.equals(elementMetadata.getKey())) {
+			relayPendingElementMetadata();
+			elementMetadata = new SimpleEntry<>(key, null);
+		}
+
+		List valueStack = elementMetadata.getValue();
+
+		if (valueStack == null) {
+			valueStack = new ArrayList<>(4);
+			valueStack.add(new String[dstNames.length + 1]);
+			elementMetadata.setValue(valueStack);
+		}
+
+		String[] values = valueStack.get(valueStack.size() - 1);
+
+		// Merge value into existing array, unless already set, then append to stack and add there.
+		if (values[namespace] != null) {
+			values = new String[dstNames.length + 1];
+			valueStack.add(values);
+		}
+
+		values[namespace] = value == null ? nullSubstitute : value;
+	}
+
 	@Override
 	public void visitComment(MappedElementKind targetKind, String comment) throws IOException {
+		relayPendingElementMetadata();
+
 		switch (targetKind) {
 		case CLASS:
 			next.visitClassComment(srcClsName, dstClassNames, comment);
@@ -206,8 +244,57 @@ public void visitComment(MappedElementKind targetKind, String comment) throws IO
 		}
 	}
 
+	private void relayPendingElementMetadata() throws IOException {
+		if (elementMetadata.getValue() == null) return;
+		String key = elementMetadata.getKey();
+		String[] lastValues = null;
+
+		for (String[] values : elementMetadata.getValue()) {
+			for (int i = 0; i < values.length; i++) {
+				if (values[i] == nullSubstitute) {
+					values[i] = null;
+				} else if (values[i] == null && lastValues != null) {
+					// Fill in holes
+					values[i] = lastValues[i];
+				}
+			}
+
+			switch (currentElementKind) {
+			case CLASS:
+				next.visitClassMetadata(srcClsName, dstClassNames, key, values);
+				break;
+			case FIELD:
+				next.visitFieldMetadata(srcClsName, srcMemberName, srcMemberDesc,
+						dstClassNames, dstMemberNames, dstMemberDescs, key, values);
+				break;
+			case METHOD:
+				next.visitMethodMetadata(srcClsName, srcMemberName, srcMemberDesc,
+						dstClassNames, dstMemberNames, dstMemberDescs, key, values);
+				break;
+			case METHOD_ARG:
+				next.visitMethodArgMetadata(srcClsName, srcMemberName, srcMemberDesc, argIdx, lvIndex, srcMemberSubName,
+						dstClassNames, dstMemberNames, dstMemberDescs, dstNames, key, values);
+				break;
+			case METHOD_VAR:
+				next.visitMethodVarMetadata(srcClsName, srcMemberName, srcMemberDesc, argIdx, lvIndex, startOpIdx, endOpIdx, srcMemberSubName,
+						dstClassNames, dstMemberNames, dstMemberDescs, dstNames, key, values);
+				break;
+			default:
+				throw new IllegalStateException();
+			}
+
+			lastValues = values;
+		}
+
+		elementMetadata.setValue(null);
+		currentElementKind = null;
+	}
+
+	private static final String nullSubstitute = new String();
 	private final FlatMappingVisitor next;
 
+	private Map.Entry> elementMetadata = new SimpleEntry<>(null, null);
+	private MappedElementKind currentElementKind;
 	private String srcClsName;
 	private String srcMemberName;
 	private String srcMemberDesc;
diff --git a/src/main/java/net/fabricmc/mappingio/adapter/ForwardingMappingVisitor.java b/src/main/java/net/fabricmc/mappingio/adapter/ForwardingMappingVisitor.java
index 71d9574f..b06b617c 100644
--- a/src/main/java/net/fabricmc/mappingio/adapter/ForwardingMappingVisitor.java
+++ b/src/main/java/net/fabricmc/mappingio/adapter/ForwardingMappingVisitor.java
@@ -107,6 +107,11 @@ public boolean visitElementContent(MappedElementKind targetKind) throws IOExcept
 		return next.visitElementContent(targetKind);
 	}
 
+	@Override
+	public void visitElementMetadata(MappedElementKind targetKind, String key, int namespace, String value) throws IOException {
+		next.visitElementMetadata(targetKind, key, namespace, value);
+	}
+
 	@Override
 	public void visitComment(MappedElementKind targetKind, String comment) throws IOException {
 		next.visitComment(targetKind, comment);
diff --git a/src/main/java/net/fabricmc/mappingio/adapter/MappingDstNsReorder.java b/src/main/java/net/fabricmc/mappingio/adapter/MappingDstNsReorder.java
index b0232d25..1086828a 100644
--- a/src/main/java/net/fabricmc/mappingio/adapter/MappingDstNsReorder.java
+++ b/src/main/java/net/fabricmc/mappingio/adapter/MappingDstNsReorder.java
@@ -66,6 +66,15 @@ public void visitDstDesc(MappedElementKind targetKind, int namespace, String des
 		}
 	}
 
+	@Override
+	public void visitElementMetadata(MappedElementKind targetKind, String key, int namespace, String value) throws IOException {
+		if (namespace >= 0) namespace = nsMap[namespace];
+
+		if (namespace >= 0) {
+			super.visitElementMetadata(targetKind, key, namespace, value);
+		}
+	}
+
 	private final List newDstNs;
 	private int[] nsMap;
 }
diff --git a/src/main/java/net/fabricmc/mappingio/adapter/MappingSourceNsSwitch.java b/src/main/java/net/fabricmc/mappingio/adapter/MappingSourceNsSwitch.java
index de52a900..91da8920 100644
--- a/src/main/java/net/fabricmc/mappingio/adapter/MappingSourceNsSwitch.java
+++ b/src/main/java/net/fabricmc/mappingio/adapter/MappingSourceNsSwitch.java
@@ -300,6 +300,17 @@ public boolean visitElementContent(MappedElementKind targetKind) throws IOExcept
 		return relay;
 	}
 
+	@Override
+	public void visitElementMetadata(MappedElementKind targetKind, String key, int namespace, String value) throws IOException {
+		if (namespace == newSourceNs) {
+			namespace = -1;
+		} else if (namespace == -1) {
+			namespace = newSourceNs;
+		}
+
+		next.visitElementMetadata(targetKind, key, namespace, value);
+	}
+
 	private final String newSourceNsName;
 	private final boolean dropMissingNewSrcName;
 
diff --git a/src/main/java/net/fabricmc/mappingio/adapter/RegularAsFlatMappingVisitor.java b/src/main/java/net/fabricmc/mappingio/adapter/RegularAsFlatMappingVisitor.java
index 2c6408e8..7af2850b 100644
--- a/src/main/java/net/fabricmc/mappingio/adapter/RegularAsFlatMappingVisitor.java
+++ b/src/main/java/net/fabricmc/mappingio/adapter/RegularAsFlatMappingVisitor.java
@@ -89,6 +89,18 @@ private boolean visitClass(String srcName, String[] dstNames, String dstName) th
 		return relayLastClass;
 	}
 
+	@Override
+	public void visitClassMetadata(String srcName, String[] dstNames, String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitClass(srcName, dstNames, null)) return;
+		visitElementMetadata(MappedElementKind.CLASS, propertyKey, propertyValues);
+	}
+
+	@Override
+	public void visitClassMetadata(String srcName, String dstName, String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitClass(srcName, null, dstName)) return;
+		visitElementMetadata(MappedElementKind.CLASS, propertyKey, propertyValues);
+	}
+
 	@Override
 	public void visitClassComment(String srcName, String[] dstNames, String comment) throws IOException {
 		if (!visitClass(srcName, dstNames, null)) return;
@@ -128,6 +140,22 @@ private boolean visitField(String srcClsName, String srcName, String srcDesc,
 		return relayLastMember;
 	}
 
+	@Override
+	public void visitFieldMetadata(String srcClsName, String srcName, String srcDesc,
+			String[] dstClsNames, String[] dstNames, String[] dstDescs,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitField(srcClsName, srcName, srcDesc, dstClsNames, dstNames, dstDescs, null, null, null)) return;
+		visitElementMetadata(MappedElementKind.FIELD, propertyKey, propertyValues);
+	}
+
+	@Override
+	public void visitFieldMetadata(String srcClsName, String srcName, String srcDesc,
+			String dstClsName, String dstName, String dstDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitField(srcClsName, srcName, srcDesc, null, null, null, dstClsName, dstName, dstDesc)) return;
+		visitElementMetadata(MappedElementKind.FIELD, propertyKey, propertyValues);
+	}
+
 	@Override
 	public void visitFieldComment(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs,
@@ -171,6 +199,22 @@ private boolean visitMethod(String srcClsName, String srcName, String srcDesc,
 		return relayLastMember;
 	}
 
+	@Override
+	public void visitMethodMetadata(String srcClsName, String srcName, String srcDesc,
+			String[] dstClsNames, String[] dstNames, String[] dstDescs,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethod(srcClsName, srcName, srcDesc, dstClsNames, dstNames, dstDescs, null, null, null)) return;
+		visitElementMetadata(MappedElementKind.METHOD, propertyKey, propertyValues);
+	}
+
+	@Override
+	public void visitMethodMetadata(String srcClsName, String srcName, String srcDesc,
+			String dstClsName, String dstName, String dstDesc,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethod(srcClsName, srcName, srcDesc, null, null, null, dstClsName, dstName, dstDesc)) return;
+		visitElementMetadata(MappedElementKind.METHOD, propertyKey, propertyValues);
+	}
+
 	@Override
 	public void visitMethodComment(String srcClsName, String srcName, String srcDesc,
 			String[] dstClsNames, String[] dstNames, String[] dstDescs,
@@ -219,6 +263,30 @@ private boolean visitMethodArg(String srcClsName, String srcMethodName, String s
 		return relayLastMethodSub;
 	}
 
+	@Override
+	public void visitMethodArgMetadata(String srcClsName, String srcMethodName, String srcMethodDesc, int argPosition, int lvIndex, String srcArgName,
+			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstArgNames,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethodArg(srcClsName, srcMethodName, srcMethodDesc, argPosition, lvIndex, srcArgName,
+				dstClsNames, dstMethodNames, dstMethodDescs, dstArgNames, null, null, null, null)) {
+			return;
+		}
+
+		visitElementMetadata(MappedElementKind.METHOD_ARG, propertyKey, propertyValues);
+	}
+
+	@Override
+	public void visitMethodArgMetadata(String srcClsName, String srcMethodName, String srcMethodDesc, int argPosition,
+			int lvIndex, String srcArgName, String dstClsName, String dstMethodName, String dstMethodDesc, String dstArgName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethodArg(srcClsName, srcMethodName, srcMethodDesc, argPosition, lvIndex, srcArgName,
+				null, null, null, null, dstClsName, dstMethodName, dstMethodDesc, dstArgName)) {
+			return;
+		}
+
+		visitElementMetadata(MappedElementKind.METHOD_ARG, propertyKey, propertyValues);
+	}
+
 	@Override
 	public void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMethodDesc, int argPosition, int lvIndex, String srcArgName,
 			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstArgNames,
@@ -233,8 +301,8 @@ public void visitMethodArgComment(String srcClsName, String srcMethodName, Strin
 
 	@Override
 	public void visitMethodArgComment(String srcClsName, String srcMethodName, String srcMethodDesc, int argPosition,
-			int lvIndex, String srcArgName, String dstClsName, String dstMethodName, String dstMethodDesc,
-			String dstArgName, String comment) throws IOException {
+			int lvIndex, String srcArgName, String dstClsName, String dstMethodName, String dstMethodDesc, String dstArgName,
+			String comment) throws IOException {
 		if (!visitMethodArg(srcClsName, srcMethodName, srcMethodDesc, argPosition, lvIndex, srcArgName,
 				null, null, null, null, dstClsName, dstMethodName, dstMethodDesc, dstArgName)) {
 			return;
@@ -274,6 +342,32 @@ private boolean visitMethodVar(String srcClsName, String srcMethodName, String s
 		return relayLastMethodSub;
 	}
 
+	@Override
+	public void visitMethodVarMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
+			String[] dstClsNames, String[] dstMethodNames, String[] dstMethodDescs, String[] dstVarNames,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethodVar(srcClsName, srcMethodName, srcMethodDesc, lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
+				dstClsNames, dstMethodNames, dstMethodDescs, dstVarNames, null, null, null, null)) {
+			return;
+		}
+
+		visitElementMetadata(MappedElementKind.METHOD_VAR, propertyKey, propertyValues);
+	}
+
+	@Override
+	public void visitMethodVarMetadata(String srcClsName, String srcMethodName, String srcMethodDesc,
+			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
+			String dstClsName, String dstMethodName, String dstMethodDesc, String dstVarName,
+			String propertyKey, String[] propertyValues) throws IOException {
+		if (!visitMethodVar(srcClsName, srcMethodName, srcMethodDesc, lvtRowIndex, lvIndex, startOpIdx, endOpIdx, srcVarName,
+				null, null, null, null, dstClsName, dstMethodName, dstMethodDesc, dstVarName)) {
+			return;
+		}
+
+		visitElementMetadata(MappedElementKind.METHOD_VAR, propertyKey, propertyValues);
+	}
+
 	@Override
 	public void visitMethodVarComment(String srcClsName, String srcMethodName, String srcMethodDesc,
 			int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcVarName,
@@ -344,6 +438,15 @@ private boolean visitDstNamesDescs(MappedElementKind targetKind, String[] dstNam
 		return next.visitElementContent(targetKind);
 	}
 
+	private void visitElementMetadata(MappedElementKind targetKind, String key, String[] values) throws IOException {
+		if (values != null) {
+			for (int i = 0; i < values.length; i++) {
+				String value = values[i];
+				if (value != null) next.visitElementMetadata(targetKind, key, i-1, value);
+			}
+		}
+	}
+
 	private final MappingVisitor next;
 
 	private boolean relayDstFieldDescs;
diff --git a/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java b/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java
index ac7bfcf6..66d87e46 100644
--- a/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java
+++ b/src/main/java/net/fabricmc/mappingio/format/MappingFormat.java
@@ -17,19 +17,20 @@
 package net.fabricmc.mappingio.format;
 
 public enum MappingFormat {
-	TINY_FILE("Tiny file", "tiny", true, true, false, false, false),
-	TINY_2_FILE("Tiny v2 file", "tiny", true, true, true, true, true),
-	ENIGMA_FILE("Enigma file", "mappings", false, true, true, true, false),
-	ENIGMA_DIR("Enigma directory", null, false, true, true, true, false),
-	MCP_DIR("MCP directory", null, false, false, true, true, false),
-	SRG_FILE("SRG file", "srg", false, false, false, false, false),
-	TSRG_FILE("TSRG file", "tsrg", false, false, false, false, false),
-	TSRG_2_FILE("TSRG2 file", "tsrg", true, false, false, true, false),
-	PROGUARD_FILE("ProGuard file", "map", false, true, false, false, false);
+	TINY_FILE("Tiny file", "tiny", true, true, false, false, false, MetadataSupport.ARBITRARY, MetadataSupport.NONE),
+	TINY_2_FILE("Tiny v2 file", "tiny", true, true, true, true, true, MetadataSupport.ARBITRARY, MetadataSupport.NONE),
+	ENIGMA_FILE("Enigma file", "mappings", false, true, true, true, false, MetadataSupport.NONE, MetadataSupport.HARDCODED),
+	ENIGMA_DIR("Enigma directory", null, false, true, true, true, false, MetadataSupport.NONE, MetadataSupport.HARDCODED),
+	MCP_DIR("MCP directory", null, false, false, true, true, false, MetadataSupport.NONE, MetadataSupport.NONE),
+	SRG_FILE("SRG file", "srg", false, false, false, false, false, MetadataSupport.NONE, MetadataSupport.NONE),
+	TSRG_FILE("TSRG file", "tsrg", false, false, false, false, false, MetadataSupport.NONE, MetadataSupport.NONE),
+	TSRG_2_FILE("TSRG2 file", "tsrg", true, false, false, true, false, MetadataSupport.NONE, MetadataSupport.HARDCODED),
+	PROGUARD_FILE("ProGuard file", "map", false, true, false, false, false, MetadataSupport.NONE, MetadataSupport.HARDCODED);
 
 	MappingFormat(String name, String fileExt,
 			boolean hasNamespaces, boolean hasFieldDescriptors,
-			boolean supportsComments, boolean supportsArgs, boolean supportsLocals) {
+			boolean supportsComments, boolean supportsArgs, boolean supportsLocals,
+			MetadataSupport fileMetadataSupport, MetadataSupport elementMetadataSupport) {
 		this.name = name;
 		this.fileExt = fileExt;
 		this.hasNamespaces = hasNamespaces;
@@ -37,6 +38,8 @@ public enum MappingFormat {
 		this.supportsComments = supportsComments;
 		this.supportsArgs = supportsArgs;
 		this.supportsLocals = supportsLocals;
+		this.fileMetadataSupport = fileMetadataSupport;
+		this.elementMetadataSupport = elementMetadataSupport;
 	}
 
 	public boolean hasSingleFile() {
@@ -56,4 +59,17 @@ public String getGlobPattern() {
 	public final boolean supportsComments;
 	public final boolean supportsArgs;
 	public final boolean supportsLocals;
+	public final MetadataSupport fileMetadataSupport;
+	public final MetadataSupport elementMetadataSupport;
+
+	public enum MetadataSupport {
+		/** No metadata at all. */
+		NONE,
+
+		/** Only some select properties.  */
+		HARDCODED,
+
+		/** Arbitrary metadata may be attached. */
+		ARBITRARY
+	}
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/StandardProperties.java b/src/main/java/net/fabricmc/mappingio/format/StandardProperties.java
new file mode 100644
index 00000000..dc22319e
--- /dev/null
+++ b/src/main/java/net/fabricmc/mappingio/format/StandardProperties.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.mappingio.format;
+
+import java.util.AbstractMap.SimpleEntry;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.fabricmc.mappingio.MappedElementKind;
+
+public final class StandardProperties {
+	private StandardProperties() {
+	}
+
+	public static Set values() {
+		return Collections.unmodifiableSet(values);
+	}
+
+	public static StandardProperty getByName(String name) {
+		return valuesByName.get(name);
+	}
+
+	@ApiStatus.Internal
+	public static StandardProperty getById(String id) {
+		return valuesById.get(id);
+	}
+
+	public static final StandardProperty NEXT_INTERMEDIARY_CLASS;
+	public static final StandardProperty NEXT_INTERMEDIARY_FIELD;
+	public static final StandardProperty NEXT_INTERMEDIARY_METHOD;
+	public static final StandardProperty NEXT_INTERMEDIARY_COMPONENT;
+	public static final StandardProperty MISSING_LVT_INDICES;
+	public static final StandardProperty ESCAPED_NAMES;
+	public static final StandardProperty MODIFIED_ACCESS;
+	public static final StandardProperty IS_STATIC;
+	public static final StandardProperty START_LINE_NUMBER;
+	public static final StandardProperty END_LINE_NUMBER;
+	private static final Set values = new HashSet<>();
+	private static final Map valuesByName = new HashMap<>();
+	private static final Map valuesById = new HashMap<>();
+
+	static {
+		NEXT_INTERMEDIARY_CLASS = register("next-intermediary-class")
+				.addMapping(MappingFormat.TINY_FILE, "INTERMEDIARY_COUNTER class")
+				.addMapping(MappingFormat.TINY_2_FILE, "next-intermediary-class");
+		NEXT_INTERMEDIARY_FIELD = register("next-intermediary-field")
+				.addMapping(MappingFormat.TINY_FILE, "INTERMEDIARY_COUNTER field")
+				.addMapping(MappingFormat.TINY_2_FILE, "next-intermediary-field");
+		NEXT_INTERMEDIARY_METHOD = register("next-intermediary-method")
+				.addMapping(MappingFormat.TINY_FILE, "INTERMEDIARY_COUNTER method")
+				.addMapping(MappingFormat.TINY_2_FILE, "next-intermediary-method");
+		NEXT_INTERMEDIARY_COMPONENT = register("next-intermediary-component")
+				.addMapping(MappingFormat.TINY_FILE, "INTERMEDIARY_COUNTER component")
+				.addMapping(MappingFormat.TINY_2_FILE, "next-intermediary-component");
+		MISSING_LVT_INDICES = register("missing-lvt-indices")
+				.addMapping(MappingFormat.TINY_2_FILE, "missing-lvt-indices");
+		ESCAPED_NAMES = register("escaped-names")
+				.addMapping(MappingFormat.TINY_2_FILE, "escaped-names");
+		MODIFIED_ACCESS = register("modified-access")
+				.addMapping(MappingFormat.ENIGMA_FILE, MappedElementKind.CLASS, "ACC:")
+				.addMapping(MappingFormat.ENIGMA_FILE, MappedElementKind.FIELD, "ACC:")
+				.addMapping(MappingFormat.ENIGMA_FILE, MappedElementKind.METHOD, "ACC:")
+				.addMapping(MappingFormat.ENIGMA_DIR, MappedElementKind.CLASS, "ACC:")
+				.addMapping(MappingFormat.ENIGMA_DIR, MappedElementKind.FIELD, "ACC:")
+				.addMapping(MappingFormat.ENIGMA_DIR, MappedElementKind.METHOD, "ACC:");
+		IS_STATIC = register("is-static")
+				.addMapping(MappingFormat.TSRG_2_FILE, MappedElementKind.METHOD, "static");
+		START_LINE_NUMBER = register("start-line-number")
+				.addMapping(MappingFormat.PROGUARD_FILE, MappedElementKind.METHOD, null);
+		END_LINE_NUMBER = register("end-line-number")
+				.addMapping(MappingFormat.PROGUARD_FILE, MappedElementKind.METHOD, null);
+	}
+
+	private static StandardPropertyImpl register(String id) {
+		return new StandardPropertyImpl(id);
+	}
+
+	private static class StandardPropertyImpl implements StandardProperty {
+		StandardPropertyImpl(String id) {
+			this.id = id;
+			values.add(this);
+			valuesById.put(id, this);
+		}
+
+		private StandardPropertyImpl addMapping(MappingFormat format, String name) {
+			filePropNameByFormat.put(format, name);
+			valuesByName.put(name, this);
+			return this;
+		}
+
+		private StandardPropertyImpl addMapping(MappingFormat format, MappedElementKind elementKind, String name) {
+			propElementKindByFormat.put(format, elementKind);
+			elementPropNameByFormat.put(new SimpleEntry<>(format, elementKind), name);
+			valuesByName.put(name, this);
+			return this;
+		}
+
+		@Override
+		public boolean isFileProperty() {
+			return !filePropNameByFormat.isEmpty();
+		}
+
+		@Override
+		public boolean isElementProperty() {
+			return !elementPropNameByFormat.isEmpty();
+		}
+
+		@Override
+		public Set getApplicableFormats() {
+			return filePropNameByFormat.keySet();
+		}
+
+		@Override
+		public Map getApplicableElementKinds() {
+			return propElementKindByFormat;
+		}
+
+		@Override
+		public boolean isApplicableTo(MappingFormat format) {
+			return filePropNameByFormat.containsKey(format);
+		}
+
+		@Override
+		public boolean isApplicableTo(MappingFormat format, MappedElementKind elementKind) {
+			return elementPropNameByFormat.containsKey(new SimpleEntry<>(format, elementKind));
+		}
+
+		@Override
+		public String getNameFor(MappingFormat format) {
+			return filePropNameByFormat.get(format);
+		}
+
+		@Override
+		public String getNameFor(MappingFormat format, MappedElementKind elementKind) {
+			return elementPropNameByFormat.get(new SimpleEntry<>(format, elementKind));
+		}
+
+		@Override
+		public String getId() {
+			return id;
+		}
+
+		private final String id;
+		private final Map filePropNameByFormat = new HashMap<>(4);
+		private final Map, String> elementPropNameByFormat = new HashMap<>(4);
+		private final Map propElementKindByFormat = new HashMap<>(4);
+	}
+}
diff --git a/src/main/java/net/fabricmc/mappingio/format/StandardProperty.java b/src/main/java/net/fabricmc/mappingio/format/StandardProperty.java
new file mode 100644
index 00000000..84d5ea8e
--- /dev/null
+++ b/src/main/java/net/fabricmc/mappingio/format/StandardProperty.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.mappingio.format;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.jetbrains.annotations.ApiStatus;
+
+import net.fabricmc.mappingio.MappedElementKind;
+
+public interface StandardProperty {
+	boolean isFileProperty();
+	boolean isElementProperty();
+
+	Set getApplicableFormats();
+	Map getApplicableElementKinds();
+
+	boolean isApplicableTo(MappingFormat format);
+	boolean isApplicableTo(MappingFormat format, MappedElementKind elementKind);
+
+	String getNameFor(MappingFormat format);
+	String getNameFor(MappingFormat format, MappedElementKind elementKind);
+
+	/**
+	 * Used internally by MappingTrees, consistency between JVM sessions
+	 * or library versions isn't guaranteed!
+	 */
+	@ApiStatus.Internal
+	String getId();
+}
diff --git a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaDirWriter.java b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaDirWriter.java
index ea5332e1..4e09852c 100644
--- a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaDirWriter.java
+++ b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaDirWriter.java
@@ -29,6 +29,7 @@
 import java.util.List;
 
 import net.fabricmc.mappingio.MappedElementKind;
+import net.fabricmc.mappingio.format.MappingFormat;
 
 public final class EnigmaDirWriter extends EnigmaWriterBase {
 	public EnigmaDirWriter(Path dir, boolean deleteExistingFiles) throws IOException {
@@ -60,6 +61,11 @@ public FileVisitResult postVisitDirectory(Path file, IOException exc) throws IOE
 		}
 	}
 
+	@Override
+	protected MappingFormat getFormat() {
+		return MappingFormat.ENIGMA_DIR;
+	}
+
 	@Override
 	public void close() throws IOException {
 		if (writer != null) {
@@ -128,16 +134,9 @@ public boolean visitElementContent(MappedElementKind targetKind) throws IOExcept
 
 				writer = Files.newBufferedWriter(file, StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE);
 			}
-
-			writeMismatchedOrMissingClasses();
-		} else if (targetKind == MappedElementKind.FIELD || targetKind == MappedElementKind.METHOD) {
-			writer.write(' ');
-			writer.write(desc);
-			writer.write('\n');
-		} else {
-			writer.write('\n');
 		}
 
+		super.visitElementContent(targetKind);
 		return true;
 	}
 
diff --git a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileReader.java b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileReader.java
index 05ffce65..f8885567 100644
--- a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileReader.java
+++ b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileReader.java
@@ -19,13 +19,17 @@
 import java.io.IOException;
 import java.io.Reader;
 import java.util.Collections;
+import java.util.Locale;
 import java.util.Set;
 
+import org.jetbrains.annotations.Nullable;
+
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingUtil;
 import net.fabricmc.mappingio.MappingVisitor;
 import net.fabricmc.mappingio.format.ColumnFileReader;
+import net.fabricmc.mappingio.format.StandardProperties;
 import net.fabricmc.mappingio.tree.MappingTree;
 import net.fabricmc.mappingio.tree.MemoryMappingTree;
 
@@ -61,7 +65,7 @@ public static void read(ColumnFileReader reader, String sourceNs, String targetN
 			final MappingVisitor finalVisitor = visitor;
 
 			do {
-				if (reader.nextCol("CLASS")) { // class: CLASS  []
+				if (reader.nextCol("CLASS")) { // class: CLASS  [] []
 					readClass(reader, 0, null, null, commentSb, finalVisitor);
 				}
 			} while (reader.nextLine(0));
@@ -75,8 +79,11 @@ public static void read(ColumnFileReader reader, String sourceNs, String targetN
 	}
 
 	private static void readClass(ColumnFileReader reader, int indent, String outerSrcClass, String outerDstClass, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
-		String srcInnerName = reader.nextCol();
-		if (srcInnerName == null || srcInnerName.isEmpty()) throw new IOException("missing class-name-a in line "+reader.getLineNumber());
+		String line = reader.nextCols(false);
+		String[] parts = line.split(" ");
+
+		if (parts.length == 0 || parts[0].isEmpty()) throw new IOException("missing class-name-a in line "+reader.getLineNumber());
+		String srcInnerName = parts[0];
 
 		String srcName = srcInnerName;
 
@@ -84,7 +91,22 @@ private static void readClass(ColumnFileReader reader, int indent, String outerS
 			srcName = String.format("%s$%s", outerSrcClass, srcInnerName);
 		}
 
-		String dstInnerName = reader.nextCol();
+		String dstInnerName = null;
+		String accessModifier = null;
+
+		if (parts.length == 2) { //  | 
+			String parsedModifier = parseModifier(parts[1]);
+
+			if (parsedModifier == null) {
+				dstInnerName = parts[1];
+			} else {
+				accessModifier = parsedModifier;
+			}
+		} else {
+			dstInnerName = parts[1];
+			accessModifier = parts[2];
+		}
+
 		String dstName = dstInnerName;
 
 		// merge with outer name if available
@@ -96,19 +118,20 @@ private static void readClass(ColumnFileReader reader, int indent, String outerS
 			dstName = String.format("%s$%s", outerDstClass, dstInnerName);
 		}
 
-		readClassBody(reader, indent, srcName, dstName, commentSb, visitor);
+		readClassBody(reader, indent, srcName, dstName, accessModifier, commentSb, visitor);
 	}
 
-	private static void readClassBody(ColumnFileReader reader, int indent, String srcClass, String dstClass, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
+	private static void readClassBody(ColumnFileReader reader, int indent, String srcClass, String dstClass,
+			String classAccess, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
 		boolean visited = false;
 		int state = 0; // 0=invalid 1=visit -1=skip
 
 		while (reader.nextLine(indent + 1)) {
 			boolean isMethod;
 
-			if (reader.nextCol("CLASS")) { // nested class: CLASS  []
+			if (reader.nextCol("CLASS")) { // nested class: CLASS  [] []
 				if (!visited || commentSb.length() > 0) {
-					visitClass(srcClass, dstClass, state, commentSb, visitor);
+					visitClass(srcClass, dstClass, state, classAccess, commentSb, visitor);
 					visited = true;
 				}
 
@@ -116,46 +139,64 @@ private static void readClassBody(ColumnFileReader reader, int indent, String sr
 				state = 0;
 			} else if (reader.nextCol("COMMENT")) { // comment: COMMENT 
 				readComment(reader, commentSb);
-			} else if ((isMethod = reader.nextCol("METHOD")) || reader.nextCol("FIELD")) { // method: METHOD  []  or field: FIELD  [] 
-				state = visitClass(srcClass, dstClass, state, commentSb, visitor);
+			} else if ((isMethod = reader.nextCol("METHOD")) || reader.nextCol("FIELD")) { // METHOD|FIELD  [] [] 
+				state = visitClass(srcClass, dstClass, state, classAccess, commentSb, visitor);
 				visited = true;
 				if (state < 0) continue;
 
-				String srcName = reader.nextCol();
-				if (srcName == null || srcName.isEmpty()) throw new IOException("missing field-name-a in line "+reader.getLineNumber());
+				String line = reader.nextCols(false);
+				String[] parts = line.split(" ");
+
+				if (parts.length == 0 || parts[0].isEmpty()) throw new IOException("missing member-name-a in line "+reader.getLineNumber());
+				if (parts.length == 1 || parts[1].isEmpty()) throw new IOException("missing member-desc-a in line "+reader.getLineNumber());
+				String srcName = parts[0];
+				String dstName = null;
+				String modifier = null;
+				String srcDesc;
+
+				if (parts.length == 2) { //  
+					srcDesc = parts[1];
+				} else if (parts.length == 3) { //    |   
+					String parsedModifier = parseModifier(parts[2]);
+
+					if (parsedModifier == null) {
+						dstName = parts[1];
+						srcDesc = parts[2];
+					} else {
+						srcDesc = parts[1];
+						modifier = parsedModifier;
+					}
+				} else { //    
+					dstName = parts[1];
+					srcDesc = parts[2];
+					modifier = parts[3];
+				}
 
-				String dstNameOrSrcDesc = reader.nextCol();
-				if (dstNameOrSrcDesc == null || dstNameOrSrcDesc.isEmpty()) throw new IOException("missing field-desc-b in line "+reader.getLineNumber());
+				MappedElementKind targetKind = isMethod && visitor.visitMethod(srcName, srcDesc) ? MappedElementKind.METHOD
+						: !isMethod && visitor.visitField(srcName, srcDesc) ? MappedElementKind.FIELD : null;
 
-				String srcDesc = reader.nextCol();
-				String dstName;
+				if (targetKind != null) {
+					if (dstName != null && !dstName.isEmpty()) visitor.visitDstName(targetKind, 0, dstName);
+					if (modifier != null) visitAccessModifier(targetKind, modifier, visitor);
 
-				if (srcDesc == null) {
-					dstName = null;
-					srcDesc = dstNameOrSrcDesc;
-				} else {
-					dstName = dstNameOrSrcDesc;
-				}
-
-				if (isMethod && visitor.visitMethod(srcName, srcDesc)) {
-					if (dstName != null && !dstName.isEmpty()) visitor.visitDstName(MappedElementKind.METHOD, 0, dstName);
-					readMethod(reader, indent, commentSb, visitor);
-				} else if (!isMethod && visitor.visitField(srcName, srcDesc)) {
-					if (dstName != null && !dstName.isEmpty()) visitor.visitDstName(MappedElementKind.FIELD, 0, dstName);
-					readElement(reader, MappedElementKind.FIELD, indent, commentSb, visitor);
+					if (targetKind == MappedElementKind.METHOD) {
+						readMethod(reader, indent, commentSb, visitor);
+					} else {
+						readElement(reader, targetKind, indent, commentSb, visitor);
+					}
 				}
 			}
 		}
 
 		if (!visited || commentSb.length() > 0) {
-			visitClass(srcClass, dstClass, state, commentSb, visitor);
+			visitClass(srcClass, dstClass, state, classAccess, commentSb, visitor);
 		}
 	}
 
 	/**
 	 * Re-visit a class if necessary and visit its comment if available.
 	 */
-	private static int visitClass(String srcClass, String dstClass, int state, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
+	private static int visitClass(String srcClass, String dstClass, int state, String accessModifier, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
 		// state: 0=invalid 1=visit -1=skip
 
 		if (state == 0) {
@@ -168,6 +209,10 @@ private static int visitClass(String srcClass, String dstClass, int state, Strin
 
 			state = visitContent ? 1 : -1;
 
+			if (accessModifier != null) {
+				visitAccessModifier(MappedElementKind.CLASS, accessModifier, visitor);
+			}
+
 			if (commentSb.length() > 0) {
 				if (state > 0) visitor.visitComment(MappedElementKind.CLASS, commentSb.toString());
 
@@ -178,6 +223,10 @@ private static int visitClass(String srcClass, String dstClass, int state, Strin
 		return state;
 	}
 
+	private static void visitAccessModifier(MappedElementKind targetKind, String modifier, MappingVisitor visitor) throws IOException {
+		visitor.visitElementMetadata(targetKind, StandardProperties.MODIFIED_ACCESS.getId(), 0, modifier);
+	}
+
 	private static void readMethod(ColumnFileReader reader, int indent, StringBuilder commentSb, MappingVisitor visitor) throws IOException {
 		if (!visitor.visitElementContent(MappedElementKind.METHOD)) return;
 
@@ -233,4 +282,13 @@ private static void submitComment(MappedElementKind kind, StringBuilder commentS
 		visitor.visitComment(kind, commentSb.toString());
 		commentSb.setLength(0);
 	}
+
+	@Nullable
+	private static String parseModifier(String token) {
+		if (!token.startsWith("ACC:")) {
+			return null;
+		}
+
+		return token.substring(4).toLowerCase(Locale.ROOT);
+	}
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileWriter.java b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileWriter.java
index 4c285707..4ce1c70f 100644
--- a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileWriter.java
+++ b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaFileWriter.java
@@ -19,7 +19,7 @@
 import java.io.IOException;
 import java.io.Writer;
 
-import net.fabricmc.mappingio.MappedElementKind;
+import net.fabricmc.mappingio.format.MappingFormat;
 
 public final class EnigmaFileWriter extends EnigmaWriterBase {
 	public EnigmaFileWriter(Writer writer) throws IOException {
@@ -27,17 +27,7 @@ public EnigmaFileWriter(Writer writer) throws IOException {
 	}
 
 	@Override
-	public boolean visitElementContent(MappedElementKind targetKind) throws IOException {
-		if (targetKind == MappedElementKind.CLASS) {
-			writeMismatchedOrMissingClasses();
-		} else if (targetKind == MappedElementKind.FIELD || targetKind == MappedElementKind.METHOD) {
-			writer.write(' ');
-			writer.write(desc);
-			writer.write('\n');
-		} else {
-			writer.write('\n');
-		}
-
-		return true;
+	protected MappingFormat getFormat() {
+		return MappingFormat.ENIGMA_FILE;
 	}
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaWriterBase.java b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaWriterBase.java
index 11277530..818d27be 100644
--- a/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaWriterBase.java
+++ b/src/main/java/net/fabricmc/mappingio/format/enigma/EnigmaWriterBase.java
@@ -19,18 +19,26 @@
 import java.io.IOException;
 import java.io.Writer;
 import java.util.EnumSet;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingWriter;
+import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 
 abstract class EnigmaWriterBase implements MappingWriter {
 	EnigmaWriterBase(Writer writer) throws IOException {
 		this.writer = writer;
 	}
 
+	protected abstract MappingFormat getFormat();
+
 	@Override
 	public void close() throws IOException {
 		writer.close();
@@ -46,12 +54,14 @@ public void visitNamespaces(String srcNamespace, List dstNamespaces) { }
 
 	@Override
 	public boolean visitClass(String srcName) throws IOException {
+		if (writer != null && srcClassName != null) writePendingElementMetadata(true);
 		srcClassName = srcName;
 		return true;
 	}
 
 	@Override
 	public boolean visitField(String srcName, String srcDesc) throws IOException {
+		writePendingElementMetadata(true);
 		writeIndent(0);
 		writer.write("FIELD ");
 		writer.write(srcName);
@@ -63,6 +73,7 @@ public boolean visitField(String srcName, String srcDesc) throws IOException {
 
 	@Override
 	public boolean visitMethod(String srcName, String srcDesc) throws IOException {
+		writePendingElementMetadata(true);
 		writeIndent(0);
 		writer.write("METHOD ");
 		writer.write(srcName);
@@ -74,6 +85,7 @@ public boolean visitMethod(String srcName, String srcDesc) throws IOException {
 
 	@Override
 	public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) throws IOException {
+		writePendingElementMetadata(true);
 		writeIndent(1);
 		writer.write("ARG ");
 		writer.write(Integer.toString(lvIndex));
@@ -82,12 +94,13 @@ public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) thro
 	}
 
 	@Override
-	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcName) {
+	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcName) throws IOException {
 		return false;
 	}
 
 	@Override
 	public boolean visitEnd() throws IOException {
+		writePendingElementMetadata(false);
 		close();
 
 		return true;
@@ -106,7 +119,28 @@ public void visitDstName(MappedElementKind targetKind, int namespace, String nam
 	}
 
 	@Override
-	public abstract boolean visitElementContent(MappedElementKind targetKind) throws IOException;
+	public boolean visitElementContent(MappedElementKind targetKind) throws IOException {
+		if (targetKind == MappedElementKind.CLASS) {
+			writeMismatchedOrMissingClasses();
+		} else if (targetKind == MappedElementKind.FIELD || targetKind == MappedElementKind.METHOD) {
+			writer.write(' ');
+			writer.write(desc);
+		}
+
+		return true;
+	}
+
+	@Override
+	public void visitElementMetadata(MappedElementKind target, String key, int namespace, String value) {
+		if (namespace != 0) return;
+
+		StandardProperty property = StandardProperties.getById(key);
+		if (property == null) return;
+		if (!property.isApplicableTo(getFormat(), target)) return;
+
+		key = property.getNameFor(getFormat(), target);
+		elementMetadata.put(property, value);
+	}
 
 	protected static int getNextOuterEnd(String name, int startPos) {
 		int pos;
@@ -121,6 +155,7 @@ protected static int getNextOuterEnd(String name, int startPos) {
 
 	@Override
 	public void visitComment(MappedElementKind targetKind, String comment) throws IOException {
+		writePendingElementMetadata(true);
 		int start = 0;
 		int pos;
 
@@ -150,7 +185,9 @@ public void visitComment(MappedElementKind targetKind, String comment) throws IO
 				if (start < end) writer.write(comment, start, end - start);
 			}
 
-			writer.write('\n');
+			if (pos >= 0) {
+				writer.write('\n');
+			}
 
 			start = end + 1;
 		} while (pos >= 0);
@@ -159,6 +196,7 @@ public void visitComment(MappedElementKind targetKind, String comment) throws IO
 	protected void writeMismatchedOrMissingClasses() throws IOException {
 		indent = 0;
 		int srcStart = 0;
+		boolean writeNewLines = false;
 
 		do {
 			int srcEnd = getNextOuterEnd(srcClassName, srcStart);
@@ -167,6 +205,11 @@ protected void writeMismatchedOrMissingClasses() throws IOException {
 
 			if (!lastWrittenClass.regionMatches(srcStart, srcClassName, srcStart, srcLen) // writtenPart.startsWith(srcPart)
 					|| srcEnd < lastWrittenClass.length() && lastWrittenClass.charAt(srcEnd) != '$') { // no trailing characters in writtenPart -> startsWith = equals
+				if (writeNewLines) {
+					writer.write('\n');
+				}
+
+				writeNewLines = true;
 				writeIndent(0);
 				writer.write("CLASS ");
 				writer.write(srcClassName, srcStart, srcLen);
@@ -192,7 +235,7 @@ protected void writeMismatchedOrMissingClasses() throws IOException {
 					}
 				}
 
-				writer.write('\n');
+				writePendingElementMetadata(false);
 			}
 
 			indent++;
@@ -203,6 +246,22 @@ protected void writeMismatchedOrMissingClasses() throws IOException {
 		dstName = null;
 	}
 
+	protected void writePendingElementMetadata(boolean appendLineBreak) throws IOException {
+		if (!elementMetadata.isEmpty()) {
+			for (Map.Entry entry : elementMetadata.entrySet()) {
+				if (entry.getKey() != StandardProperties.MODIFIED_ACCESS) throw new IllegalStateException();
+
+				writer.write(" ACC:");
+				writer.write(entry.getValue().toUpperCase(Locale.ROOT));
+				break;
+			}
+
+			elementMetadata.clear();
+		}
+
+		if (appendLineBreak) writer.write('\n');
+	}
+
 	protected void writeIndent(int extra) throws IOException {
 		for (int i = 0; i < indent + extra; i++) {
 			writer.write('\t');
@@ -212,6 +271,7 @@ protected void writeIndent(int extra) throws IOException {
 	protected static final Set flags = EnumSet.of(MappingFlag.NEEDS_UNIQUENESS, MappingFlag.NEEDS_SRC_FIELD_DESC, MappingFlag.NEEDS_SRC_METHOD_DESC);
 	protected static final String toEscape = "\\\n\r\0\t";
 	protected static final String escaped = "\\nr0t";
+	protected static final LinkedHashMap elementMetadata = new LinkedHashMap<>();
 
 	protected Writer writer;
 	protected int indent;
@@ -220,7 +280,6 @@ protected void writeIndent(int extra) throws IOException {
 	protected String currentClass;
 	protected String lastWrittenClass = "";
 	protected String dstName;
-	protected String[] dstNames;
 
 	protected String desc;
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileReader.java b/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileReader.java
index d1670b7d..9193e79a 100644
--- a/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileReader.java
+++ b/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileReader.java
@@ -27,6 +27,7 @@
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingUtil;
 import net.fabricmc.mappingio.MappingVisitor;
+import net.fabricmc.mappingio.format.StandardProperties;
 
 public final class ProGuardFileReader {
 	private ProGuardFileReader() {
@@ -115,6 +116,8 @@ private static void read(BufferedReader reader, String sourceNs, String targetNs
 							// lineStart, lineEndIncl, rtype
 							String part0 = parts[0];
 							int pos = part0.indexOf(':');
+							String lineStart = null;
+							String lineEnd = null;
 
 							String retType;
 
@@ -124,6 +127,8 @@ private static void read(BufferedReader reader, String sourceNs, String targetNs
 								int pos2 = part0.indexOf(':', pos + 1);
 								assert pos2 != -1;
 
+								lineStart = part0.substring(0, pos);
+								lineEnd = part0.substring(pos + 1, pos2);
 								retType = part0.substring(pos2 + 1);
 							}
 
@@ -141,7 +146,11 @@ private static void read(BufferedReader reader, String sourceNs, String targetNs
 								if (visitor.visitMethod(name, desc)) {
 									String mappedName = parts[3];
 									visitor.visitDstName(MappedElementKind.METHOD, 0, mappedName);
-									visitor.visitElementContent(MappedElementKind.METHOD);
+
+									if (visitor.visitElementContent(MappedElementKind.METHOD) && lineStart != null) {
+										visitor.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.START_LINE_NUMBER.getId(), 0, lineStart);
+										visitor.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.END_LINE_NUMBER.getId(), 0, lineEnd);
+									}
 								}
 							}
 						}
diff --git a/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileWriter.java b/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileWriter.java
index f41d4450..d0f6a738 100644
--- a/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileWriter.java
+++ b/src/main/java/net/fabricmc/mappingio/format/proguard/ProGuardFileWriter.java
@@ -18,6 +18,7 @@
 
 import java.io.IOException;
 import java.io.Writer;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 
@@ -25,6 +26,8 @@
 
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingWriter;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 
 /**
  * A mapping writer for the ProGuard mapping format.
@@ -36,8 +39,11 @@
  */
 public final class ProGuardFileWriter implements MappingWriter {
 	private final Writer writer;
-	private int dstNamespace = -1;
 	private final String dstNamespaceString;
+	private int dstNamespace = -1;
+	private MappedElementKind pendingMemberType;
+	/** srcName, srcDesc, dstName, [lineStart, lineEnd]. */
+	private String[] pendingMemberData = new String[5];
 
 	/**
 	 * Constructs a ProGuard mapping writer that uses
@@ -103,6 +109,8 @@ public void visitNamespaces(String srcNamespace, List dstNamespaces) thr
 
 	@Override
 	public boolean visitClass(String srcName) throws IOException {
+		writePendingMember();
+
 		writer.write(toJavaClassName(srcName));
 		writeArrow();
 		return true;
@@ -110,34 +118,21 @@ public boolean visitClass(String srcName) throws IOException {
 
 	@Override
 	public boolean visitField(String srcName, String srcDesc) throws IOException {
-		writeIndent();
-		writer.write(toJavaType(srcDesc));
-		writer.write(' ');
-		writer.write(srcName);
-		writeArrow();
+		writePendingMember();
+
+		pendingMemberType = MappedElementKind.FIELD;
+		pendingMemberData[0] = srcName;
+		pendingMemberData[1] = srcDesc;
 		return true;
 	}
 
 	@Override
 	public boolean visitMethod(String srcName, String srcDesc) throws IOException {
-		Type type = Type.getMethodType(srcDesc);
-		writeIndent();
-		writer.write(toJavaType(type.getReturnType().getDescriptor()));
-		writer.write(' ');
-		writer.write(srcName);
-		writer.write('(');
-		Type[] args = type.getArgumentTypes();
-
-		for (int i = 0; i < args.length; i++) {
-			if (i > 0) {
-				writer.write(',');
-			}
-
-			writer.write(toJavaType(args[i].getDescriptor()));
-		}
+		writePendingMember();
 
-		writer.write(')');
-		writeArrow();
+		pendingMemberType = MappedElementKind.METHOD;
+		pendingMemberData[0] = srcName;
+		pendingMemberData[1] = srcDesc;
 		return true;
 	}
 
@@ -162,11 +157,21 @@ public void visitDstName(MappedElementKind targetKind, int namespace, String nam
 		if (targetKind == MappedElementKind.CLASS) {
 			writer.write(toJavaClassName(name));
 			writer.write(':');
+			writer.write('\n');
 		} else {
-			writer.write(name);
+			pendingMemberData[2] = name;
 		}
+	}
 
-		writer.write('\n');
+	@Override
+	public void visitElementMetadata(MappedElementKind target, String key, int namespace, String value) {
+		StandardProperty property = StandardProperties.getById(key);
+
+		if (property == StandardProperties.START_LINE_NUMBER) {
+			pendingMemberData[3] = value;
+		} else if (property == StandardProperties.END_LINE_NUMBER) {
+			pendingMemberData[4] = value;
+		}
 	}
 
 	@Override
@@ -174,6 +179,63 @@ public void visitComment(MappedElementKind targetKind, String comment) throws IO
 		// ignored
 	}
 
+	@Override
+	public boolean visitEnd() throws IOException {
+		writePendingMember();
+		return true;
+	}
+
+	private void writePendingMember() throws IOException {
+		if (pendingMemberType == null) return;
+		String srcName = pendingMemberData[0];
+		String srcDesc = pendingMemberData[1];
+		String dstName = pendingMemberData[2];
+		String startLine = pendingMemberData[3];
+		String endLine = pendingMemberData[4];
+
+		writeIndent();
+
+		if (startLine != null && endLine != null) {
+			writer.write(startLine);
+			writer.write(':');
+			writer.write(endLine);
+			writer.write(':');
+		}
+
+		if (pendingMemberType == MappedElementKind.FIELD) {
+			writer.write(toJavaType(srcDesc));
+			writer.write(' ');
+			writer.write(srcName);
+			writeArrow();
+		} else {
+			Type type = Type.getMethodType(srcDesc);
+			writer.write(toJavaType(type.getReturnType().getDescriptor()));
+			writer.write(' ');
+			writer.write(srcName);
+			writer.write('(');
+			Type[] args = type.getArgumentTypes();
+
+			for (int i = 0; i < args.length; i++) {
+				if (i > 0) {
+					writer.write(',');
+				}
+
+				writer.write(toJavaType(args[i].getDescriptor()));
+			}
+
+			writer.write(')');
+			writeArrow();
+		}
+
+		if (dstName != null) {
+			writer.write(dstName);
+			writer.write('\n');
+		}
+
+		Arrays.fill(pendingMemberData, null);
+		pendingMemberType = null;
+	}
+
 	private void writeArrow() throws IOException {
 		writer.write(" -> ");
 	}
diff --git a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileReader.java b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileReader.java
index 56e2517c..9d807b6d 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileReader.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileReader.java
@@ -26,6 +26,9 @@
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingVisitor;
 import net.fabricmc.mappingio.format.ColumnFileReader;
+import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 import net.fabricmc.mappingio.tree.MappingTree;
 import net.fabricmc.mappingio.tree.MemoryMappingTree;
 
@@ -134,28 +137,27 @@ private static void read(ColumnFileReader reader, MappingVisitor visitor) throws
 						}
 					} else {
 						String line = reader.nextCol();
-						final String prefix = "# INTERMEDIARY-COUNTER ";
-						String[] parts;
-
-						if (line.startsWith(prefix)
-								&& (parts = line.substring(prefix.length()).split(" ")).length == 2) {
-							String property = null;
-
-							switch (parts[0]) {
-							case "class":
-								property = nextIntermediaryClassProperty;
-								break;
-							case "field":
-								property = nextIntermediaryFieldProperty;
-								break;
-							case "method":
-								property = nextIntermediaryMethodProperty;
-								break;
+
+						if (line.startsWith("# ") && line.length() >= 3 && line.charAt(3) != ' ') { // Metadata
+							line = line.substring(2);
+							String[] parts = line.split(" ");
+							String value = parts[parts.length - 1];
+							String key = line.substring(0, line.lastIndexOf(value));
+
+							if (key.isEmpty()) {
+								String oldValue = value;
+								value = key;
+								key = oldValue;
 							}
 
+							StandardProperty property = StandardProperties.getByName(key);
+
 							if (property != null) {
-								visitor.visitMetadata(property, parts[1]);
+								if (!property.isApplicableTo(format)) continue; // How did it get there?
+								key = property.getId();
 							}
+
+							visitor.visitMetadata(key, value);
 						}
 					}
 				}
@@ -180,7 +182,5 @@ private static void readDstNames(ColumnFileReader reader, MappedElementKind subj
 		}
 	}
 
-	static final String nextIntermediaryClassProperty = "next-intermediary-class";
-	static final String nextIntermediaryFieldProperty = "next-intermediary-field";
-	static final String nextIntermediaryMethodProperty = "next-intermediary-method";
+	private static final MappingFormat format = MappingFormat.TINY_FILE;
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileWriter.java b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileWriter.java
index bcdf2406..2685c033 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileWriter.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny1FileWriter.java
@@ -26,6 +26,9 @@
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingWriter;
+import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 
 public final class Tiny1FileWriter implements MappingWriter {
 	public Tiny1FileWriter(Writer writer) {
@@ -59,30 +62,18 @@ public void visitNamespaces(String srcNamespace, List dstNamespaces) thr
 
 	@Override
 	public void visitMetadata(String key, String value) throws IOException {
-		switch (key) {
-		case Tiny1FileReader.nextIntermediaryClassProperty:
-		case Tiny1FileReader.nextIntermediaryFieldProperty:
-		case Tiny1FileReader.nextIntermediaryMethodProperty:
-			write("# INTERMEDIARY-COUNTER ");
-
-			switch (key) {
-			case Tiny1FileReader.nextIntermediaryClassProperty:
-				write("class");
-				break;
-			case Tiny1FileReader.nextIntermediaryFieldProperty:
-				write("field");
-				break;
-			case Tiny1FileReader.nextIntermediaryMethodProperty:
-				write("method");
-				break;
-			default:
-				throw new IllegalStateException();
-			}
+		StandardProperty property = StandardProperties.getById(key);
 
-			write(" ");
-			write(value);
-			writeLn();
+		if (property != null) {
+			if (!property.isApplicableTo(format)) return;
+			key = property.getNameFor(format);
 		}
+
+		write("# ");
+		write(key);
+		write(" ");
+		write(value);
+		writeLn();
 	}
 
 	@Override
@@ -190,7 +181,8 @@ private void writeTab() throws IOException {
 		writer.write('\t');
 	}
 
-	private static final Set flags = EnumSet.of(MappingFlag.NEEDS_SRC_FIELD_DESC, MappingFlag.NEEDS_SRC_METHOD_DESC);
+	private static final Set flags = EnumSet.of(MappingFlag.NEEDS_UNIQUENESS, MappingFlag.NEEDS_SRC_FIELD_DESC, MappingFlag.NEEDS_SRC_METHOD_DESC);
+	private static final MappingFormat format = MappingFormat.TINY_FILE;
 
 	private final Writer writer;
 	private String classSrcName;
diff --git a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileReader.java b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileReader.java
index 6381d80f..7c174ea1 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileReader.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileReader.java
@@ -25,6 +25,9 @@
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingVisitor;
 import net.fabricmc.mappingio.format.ColumnFileReader;
+import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 
 public final class Tiny2FileReader {
 	private Tiny2FileReader() {
@@ -89,16 +92,22 @@ private static void read(ColumnFileReader reader, MappingVisitor visitor) throws
 			if (visitHeader || firstIteration) {
 				while (reader.nextLine(1)) {
 					if (!visitHeader) {
-						if (!escapeNames && reader.nextCol(Tiny2Util.escapedNamesProperty)) {
+						if (!escapeNames && reader.nextCol(StandardProperties.ESCAPED_NAMES.getNameFor(format))) {
 							escapeNames = true;
 						}
 					} else {
 						String key = reader.nextCol();
 						if (key == null) throw new IOException("missing property key in line "+reader.getLineNumber());
 						String value = reader.nextEscapedCol(); // may be missing -> null
+						StandardProperty property = StandardProperties.getByName(key);
 
-						if (key.equals(Tiny2Util.escapedNamesProperty)) {
-							escapeNames = true;
+						if (property != null) {
+							if (!property.isApplicableTo(format)) continue; // How did it get there?
+							key = property.getId();
+
+							if (property == StandardProperties.ESCAPED_NAMES) {
+								escapeNames = true;
+							}
 						}
 
 						visitor.visitMetadata(key, value);
@@ -215,4 +224,6 @@ private static void readDstNames(ColumnFileReader reader, MappedElementKind subj
 			if (!name.isEmpty()) visitor.visitDstName(subjectKind, dstNs, name);
 		}
 	}
+
+	private static final MappingFormat format = MappingFormat.TINY_2_FILE;
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileWriter.java b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileWriter.java
index aee1bdc0..2a37e263 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileWriter.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2FileWriter.java
@@ -26,6 +26,9 @@
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingFlag;
 import net.fabricmc.mappingio.MappingWriter;
+import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.format.StandardProperty;
 
 public final class Tiny2FileWriter implements MappingWriter {
 	public Tiny2FileWriter(Writer writer, boolean escapeNames) {
@@ -60,9 +63,16 @@ public void visitNamespaces(String srcNamespace, List dstNamespaces) thr
 
 	@Override
 	public void visitMetadata(String key, String value) throws IOException {
-		if (key.equals(Tiny2Util.escapedNamesProperty)) {
-			escapeNames = true;
-			wroteEscapedNamesProperty = true;
+		StandardProperty property = StandardProperties.getById(key);
+
+		if (property != null) {
+			if (!property.isApplicableTo(format)) return;
+			key = property.getNameFor(format);
+
+			if (property == StandardProperties.ESCAPED_NAMES) {
+				escapeNames = true;
+				wroteEscapedNamesProperty = true;
+			}
 		}
 
 		writeTab();
@@ -80,7 +90,7 @@ public void visitMetadata(String key, String value) throws IOException {
 	public boolean visitContent() throws IOException {
 		if (escapeNames && !wroteEscapedNamesProperty) {
 			write("\t");
-			write(Tiny2Util.escapedNamesProperty);
+			write(StandardProperties.ESCAPED_NAMES.getNameFor(format));
 			writeLn();
 		}
 
@@ -201,6 +211,7 @@ private void writeTabs(int count) throws IOException {
 	}
 
 	private static final Set flags = EnumSet.of(MappingFlag.NEEDS_HEADER_METADATA, MappingFlag.NEEDS_UNIQUENESS, MappingFlag.NEEDS_SRC_FIELD_DESC, MappingFlag.NEEDS_SRC_METHOD_DESC);
+	private static final MappingFormat format = MappingFormat.TINY_2_FILE;
 
 	private final Writer writer;
 	private boolean escapeNames;
diff --git a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2Util.java b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2Util.java
index 68bee840..341d297d 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2Util.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tiny/Tiny2Util.java
@@ -84,6 +84,4 @@ public static String unescape(String str) {
 
 	private static final String toEscape = "\\\n\r\0\t";
 	private static final String escaped = "\\nr0t";
-
-	static final String escapedNamesProperty = "escaped-names";
 }
diff --git a/src/main/java/net/fabricmc/mappingio/format/tsrg/TsrgFileReader.java b/src/main/java/net/fabricmc/mappingio/format/tsrg/TsrgFileReader.java
index 8ef79883..9eb1def0 100644
--- a/src/main/java/net/fabricmc/mappingio/format/tsrg/TsrgFileReader.java
+++ b/src/main/java/net/fabricmc/mappingio/format/tsrg/TsrgFileReader.java
@@ -28,6 +28,8 @@
 import net.fabricmc.mappingio.MappingUtil;
 import net.fabricmc.mappingio.MappingVisitor;
 import net.fabricmc.mappingio.format.ColumnFileReader;
+import net.fabricmc.mappingio.format.StandardProperties;
+import net.fabricmc.mappingio.tree.MappingTree;
 
 public final class TsrgFileReader {
 	private TsrgFileReader() {
@@ -185,8 +187,8 @@ private static void readMethod(ColumnFileReader reader, int dstNsCount, MappingV
 		while (reader.nextLine(2)) {
 			if (reader.hasExtraIndents()) continue;
 
-			if (reader.nextCol("static")) {
-				// method is static
+			if (reader.nextCol("static")) { // method is static
+				visitor.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.IS_STATIC.getId(), MappingTree.SRC_NAMESPACE_ID, "true");
 			} else {
 				int lvIndex = reader.nextIntCol();
 				if (lvIndex < 0) throw new IOException("missing/invalid parameter lv-index in line "+reader.getLineNumber());
diff --git a/src/main/java/net/fabricmc/mappingio/tree/MappingTree.java b/src/main/java/net/fabricmc/mappingio/tree/MappingTree.java
index b99d749b..26b98001 100644
--- a/src/main/java/net/fabricmc/mappingio/tree/MappingTree.java
+++ b/src/main/java/net/fabricmc/mappingio/tree/MappingTree.java
@@ -23,8 +23,28 @@ public interface MappingTree extends MappingTreeView {
 	String setSrcNamespace(String namespace);
 	List setDstNamespaces(List namespaces);
 
-	void addMetadata(String key, String value);
-	String removeMetadata(String key);
+	/**
+	 * @return A modifiable list of all metadata entries currently present in the tree.
+	 * The list's order is equal to the order in which the entries have been originally added.
+	 */
+	@Override
+	List extends MetadataEntry> getMetadata();
+
+	/**
+	 * @return An unmodifiable list of all metadata entries currently present
+	 * in the tree whose key is equal to the passed one.
+	 * The list's order is equal to the order in which the entries have been originally added.
+	 */
+	@Override
+	List extends MetadataEntry> getMetadata(String key);
+
+	void addMetadata(MetadataEntry entry);
+
+	/**
+	 * Removes all metadata entries whose key is equal to the passed one.
+	 * @return Whether or not any entries have been removed.
+	 */
+	boolean removeMetadata(String key);
 
 	@Override
 	Collection extends ClassMapping> getClasses();
@@ -59,11 +79,49 @@ default MethodMapping getMethod(String ownerName, String name, String desc, int
 		return (MethodMapping) MappingTreeView.super.getMethod(ownerName, name, desc, namespace);
 	}
 
+	interface MetadataEntry extends MetadataEntryView {
+	}
+
+	interface ElementMetadataEntry extends ElementMetadataEntryView {
+	}
+
 	interface ElementMapping extends ElementMappingView {
 		@Override
 		MappingTree getTree();
 
 		void setDstName(String name, int namespace);
+
+		/**
+		 * @return A modifiable list of all metadata entries currently associated with the element.
+		 * The list's order is equal to the order in which the entries have been originally added.
+		 */
+		@Override
+		List extends ElementMetadataEntry> getMetadata();
+
+		/**
+		 * @return An unmodifiable list of all metadata entries currently associated with the element
+		 * whose key is equal to the passed one.
+		 * The list's order is equal to the order in which the entries have been originally added.
+		 */
+		@Override
+		List extends ElementMetadataEntry> getMetadata(String key);
+
+		/**
+		 * @return An unmodifiable list of all metadata entries currently associated with the element
+		 * whose key and namespace are equal to the passed ones.
+		 * The list's order is equal to the order in which the entries have been originally added.
+		 */
+		@Override
+		List extends ElementMetadataEntry> getMetadata(String key, int namespace);
+
+		void addMetadata(ElementMetadataEntry entry);
+
+		/**
+		 * Removes all metadata entries whose key is equal to the passed one.
+		 * @return Whether or not any entries have been removed.
+		 */
+		boolean removeMetadata(String key);
+
 		void setComment(String comment);
 	}
 
diff --git a/src/main/java/net/fabricmc/mappingio/tree/MappingTreeView.java b/src/main/java/net/fabricmc/mappingio/tree/MappingTreeView.java
index 3de70989..fc146a69 100644
--- a/src/main/java/net/fabricmc/mappingio/tree/MappingTreeView.java
+++ b/src/main/java/net/fabricmc/mappingio/tree/MappingTreeView.java
@@ -19,7 +19,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
-import java.util.Map.Entry;
 
 import net.fabricmc.mappingio.MappingVisitor;
 
@@ -57,8 +56,8 @@ default String getNamespaceName(int id) {
 		return getDstNamespaces().get(id);
 	}
 
-	Collection> getMetadata();
-	String getMetadata(String key);
+	List extends MetadataEntryView> getMetadata();
+	List extends MetadataEntryView> getMetadata(String key);
 
 	Collection extends ClassMappingView> getClasses();
 	ClassMappingView getClass(String srcName);
@@ -182,6 +181,21 @@ default String mapDesc(CharSequence desc, int start, int end, int srcNamespace,
 		return ret.toString();
 	}
 
+	interface MetadataEntryView {
+		String getKey();
+		String getValue();
+	}
+
+	interface ElementMetadataEntryView {
+		String getKey();
+
+		/**
+		 * @return Values by namespace, offset by +1 (value for namespace x is at index x+1).
+		 */
+		String[] getValues();
+		String getValue(int namespace);
+	}
+
 	interface ElementMappingView {
 		MappingTreeView getTree();
 
@@ -206,6 +220,9 @@ default String getName(String namespace) {
 			}
 		}
 
+		List extends ElementMetadataEntryView> getMetadata();
+		List extends ElementMetadataEntryView> getMetadata(String key);
+		List extends ElementMetadataEntryView> getMetadata(String key, int namespace);
 		String getComment();
 	}
 
diff --git a/src/main/java/net/fabricmc/mappingio/tree/MemoryMappingTree.java b/src/main/java/net/fabricmc/mappingio/tree/MemoryMappingTree.java
index 9956cce0..d160bfa0 100644
--- a/src/main/java/net/fabricmc/mappingio/tree/MemoryMappingTree.java
+++ b/src/main/java/net/fabricmc/mappingio/tree/MemoryMappingTree.java
@@ -17,19 +17,20 @@
 package net.fabricmc.mappingio.tree;
 
 import java.io.IOException;
-import java.util.AbstractMap;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.IdentityHashMap;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import net.fabricmc.mappingio.MappedElementKind;
 import net.fabricmc.mappingio.MappingFlag;
@@ -52,8 +53,8 @@ public MemoryMappingTree(MappingTree src) {
 		setSrcNamespace(src.getSrcNamespace());
 		setDstNamespaces(src.getDstNamespaces());
 
-		for (Map.Entry entry : src.getMetadata()) {
-			addMetadata(entry.getKey(), entry.getValue());
+		for (MetadataEntry entry : src.getMetadata()) {
+			addMetadata(entry);
 		}
 
 		for (ClassMapping cls : src.getClasses()) {
@@ -205,37 +206,25 @@ private void updateDstNames(int[] nameMap) {
 	}
 
 	@Override
-	public Collection> getMetadata() {
+	public List extends MetadataEntry> getMetadata() {
 		return metadata;
 	}
 
 	@Override
-	public String getMetadata(String key) {
-		for (Map.Entry entry : metadata) {
-			if (entry.getKey().equals(key)) return entry.getValue();
-		}
-
-		return null;
+	public List extends MetadataEntry> getMetadata(String key) {
+		return Collections.unmodifiableList(metadata.stream()
+				.filter(entry -> entry.getKey().equals(key))
+				.collect(Collectors.toList()));
 	}
 
 	@Override
-	public void addMetadata(String key, String value) {
-		metadata.add(new AbstractMap.SimpleEntry<>(key, value));
+	public void addMetadata(MetadataEntry entry) {
+		metadata.add(entry);
 	}
 
 	@Override
-	public String removeMetadata(String key) {
-		for (Iterator> it = metadata.iterator(); it.hasNext(); ) {
-			Map.Entry entry = it.next();
-
-			if (entry.getKey().equals(key)) {
-				it.remove();
-
-				return entry.getValue();
-			}
-		}
-
-		return null;
+	public boolean removeMetadata(String key) {
+		return metadata.removeIf(entry -> entry.getKey().equals(key));
 	}
 
 	@Override
@@ -301,10 +290,28 @@ public ClassMapping removeClass(String srcName) {
 	@Override
 	public void accept(MappingVisitor visitor, VisitOrder order) throws IOException {
 		do {
+			boolean needsUniqueness = visitor.getFlags().contains(MappingFlag.NEEDS_UNIQUENESS);
+
 			if (visitor.visitHeader()) {
 				visitor.visitNamespaces(srcNamespace, dstNamespaces);
+				List metadataToVisit = metadata;
 
-				for (Map.Entry entry : metadata) {
+				if (needsUniqueness) {
+					metadataToVisit = new LinkedList<>();
+					Set addedKeys = new HashSet<>();
+
+					// Iterate last-to-first to construct a list of each key's latest occurrence.
+					for (int i = metadata.size() - 1; i >= 0; i--) {
+						MetadataEntry entry = metadata.get(i);
+
+						if (!addedKeys.contains(entry.getKey())) {
+							addedKeys.add(entry.getKey());
+							metadataToVisit.add(0, entry);
+						}
+					}
+				}
+
+				for (MetadataEntry entry : metadataToVisit) {
 					visitor.visitMetadata(entry.getKey(), entry.getValue());
 				}
 			}
@@ -391,7 +398,8 @@ public void visitNamespaces(String srcNamespace, List dstNamespaces) {
 
 	@Override
 	public void visitMetadata(String key, String value) {
-		this.metadata.add(new AbstractMap.SimpleEntry<>(key, value));
+		MetadataEntryImpl entry = new MetadataEntryImpl(key, value);
+		addMetadata(entry);
 	}
 
 	@Override
@@ -670,8 +678,17 @@ public boolean visitElementContent(MappedElementKind targetKind) throws IOExcept
 		return targetKind != MappedElementKind.CLASS || currentClass.getSrcName() != null; // reject classes that never received a src name
 	}
 
+	@Override
+	public void visitElementMetadata(MappedElementKind targetKind, String propertyKey, int namespace, String propertyValue) {
+		getCurrentEntry(targetKind).visitMetadata(propertyKey, namespace, propertyValue);
+	}
+
 	@Override
 	public void visitComment(MappedElementKind targetKind, String comment) {
+		getCurrentEntry(targetKind).setComment(comment);
+	}
+
+	private Entry> getCurrentEntry(MappedElementKind targetKind) {
 		Entry> entry;
 
 		switch (targetKind) {
@@ -685,8 +702,8 @@ public void visitComment(MappedElementKind targetKind, String comment) {
 			entry = currentEntry;
 		}
 
-		if (entry == null) throw new UnsupportedOperationException("Tried to visit comment before owning target");
-		entry.setComment(comment);
+		if (entry == null) throw new UnsupportedOperationException("Tried to visit element content before owning target");
+		return entry;
 	}
 
 	abstract static class Entry> implements ElementMapping {
@@ -728,6 +745,18 @@ public void setDstName(String name, int namespace) {
 
 		void resizeDstNames(int newSize) {
 			dstNames = Arrays.copyOf(dstNames, newSize);
+
+			for (ElementMetadataEntry entry : metadata) {
+				String[] resizedValues = Arrays.copyOf(entry.getValues(), newSize);
+
+				if (entry instanceof ElementMetadataEntryImpl) {
+					ElementMetadataEntryImpl impl = (ElementMetadataEntryImpl) entry;
+					impl.values = resizedValues;
+				} else {
+					metadata.add(metadata.indexOf(entry), new ElementMetadataEntryImpl(entry.getKey(), resizedValues));
+					metadata.remove(entry);
+				}
+			}
 		}
 
 		void updateDstNames(int[] map) {
@@ -744,6 +773,72 @@ void updateDstNames(int[] map) {
 			dstNames = newDstNames;
 		}
 
+		@Override
+		public List extends ElementMetadataEntry> getMetadata() {
+			return metadata;
+		}
+
+		@Override
+		public List extends ElementMetadataEntry> getMetadata(String key) {
+			return Collections.unmodifiableList(metadata.stream()
+					.filter(entry -> entry.getKey().equals(key))
+					.collect(Collectors.toList()));
+		}
+
+		@Override
+		public List extends ElementMetadataEntry> getMetadata(String key, int namespace) {
+			return Collections.unmodifiableList(metadata.stream()
+					.filter(entry -> entry.getKey().equals(key))
+					.filter(entry -> entry.getValue(namespace) != null)
+					.collect(Collectors.toList()));
+		}
+
+		@Override
+		public void addMetadata(ElementMetadataEntry entry) {
+			ElementMetadataEntry lastEntry;
+
+			if (metadata.isEmpty() || !(lastEntry = metadata.get(metadata.size() - 1)).getKey().equals(entry.getKey())) {
+				metadata.add(entry);
+				return;
+			}
+
+			for (int i = -1; i < entry.getValues().length - 1; i++) {
+				lastEntry = mergeMetadata(entry.getKey(), i, entry.getValue(i), lastEntry);
+			}
+		}
+
+		void visitMetadata(String key, int namespace, String value) {
+			ElementMetadataEntry lastEntry;
+
+			if (metadata.isEmpty() || !(lastEntry = metadata.get(metadata.size() - 1)).getKey().equals(key)) {
+				String[] values = new String[dstNames.length + 1];
+				values[namespace + 1] = value;
+				metadata.add(new ElementMetadataEntryImpl(key, values));
+				return;
+			}
+
+			mergeMetadata(key, namespace, value, lastEntry);
+		}
+
+		private ElementMetadataEntry mergeMetadata(String key, int namespace, String value, ElementMetadataEntry lastEntry) {
+			String[] values = lastEntry.getValues();
+
+			if (lastEntry.getValue(namespace) != null) {
+				// Can't merge, create new entry
+				values = new String[dstNames.length + 1];
+				lastEntry = new ElementMetadataEntryImpl(key, values);
+				metadata.add(lastEntry);
+			}
+
+			values[namespace + 1] = value;
+			return lastEntry;
+		}
+
+		@Override
+		public boolean removeMetadata(String key) {
+			return metadata.removeIf(entry -> entry.getKey().equals(key));
+		}
+
 		@Override
 		public final String getComment() {
 			return comment;
@@ -775,6 +870,44 @@ protected final boolean acceptElement(MappingVisitor visitor, String[] dstDescs)
 				return false;
 			}
 
+			List metadataToVisit = metadata;
+
+			if (visitor.getFlags().contains(MappingFlag.NEEDS_UNIQUENESS)) {
+				metadataToVisit = new LinkedList<>();
+				Set addedKeys = new HashSet<>();
+
+				// Iterate last-to-first to construct a list of each key's latest occurrence.
+				for (int i = metadata.size() - 1; i >= 0; i--) {
+					ElementMetadataEntry entry = metadata.get(i);
+
+					if (!addedKeys.contains(entry.getKey())) {
+						addedKeys.add(entry.getKey());
+						metadataToVisit.add(0, entry);
+					}
+				}
+			}
+
+			String lastKey = null;
+			String[] lastValues = null;
+
+			for (ElementMetadataEntry entry : metadataToVisit) {
+				String[] values = entry.getValues();
+
+				for (int ns = -1; ns < values.length - 1; ns++) {
+					if (entry.getKey().equals(lastKey)) {
+						if (values[ns+1] == null && lastValues != null) {
+							// Fill in holes
+							values[ns+1] = lastValues[ns+1];
+						}
+					} else {
+						lastKey = entry.getKey();
+					}
+
+					lastValues = values;
+					visitor.visitElementMetadata(kind, entry.getKey(), ns, entry.getValue(ns));
+				}
+			}
+
 			if (comment != null) visitor.visitComment(kind, comment);
 
 			return true;
@@ -794,6 +927,7 @@ protected void copyFrom(T o, boolean replace) {
 			// TODO: copy args+vars
 		}
 
+		private final List metadata = new ArrayList<>();
 		protected String srcName;
 		protected String[] dstNames;
 		protected String comment;
@@ -1699,6 +1833,85 @@ public String toString() {
 		private final int hash;
 	}
 
+	static final class MetadataEntryImpl implements MetadataEntry {
+		MetadataEntryImpl(String key, String value) {
+			this.key = key;
+			this.value = value;
+		}
+
+		@Override
+		public String getKey() {
+			return key;
+		}
+
+		@Override
+		public String getValue() {
+			return value;
+		}
+
+		@Override
+		public boolean equals(Object other) {
+			if (other == this) return true;
+
+			if (!(other instanceof MetadataEntryImpl)) {
+				return false;
+			}
+
+			MetadataEntryImpl entry = (MetadataEntryImpl) other;
+
+			return this.key.equals(entry.key) && this.value.equals(entry.value);
+		}
+
+		@Override
+		public int hashCode() {
+			return key.hashCode() | value.hashCode();
+		}
+
+		final String key;
+		final String value;
+	}
+
+	static final class ElementMetadataEntryImpl implements ElementMetadataEntry {
+		ElementMetadataEntryImpl(String key, String[] values) {
+			this.key = key;
+			this.values = values;
+		}
+
+		@Override
+		public String getKey() {
+			return key;
+		}
+
+		@Override
+		public String[] getValues() {
+			return values;
+		}
+
+		public String getValue(int namespace) {
+			return values[namespace + 1];
+		}
+
+		@Override
+		public boolean equals(Object other) {
+			if (other == this) return true;
+
+			if (!(other instanceof ElementMetadataEntryImpl)) {
+				return false;
+			}
+
+			ElementMetadataEntryImpl entry = (ElementMetadataEntryImpl) other;
+			return this.key.equals(entry.key) && this.values.equals(entry.values);
+		}
+
+		@Override
+		public int hashCode() {
+			return key.hashCode() | values.hashCode();
+		}
+
+		final String key;
+		String[] values;
+	}
+
 	static final class GlobalMemberKey {
 		GlobalMemberKey(ClassEntry owner, String name, String desc, boolean isField) {
 			this.owner = owner;
@@ -1739,7 +1952,7 @@ public String toString() {
 	private boolean indexByDstNames;
 	private String srcNamespace;
 	private List dstNamespaces = Collections.emptyList();
-	private final List> metadata = new ArrayList<>();
+	private final List metadata = new ArrayList<>();
 	private final Map classesBySrcName = new LinkedHashMap<>();
 	private Map[] classesByDstNames;
 
diff --git a/src/test/java/net/fabricmc/mappingio/MetadataTest.java b/src/test/java/net/fabricmc/mappingio/MetadataTest.java
new file mode 100644
index 00000000..aa9e7008
--- /dev/null
+++ b/src/test/java/net/fabricmc/mappingio/MetadataTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.mappingio;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import net.fabricmc.mappingio.tree.VisitableMappingTree;
+
+public class MetadataTest {
+	private static final Random random = new Random();
+	private static final List keys = new ArrayList<>();
+	private static final List values = new ArrayList<>();
+	private static VisitableMappingTree tree;
+
+	@BeforeAll
+	public static void setup() throws Exception {
+		tree = TestHelper.createTestTree();
+
+		for (int i = 0; i < 40; i++) {
+			String key = "key" + random.nextInt(3);
+			String value = "position" + i;
+
+			keys.add(key);
+			values.add(value);
+			tree.visitMetadata(key, value);
+		}
+	}
+
+	@Test
+	public void testOrder() throws Exception {
+		tree.accept(new NopMappingVisitor(true) {
+			@Override
+			public void visitMetadata(String key, String value) {
+				assertEquals(key, keys.get(visitCount));
+				assertEquals(value, values.get(visitCount));
+				visitCount++;
+			}
+
+			int visitCount;
+		});
+	}
+
+	@Test
+	public void testUniqueness() throws Exception {
+		tree.accept(new NopMappingVisitor(true) {
+			@Override
+			public Set getFlags() {
+				return EnumSet.of(MappingFlag.NEEDS_UNIQUENESS);
+			}
+
+			@Override
+			public void visitMetadata(String key, String value) {
+				assertFalse(visitedKeys.contains(key));
+				visitedKeys.add(key);
+			}
+
+			Set visitedKeys = new HashSet<>();
+		});
+	}
+}
diff --git a/src/test/java/net/fabricmc/mappingio/NopMappingVisitor.java b/src/test/java/net/fabricmc/mappingio/NopMappingVisitor.java
new file mode 100644
index 00000000..81d37607
--- /dev/null
+++ b/src/test/java/net/fabricmc/mappingio/NopMappingVisitor.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2023 FabricMC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.fabricmc.mappingio;
+
+import java.io.IOException;
+import java.util.List;
+
+public class NopMappingVisitor implements MappingVisitor {
+	NopMappingVisitor(boolean visitSubVisitors) {
+		this.visitSubVisitors = visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitHeader() throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public void visitNamespaces(String srcNamespace, List dstNamespaces) throws IOException {
+	}
+
+	@Override
+	public boolean visitContent() throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitClass(String srcName) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitField(String srcName, String srcDesc) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitMethod(String srcName, String srcDesc) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitMethodArg(int argPosition, int lvIndex, String srcName) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public boolean visitMethodVar(int lvtRowIndex, int lvIndex, int startOpIdx, int endOpIdx, String srcName) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public void visitDstName(MappedElementKind targetKind, int namespace, String name) throws IOException {
+	}
+
+	@Override
+	public boolean visitElementContent(MappedElementKind targetKind) throws IOException {
+		return visitSubVisitors;
+	}
+
+	@Override
+	public void visitComment(MappedElementKind targetKind, String comment) throws IOException {
+	}
+
+	protected final boolean visitSubVisitors;
+}
diff --git a/src/test/java/net/fabricmc/mappingio/TestHelper.java b/src/test/java/net/fabricmc/mappingio/TestHelper.java
index 0cf4ea4d..f7930496 100644
--- a/src/test/java/net/fabricmc/mappingio/TestHelper.java
+++ b/src/test/java/net/fabricmc/mappingio/TestHelper.java
@@ -20,9 +20,12 @@
 import java.net.URISyntaxException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Arrays;
 
 import net.fabricmc.mappingio.format.MappingFormat;
+import net.fabricmc.mappingio.format.StandardProperties;
 import net.fabricmc.mappingio.tree.MappingTree;
+import net.fabricmc.mappingio.tree.MemoryMappingTree;
 
 public final class TestHelper {
 	public static Path getResource(String slashPrefixedResourcePath) {
@@ -33,6 +36,42 @@ public static Path getResource(String slashPrefixedResourcePath) {
 		}
 	}
 
+	public static MemoryMappingTree createTestTree() {
+		MemoryMappingTree tree = new MemoryMappingTree();
+		tree.visitNamespaces(MappingUtil.NS_SOURCE_FALLBACK, Arrays.asList(MappingUtil.NS_TARGET_FALLBACK));
+
+		tree.visitClass("class_1");
+		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass1");
+		tree.visitElementMetadata(MappedElementKind.CLASS, StandardProperties.MODIFIED_ACCESS.getId(), 0, "public");
+
+		tree.visitField("field_1", "I");
+		tree.visitDstName(MappedElementKind.FIELD, 0, "renamedField");
+		tree.visitElementMetadata(MappedElementKind.FIELD, StandardProperties.MODIFIED_ACCESS.getId(), 0, "protected");
+
+		tree.visitMethod("method_1", "(F)I");
+		tree.visitDstName(MappedElementKind.METHOD, 0, "renamedMethod");
+		tree.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.MODIFIED_ACCESS.getId(), 0, "private");
+		tree.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.START_LINE_NUMBER.getId(), 0, "20");
+		tree.visitElementMetadata(MappedElementKind.METHOD, StandardProperties.END_LINE_NUMBER.getId(), 0, "25");
+
+		tree.visitMethodArg(0, 0, "param_1");
+		tree.visitDstName(MappedElementKind.METHOD_ARG, 0, "renamedParameter");
+
+		tree.visitMethodVar(0, 0, 0, 0, "var_1");
+		tree.visitDstName(MappedElementKind.METHOD_VAR, 0, "renamedVariable");
+
+		tree.visitClass("class_1$class_2");
+		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass1$RenamedInnerClass");
+
+		tree.visitField("field_1", "I");
+		tree.visitDstName(MappedElementKind.FIELD, 0, "renamedField2");
+
+		tree.visitClass("class_3");
+		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass2");
+
+		return tree;
+	}
+
 	public static void writeToDir(MappingTree tree, MappingFormat format, Path dir) throws IOException {
 		MappingWriter writer = MappingWriter.create(dir.resolve(format.name() + "." + format.fileExt), format);
 		tree.accept(writer);
diff --git a/src/test/java/net/fabricmc/mappingio/WriteTest.java b/src/test/java/net/fabricmc/mappingio/WriteTest.java
index 19bacaca..100e6890 100644
--- a/src/test/java/net/fabricmc/mappingio/WriteTest.java
+++ b/src/test/java/net/fabricmc/mappingio/WriteTest.java
@@ -17,14 +17,12 @@
 package net.fabricmc.mappingio;
 
 import java.nio.file.Path;
-import java.util.Arrays;
 
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import net.fabricmc.mappingio.format.MappingFormat;
-import net.fabricmc.mappingio.tree.MemoryMappingTree;
 import net.fabricmc.mappingio.tree.VisitableMappingTree;
 
 public class WriteTest {
@@ -34,32 +32,7 @@ public class WriteTest {
 
 	@BeforeAll
 	public static void setup() throws Exception {
-		tree = new MemoryMappingTree();
-		tree.visitNamespaces(MappingUtil.NS_SOURCE_FALLBACK, Arrays.asList(MappingUtil.NS_TARGET_FALLBACK));
-
-		tree.visitClass("class_1");
-		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass1");
-
-		tree.visitField("field_1", "I");
-		tree.visitDstName(MappedElementKind.FIELD, 0, "renamedField");
-
-		tree.visitMethod("method_1", "(F)I");
-		tree.visitDstName(MappedElementKind.METHOD, 0, "renamedMethod");
-
-		tree.visitMethodArg(0, 0, "param_1");
-		tree.visitDstName(MappedElementKind.METHOD_ARG, 0, "renamedParameter");
-
-		tree.visitMethodVar(0, 0, 0, 0, "param_1");
-		tree.visitDstName(MappedElementKind.METHOD_VAR, 0, "renamedVariable");
-
-		tree.visitClass("class_1$class_2");
-		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass1$RenamedInnerClass2");
-
-		tree.visitField("field_1", "I");
-		tree.visitDstName(MappedElementKind.FIELD, 0, "renamedField2");
-
-		tree.visitClass("class_3");
-		tree.visitDstName(MappedElementKind.CLASS, 0, "RenamedClass3");
+		tree = TestHelper.createTestTree();
 	}
 
 	@Test
@@ -82,6 +55,11 @@ public void tinyV2File() throws Exception {
 		write(MappingFormat.TINY_2_FILE);
 	}
 
+	@Test
+	public void proguardFile() throws Exception {
+		write(MappingFormat.PROGUARD_FILE);
+	}
+
 	private void write(MappingFormat format) throws Exception {
 		TestHelper.writeToDir(tree, format, dir);
 	}