diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifier.java b/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifier.java
new file mode 100644
index 00000000..598ac994
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifier.java
@@ -0,0 +1,38 @@
+package net.ornithemc.osl.core.api.util;
+
+/**
+ * Namespaced identifiers are two-part strings that uniquely point to content in Minecraft.
+ * The two parts are the namespace and the identifier. They can be combined into a single
+ * string representation as namespace:identifier (the namespace, followed by the identifier,
+ * separated by a colon).
+ *
+ * A namespace is a domain for content. It is used not to point to specific content, but to
+ * differentiate between different content sources or publishers. The use of namespaces can
+ * prevent conflicts between mods, resource packs, or data packs, in cases where the same
+ * identifier is used.
+ *
+ * The identifier is a unique name for content within a namespace. It should be descriptive
+ * to avoid naming conflicts with other content. The preferred format is snake_case.
+ *
+ * Namespaces may only contain alphanumeric characters [a-zA-Z0-9] and special characters
+ * [-._]. Identifiers may also contain the special character [/].
+ */
+public interface NamespacedIdentifier {
+
+ /**
+ * The separator between the namespace and identifier in the {@code String}
+ * representation of a {@code NamespacedIdentifier}.
+ */
+ static char SEPARATOR = ':';
+
+ /**
+ * @return the namespace of this {@code NamespacedIdentifier}.
+ */
+ String namespace();
+
+ /**
+ * @return the identifier of this {@code NamespacedIdentifier}.
+ */
+ String identifier();
+
+}
diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifiers.java b/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifiers.java
new file mode 100644
index 00000000..691ae091
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/api/util/NamespacedIdentifiers.java
@@ -0,0 +1,146 @@
+package net.ornithemc.osl.core.api.util;
+
+import java.util.Comparator;
+
+import net.ornithemc.osl.core.impl.util.NamespacedIdentifierException;
+import net.ornithemc.osl.core.impl.util.NamespacedIdentifierParseException;
+import net.ornithemc.osl.core.impl.util.NamespacedIdentifierImpl;
+
+/**
+ * Utility methods for creating and validating {@link NamespacedIdentifier}s.
+ */
+public final class NamespacedIdentifiers {
+
+ /**
+ * The {@code minecraft} namespace is used for Vanilla resources and ids.
+ */
+ public static final String MINECRAFT_NAMESPACE = "minecraft";
+ /**
+ * The default namespace of {@code NamespacedIdentifier}s.
+ * It is recommended to use a custom namespace for your own identifiers.
+ */
+ public static final String DEFAULT_NAMESPACE = MINECRAFT_NAMESPACE;
+
+ /**
+ * The maximum length of a {@code NamespacedIdentifier}'s namespace string.
+ */
+ public static final int MAX_LENGTH_NAMESPACE = Integer.MAX_VALUE;
+ /**
+ * The maximum length of a {@code NamespacedIdentifier} identifier string.
+ */
+ public static final int MAX_LENGTH_IDENTIFIER = Integer.MAX_VALUE;
+
+ /**
+ * A comparator for {@code NamespacedIdentifier}s, comparing first by identifier, then by namespace.
+ */
+ public static final Comparator COMPARATOR = (a, b) -> {
+ int c = a.identifier().compareTo(b.identifier());
+ if (c == 0) {
+ c = a.namespace().compareTo(b.namespace());
+ }
+
+ return c;
+ };
+
+ /**
+ * Construct and validate a {@code NamespacedIdentifier} with the default namespace and the given identifier.
+ *
+ * @return a {@code NamespacedIdentifier} with the default namespace and the given identifier.
+ * @throws NamespacedIdentifierException
+ * if the given identifier is invalid.
+ */
+ public static NamespacedIdentifier from(String identifier) {
+ return from(DEFAULT_NAMESPACE, identifier);
+ }
+
+ /**
+ * Construct and validate a {@code NamespacedIdentifier} from the given namespace and identifier.
+ *
+ * @return a {@code NamespacedIdentifier} with the given namespace and identifier.
+ * @throws NamespacedIdentifierException
+ * if the given namespace or identifier is invalid.
+ */
+ public static NamespacedIdentifier from(String namespace, String identifier) {
+ return new NamespacedIdentifierImpl(
+ validateNamespace(namespace),
+ validateIdentifier(identifier)
+ );
+ }
+
+ /**
+ * Parse a {@code NamespacedIdentifier} from the given {@code String}.
+ * The returned identifier is always valid. If no valid identifier can
+ * be parsed from the given string, an exception is thrown.
+ *
+ * @return the {@code NamespacedIdentifier}} represented by the {@code String}.
+ * @throws NamespacedIdentifierParseException
+ * if no valid {@code NamespacedIdentifier} can be parsed from the given {@code String}.
+ */
+ public static NamespacedIdentifier parse(String s) {
+ int i = s.indexOf(NamespacedIdentifier.SEPARATOR);
+
+ try {
+ if (i < 0) {
+ return from(s.substring(i + 1));
+ } else if (i > 0) {
+ return from(s.substring(0, i), s.substring(i + 1));
+ } else {
+ throw NamespacedIdentifierParseException.invalid(s, "badly formatted");
+ }
+ } catch (NamespacedIdentifierException e) {
+ throw NamespacedIdentifierParseException.invalid(s, e);
+ }
+ }
+
+ /**
+ * Check whether the given {@code NamespacedIdentifier} is valid, or throw an exception.
+ */
+ public static NamespacedIdentifier validate(NamespacedIdentifier id) {
+ try {
+ validateNamespace(id.namespace());
+ validateIdentifier(id.identifier());
+
+ return id;
+ } catch (NamespacedIdentifierException e) {
+ throw NamespacedIdentifierException.invalid(id, e);
+ }
+ }
+
+ /**
+ * Check that the given namespace is valid for a {@code NamespacedIdentifier}.
+ */
+ public static String validateNamespace(String namespace) {
+ if (namespace == null || namespace.isEmpty()) {
+ throw NamespacedIdentifierException.invalidNamespace(namespace, "null or empty");
+ }
+ if (namespace.length() > MAX_LENGTH_NAMESPACE) {
+ throw NamespacedIdentifierException.invalidNamespace(namespace, "length " + namespace.length() + " is greater than maximum allowed " + MAX_LENGTH_NAMESPACE);
+ }
+ if (!namespace.chars().allMatch(chr -> chr == '-' || chr == '.' || chr == '_' || (chr >= 'a' && chr <= 'z') || (chr >= 'A' && chr <= 'Z') || (chr >= '0' && chr <= '9'))) {
+ throw NamespacedIdentifierException.invalidNamespace(namespace, "contains illegal characters - only [a-zA-Z0-9-._] are allowed");
+ }
+
+ return namespace;
+ }
+
+ /**
+ * Check that the given identifier is valid for a {@code NamespacedIdentifier}.
+ */
+ public static String validateIdentifier(String identifier) {
+ if (identifier == null || identifier.isEmpty()) {
+ throw NamespacedIdentifierException.invalidIdentifier(identifier, "null or empty");
+ }
+ if (identifier.length() > MAX_LENGTH_IDENTIFIER) {
+ throw NamespacedIdentifierException.invalidIdentifier(identifier, "length " + identifier.length() + " is greater than maximum allowed " + MAX_LENGTH_IDENTIFIER);
+ }
+ if (!identifier.chars().allMatch(chr -> chr == '-' || chr == '.' || chr == '_' || chr == '/' || (chr >= 'a' && chr <= 'z') || (chr >= 'A' && chr <= 'Z') || (chr >= '0' && chr <= '9'))) {
+ throw NamespacedIdentifierException.invalidIdentifier(identifier, "contains illegal characters - only [a-zA-Z0-9-._/] are allowed");
+ }
+
+ return identifier;
+ }
+
+ public static boolean equals(NamespacedIdentifier a, NamespacedIdentifier b) {
+ return a.namespace().equals(b.namespace()) && a.identifier().equals(b.identifier());
+ }
+}
diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/impl/mixin/IdentifierMixin.java b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/mixin/IdentifierMixin.java
new file mode 100644
index 00000000..ce6a763d
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/mixin/IdentifierMixin.java
@@ -0,0 +1,47 @@
+package net.ornithemc.osl.core.impl.mixin;
+
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.Pseudo;
+import org.spongepowered.asm.mixin.Shadow;
+import org.spongepowered.asm.mixin.injection.At;
+import org.spongepowered.asm.mixin.injection.Inject;
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
+
+import net.minecraft.client.resource.Identifier;
+
+import net.ornithemc.osl.core.api.util.NamespacedIdentifier;
+import net.ornithemc.osl.core.api.util.NamespacedIdentifiers;
+
+@Pseudo // needed because Identifier does not exist in all versions
+@Mixin(Identifier.class)
+public class IdentifierMixin implements NamespacedIdentifier { // TODO: interface injection
+
+ @Shadow
+ private String namespace;
+ @Shadow
+ private String path;
+
+ @Inject(
+ method = "equals",
+ remap = false,
+ cancellable = true,
+ at = @At(
+ value = "HEAD"
+ )
+ )
+ private void osl$core$equalsNamespacedIdentifier(Object o, CallbackInfoReturnable cir) {
+ if (o instanceof NamespacedIdentifier) {
+ cir.setReturnValue(NamespacedIdentifiers.equals(this, (NamespacedIdentifier) o));
+ }
+ }
+
+ @Override
+ public String namespace() {
+ return namespace;
+ }
+
+ @Override
+ public String identifier() {
+ return path;
+ }
+}
diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierException.java b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierException.java
new file mode 100644
index 00000000..505ddc66
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierException.java
@@ -0,0 +1,31 @@
+package net.ornithemc.osl.core.impl.util;
+
+import net.ornithemc.osl.core.api.util.NamespacedIdentifier;
+
+@SuppressWarnings("serial")
+public class NamespacedIdentifierException extends RuntimeException {
+
+ private NamespacedIdentifierException(String message) {
+ super(message);
+ }
+
+ private NamespacedIdentifierException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static NamespacedIdentifierException invalid(NamespacedIdentifier id, Throwable cause) {
+ return new NamespacedIdentifierException("\'" + id + "\' is not a valid namespaced identifier", cause);
+ }
+
+ public static NamespacedIdentifierException invalid(NamespacedIdentifier id, String reason) {
+ return new NamespacedIdentifierException("\'" + id + "\' is not a valid namespaced identifier: " + reason);
+ }
+
+ public static NamespacedIdentifierException invalidNamespace(String namespace, String reason) {
+ return new NamespacedIdentifierException("\'" + namespace + "\' is not a valid namespace: " + reason);
+ }
+
+ public static NamespacedIdentifierException invalidIdentifier(String identifier, String reason) {
+ return new NamespacedIdentifierException("\'" + identifier + "\' is not a valid identifier: " + reason);
+ }
+}
diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierImpl.java b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierImpl.java
new file mode 100644
index 00000000..f373c436
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierImpl.java
@@ -0,0 +1,57 @@
+package net.ornithemc.osl.core.impl.util;
+
+import net.ornithemc.osl.core.api.util.NamespacedIdentifier;
+import net.ornithemc.osl.core.api.util.NamespacedIdentifiers;
+
+/**
+ * This class is a version-agnostic implementation of {@link NamespacedIdentifier}.
+ *
+ * This class is essentially equivalent to Vanilla's {@code Identifier}. It was added for a
+ * few reasons. For one, Vanilla's {@code Identifier} was only added in 13w21a, and then was
+ * client-only until 14w27b. Implementation details of this class also changed a few times,
+ * and only since 17w43a were {@code Identifiers} validated in any way.
+ *
This class is available for all Minecraft versions, without any version-specific
+ * implementation details.
+ */
+public final class NamespacedIdentifierImpl implements NamespacedIdentifier {
+
+ private final String namespace;
+ private final String identifier;
+
+ public NamespacedIdentifierImpl(String namespace, String identifier) {
+ this.namespace = namespace;
+ this.identifier = identifier;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof NamespacedIdentifier)) {
+ return false;
+ }
+ return NamespacedIdentifiers.equals(this, (NamespacedIdentifier) o);
+ }
+
+ @Override
+ public int hashCode() {
+ // this impl matches Vanilla Identifier's impl
+ return 31 * namespace.hashCode() + identifier.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return namespace + SEPARATOR + identifier;
+ }
+
+ @Override
+ public String namespace() {
+ return namespace;
+ }
+
+ @Override
+ public String identifier() {
+ return identifier;
+ }
+}
diff --git a/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierParseException.java b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierParseException.java
new file mode 100644
index 00000000..caf35242
--- /dev/null
+++ b/libraries/core/src/main/java/net/ornithemc/osl/core/impl/util/NamespacedIdentifierParseException.java
@@ -0,0 +1,21 @@
+package net.ornithemc.osl.core.impl.util;
+
+@SuppressWarnings("serial")
+public class NamespacedIdentifierParseException extends RuntimeException {
+
+ private NamespacedIdentifierParseException(String message) {
+ super(message);
+ }
+
+ private NamespacedIdentifierParseException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static NamespacedIdentifierParseException invalid(String s, Throwable cause) {
+ return new NamespacedIdentifierParseException("unable to parse namespaced identifier from \'" + s + "\'", cause);
+ }
+
+ public static NamespacedIdentifierParseException invalid(String s, String reason) {
+ return new NamespacedIdentifierParseException("unable to parse namespaced identifier from \'" + s + "\': " + reason);
+ }
+}
diff --git a/libraries/core/src/main/resources/fabric.mod.json b/libraries/core/src/main/resources/fabric.mod.json
index f5bf5e48..b4fd2ede 100644
--- a/libraries/core/src/main/resources/fabric.mod.json
+++ b/libraries/core/src/main/resources/fabric.mod.json
@@ -16,6 +16,9 @@
"license": "Apache-2.0",
"icon": "assets/ornithe-standard-libraries/core/icon.png",
"environment": "*",
+ "mixins": [
+ "osl.core.mixins.json"
+ ],
"depends": {
"fabricloader": ">=0.16.0"
}
diff --git a/libraries/core/src/main/resources/osl.core.mixins.json b/libraries/core/src/main/resources/osl.core.mixins.json
new file mode 100644
index 00000000..da16fd2f
--- /dev/null
+++ b/libraries/core/src/main/resources/osl.core.mixins.json
@@ -0,0 +1,16 @@
+{
+ "required": true,
+ "minVersion": "0.8",
+ "package": "net.ornithemc.osl.core.impl.mixin",
+ "compatibilityLevel": "JAVA_8",
+ "mixins": [
+ "IdentifierMixin"
+ ],
+ "client": [
+ ],
+ "server": [
+ ],
+ "injectors": {
+ "defaultRequire": 1
+ }
+}