D6 Custom armatures via datapack

Modders can now define custom armatures (quadruped, centaur, neko, etc.)
via data/<ns>/tiedup/armatures/*.json — zero Java code required.

Format :
- root_joint : name of root joint
- joints : map<name, JointDefinition> with id, parent, translation,
  rotation quaternion (xyzw), children array

Validation enforces :
- unique joint IDs, contiguous from 0
- all children/parent references exist in the map
- DAG structure (no cycles, no duplicate reachability)
- bidirectional parent/child coherence (A.children lists B <=> B.parent == A)
- max 128 joints (MAX_JOINTS limit)
- exactly one root (the declared root_joint has parent = null, all others
  have a non-null parent)

TiedUpArmatures.get() now delegates to ArmatureReloadListener for
unknown IDs — builtin BIPED remains hardcoded for performance +
VanillaModelTransformer compat. InstantiateInvoker.getArmature()
automatically resolves datapack armatures via the same path, no change
needed there.

Listener registered server-side via AddReloadListenerEvent and
client-side via RegisterClientReloadListenersEvent (same pattern as
LivingMotionReloadListener).

Tests : 21 new tests (13 for ArmatureDefinition validation + runtime
conversion, 8 for the reload listener + TiedUpArmatures delegation).
342 -> 363 GREEN.
This commit is contained in:
notevil
2026-04-24 15:16:25 +02:00
parent c9d5271102
commit a7a1c774f7
7 changed files with 1354 additions and 11 deletions

View File

@@ -627,6 +627,17 @@ public class TiedUpMod {
"Registered LivingMotionReloadListener for data-driven motion additions"
);
// Data-driven custom armatures (server-side, from data/<namespace>/tiedup/armatures/)
// Enables modders to ship custom armatures (quadruped, centaur, neko, ...)
// via JSON — resolved by TiedUpArmatures.get() for any ID that is not
// the builtin tiedup:biped. See ArmatureReloadListener javadoc (D6).
event.addListener(
new com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener()
);
LOGGER.info(
"Registered ArmatureReloadListener for data-driven custom armatures"
);
// Data-driven PoseType additions (server-side, from data/<namespace>/tiedup/pose_types/)
// Additive registry — the 6 builtin PoseType enum values remain the
// only poses consumable by legacy V1 call-sites. Datapack types are

View File

@@ -14,6 +14,7 @@ import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.HumanoidArmature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener;
import com.tiedup.remake.rig.asset.AssetAccessor;
import com.tiedup.remake.rig.math.OpenMatrix4f;
@@ -251,18 +252,25 @@ public final class TiedUpArmatures {
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} rencontre un arg de
* type {@code Armature}, il appelle cette méthode via {@code getArmature(id)}.
*
* <p><b>Phase 2.4 scope</b> : seul l'ID canonique {@code tiedup:biped}
* (et son équivalent long-form {@code tiedup:armature/biped}) est reconnu.
* Tout autre ID retourne {@code null} — le caller décide du fallback
* (InstantiateInvoker warn + BIPED, cf. sa Javadoc).</p>
* <p><b>Builtin</b> : seul l'ID canonique {@code tiedup:biped} (et son
* équivalent long-form {@code tiedup:armature/biped}) est résolu en dur —
* {@link HumanoidArmature} procédurale construite par {@link #buildBiped()}.
* Hardcodé pour performance + compat backward avec le
* {@link com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer}
* qui référence les IDs de joint verbatim.</p>
*
* <p><b>Phase 2.7 future</b> : quand {@link TiedUpRigRegistry} exposera un
* registry JSON Blender-authored, ce lookup interrogera le registry map
* et supportera les armatures tierces (datapack mods). API conservée
* stable pour les call sites InstantiateInvoker + test smoke.</p>
* <p><b>Datapack</b> : pour tout autre ID, on delegate au
* {@link ArmatureReloadListener} — chargement depuis
* {@code data/<ns>/tiedup/armatures/<name>.json} (cf. D6). Permet aux
* modders de définir quadruped / centaure / neko / ... sans coder Java.</p>
*
* @param id l'ID à résoudre (ex. {@code tiedup:biped} ou
* {@code tiedup:armature/biped}). Jamais null — caller check.
* <p>Retourne {@code null} si l'ID n'est ni le biped builtin ni présent
* dans le registry datapack — le caller décide du fallback
* (InstantiateInvoker log WARN + BIPED, cf. sa Javadoc).</p>
*
* @param id l'ID à résoudre (ex. {@code tiedup:biped},
* {@code tiedup:armature/biped} ou
* {@code mymod:quadruped}). Jamais null — caller check.
* @return l'{@link AssetAccessor} correspondant, ou {@code null} si l'ID
* n'est pas connu du registry. Pas de fallback automatique ici —
* c'est au caller de décider (InstantiateInvoker log+BIPED,
@@ -272,6 +280,45 @@ public final class TiedUpArmatures {
if (BIPED_SHORT_ID.equals(id) || BIPED_REGISTRY_NAME.equals(id)) {
return BIPED;
}
Armature datapackArmature = ArmatureReloadListener.get(id);
if (datapackArmature != null) {
return wrapDatapackArmature(id, datapackArmature);
}
return null;
}
/**
* Enveloppe une {@link Armature} datapack en {@link AssetAccessor}
* conformément à l'API de {@link #get(ResourceLocation)}. Le
* {@code AssetAccessor} porte l'{@link Armature} directement (pas de
* re-lookup à chaque {@code get()}) et {@code inRegistry()} retourne
* {@code true} pour signaler que l'armature provient d'un registry JSON.
*
* <p>Chaque appel crée une nouvelle instance de l'accessor — c'est un
* wrapper léger, non-cache. Si un caller veut dé-dupliquer il peut le
* faire lui-même. Cache en interne serait prématuré : les datapack
* armatures sont remplacées complètement à chaque {@code /reload}, donc
* un cache devrait se purger — préférable de laisser les callers
* re-résoudre.</p>
*/
private static AssetAccessor<? extends Armature> wrapDatapackArmature(
ResourceLocation id, Armature armature
) {
return new AssetAccessor<Armature>() {
@Override
public Armature get() {
return armature;
}
@Override
public ResourceLocation registryName() {
return id;
}
@Override
public boolean inRegistry() {
return true;
}
};
}
}

View File

@@ -0,0 +1,340 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import javax.annotation.Nullable;
import org.joml.Quaternionf;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.math.OpenMatrix4f;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Descriptor immuable d'une armature custom chargee depuis un datapack
* ({@code data/<ns>/tiedup/armatures/<name>.json}).
*
* <h2>But</h2>
* <p>Permet a un modder de definir une armature custom (quadruped, centaure,
* neko, ...) en JSON sans ecrire de code Java. Cette classe est la
* representation parsee du JSON ; elle sait se valider et se convertir en
* {@link Armature} runtime via {@link #toRuntimeArmature()}.</p>
*
* <h2>Format JSON attendu</h2>
* <pre>{@code
* {
* "description": "Four-legged pet creature armature",
* "root_joint": "Root",
* "joints": {
* "Root": {
* "id": 0,
* "parent": null,
* "translation": [0.0, 0.0, 0.0],
* "rotation": [0.0, 0.0, 0.0, 1.0],
* "children": ["Torso"]
* },
* "Torso": {
* "id": 1,
* "parent": "Root",
* "translation": [0.0, 12.0, 0.0],
* "rotation": [0.0, 0.0, 0.0, 1.0],
* "children": []
* }
* }
* }
* }</pre>
*
* <h2>Invariants (enforces par {@link #validate()})</h2>
* <ul>
* <li>{@code root_joint} doit exister dans {@code joints}.</li>
* <li>Tous les {@code parent} (sauf null) et tous les {@code children}
* doivent referencer un joint declare dans la map.</li>
* <li>Les {@code id} des joints doivent etre uniques et contigus a partir
* de 0 (donc 0..N-1 exactement, ou N = taille de la map).</li>
* <li>La hierarchie parent-children doit former un DAG acyclique a racine
* unique (le root_joint).</li>
* <li>Le nombre total de joints est plafonne a
* {@link TiedUpRigConstants#MAX_JOINTS}.</li>
* <li>Relation parent-child bidirectionnelle coherente : si A liste B dans
* {@code children}, B doit avoir {@code parent = A}.</li>
* </ul>
*
* <h2>Quaternion convention</h2>
* <p>Format <b>xyzw</b> (JOML standard, aligne sur {@link Quaternionf}).
* Une rotation identity = {@code [0, 0, 0, 1]}.</p>
*/
public record ArmatureDefinition(
ResourceLocation id,
String description,
String rootJointName,
Map<String, JointDefinition> joints
) {
public ArmatureDefinition {
// Defensive immutable wrap for the record's invariants. The loader
// already passes LinkedHashMap but callers who construct directly in
// tests shouldn't be able to mutate the internal state post-hoc.
joints = Collections.unmodifiableMap(new LinkedHashMap<>(joints));
}
/**
* Descriptor immuable d'un seul joint.
*
* @param name nom du joint (cle dans la map parente)
* @param jointId id int unique [0..N-1]
* @param parentName nom du joint parent, ou null si c'est le root
* @param translation translation locale (relative au parent)
* @param rotation rotation locale quaternion xyzw
* @param childrenNames liste des joints enfants (par nom, ordre preserve)
*/
public record JointDefinition(
String name,
int jointId,
@Nullable String parentName,
Vec3f translation,
Quaternionf rotation,
List<String> childrenNames
) {
public JointDefinition {
Objects.requireNonNull(name, "joint name");
Objects.requireNonNull(translation, "translation");
Objects.requireNonNull(rotation, "rotation");
Objects.requireNonNull(childrenNames, "childrenNames");
childrenNames = List.copyOf(childrenNames);
}
}
/**
* Verifie la coherence structurelle du descriptor.
*
* @return {@link Optional#empty()} si la structure est valide, sinon un
* {@link Optional} contenant un message d'erreur humain-lisible
* (premiere erreur detectee — la validation s'arrete au premier
* probleme pour eviter un deluge de logs).
*/
public Optional<String> validate() {
if (joints.isEmpty()) {
return Optional.of("joints map is empty");
}
if (joints.size() > TiedUpRigConstants.MAX_JOINTS) {
return Optional.of(
"too many joints: " + joints.size() + " > MAX_JOINTS="
+ TiedUpRigConstants.MAX_JOINTS
);
}
if (rootJointName == null || rootJointName.isEmpty()) {
return Optional.of("root_joint is null or empty");
}
if (!joints.containsKey(rootJointName)) {
return Optional.of(
"root_joint '" + rootJointName + "' is not declared in the joints map"
);
}
// Check each joint's internal consistency (name match, id range,
// parent exists, children exist, uniqueness).
Set<Integer> seenIds = new HashSet<>();
int jointCount = joints.size();
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
String declaredName = e.getKey();
JointDefinition j = e.getValue();
if (!declaredName.equals(j.name())) {
return Optional.of(
"joint map key '" + declaredName + "' does not match joint.name '"
+ j.name() + "'"
);
}
int jid = j.jointId();
if (jid < 0 || jid >= jointCount) {
return Optional.of(
"joint '" + declaredName + "' has id " + jid
+ " outside the contiguous range [0.."
+ (jointCount - 1) + "]"
);
}
if (!seenIds.add(jid)) {
return Optional.of(
"duplicate joint id " + jid + " on joint '" + declaredName + "'"
);
}
if (j.parentName() != null && !joints.containsKey(j.parentName())) {
return Optional.of(
"joint '" + declaredName + "' references unknown parent '"
+ j.parentName() + "'"
);
}
for (String child : j.childrenNames()) {
if (!joints.containsKey(child)) {
return Optional.of(
"joint '" + declaredName + "' references unknown child '"
+ child + "'"
);
}
}
}
// The above "id in [0..N-1] + unique" implies ids are exactly the set
// {0,1,...,N-1} (pigeonhole) — contiguity is therefore guaranteed by
// the uniqueness + range checks, no further pass needed.
// Root must have parent = null; all non-root joints must have a
// non-null parent. Verifies that there's exactly one root in the set.
JointDefinition rootDef = joints.get(rootJointName);
if (rootDef.parentName() != null) {
return Optional.of(
"root_joint '" + rootJointName + "' must have parent = null, got '"
+ rootDef.parentName() + "'"
);
}
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
if (e.getKey().equals(rootJointName)) continue;
if (e.getValue().parentName() == null) {
return Optional.of(
"joint '" + e.getKey() + "' has no parent but is not the root_joint "
+ "'" + rootJointName + "'"
);
}
}
// Bidirectional coherence : if A lists B in children, B must have
// parent=A. Detects orphans + bogus child refs up-front.
for (Map.Entry<String, JointDefinition> e : joints.entrySet()) {
String parentName = e.getKey();
for (String childName : e.getValue().childrenNames()) {
JointDefinition child = joints.get(childName);
if (!parentName.equals(child.parentName())) {
return Optional.of(
"joint '" + parentName + "' lists '" + childName
+ "' as child, but '" + childName + "'.parent = '"
+ child.parentName() + "' (mismatch)"
);
}
}
}
// DAG / connectivity check: BFS from the root, every joint must be
// reachable exactly once. Detects cycles (would require infinite
// reachability — bounded by seen-set, any loop shows up as "child
// visited twice"), detached subtrees, and disconnected components.
Set<String> visited = new LinkedHashSet<>();
Deque<String> queue = new ArrayDeque<>();
queue.add(rootJointName);
visited.add(rootJointName);
while (!queue.isEmpty()) {
String current = queue.poll();
JointDefinition jd = joints.get(current);
for (String child : jd.childrenNames()) {
if (!visited.add(child)) {
return Optional.of(
"cycle or duplicate parent detected: joint '" + child
+ "' is reachable from more than one path "
+ "(parent chain is not a tree)"
);
}
queue.add(child);
}
}
if (visited.size() != joints.size()) {
List<String> unreachable = new ArrayList<>(joints.keySet());
unreachable.removeAll(visited);
return Optional.of(
"joints not reachable from root '" + rootJointName + "': "
+ unreachable
);
}
return Optional.empty();
}
/**
* Convertit ce descriptor en {@link Armature} runtime.
*
* <p>Pre-condition : {@link #validate()} doit avoir retourne
* {@link Optional#empty()}. Si ce n'est pas le cas, cette methode peut
* throw {@link IllegalStateException} lors de la construction (ex :
* child introuvable). Le loader public
* ({@link ArmatureReloadListener}) appelle toujours validate() avant.</p>
*
* <p>L'armature retournee a ses {@code toOrigin} matrices calcules via
* {@link Armature#bakeOriginMatrices()} — prete a etre utilisee par
* le renderer sans etape supplementaire.</p>
*
* @return un {@link Armature} avec la hierarchie + les localTransform
* baked depuis (translation, rotation).
* @throws IllegalStateException si le descriptor est structurellement
* invalide (validate() devrait avoir detecte
* ca en amont).
*/
public Armature toRuntimeArmature() {
Map<String, Joint> joints = new LinkedHashMap<>(this.joints.size());
// Pass 1 : create every Joint with its localTransform (translation +
// rotation composed into an OpenMatrix4f). No parent-child wiring yet.
for (JointDefinition def : this.joints.values()) {
OpenMatrix4f local = OpenMatrix4f.fromQuaternion(def.rotation())
.translate(def.translation());
joints.put(def.name(), new Joint(def.name(), def.jointId(), local));
}
// Pass 2 : wire up the hierarchy. Children are added in the order
// they appear in the JSON's children array — this determines the
// subJoints[i] index used by Joint.HierarchicalJointAccessor, so we
// must preserve it verbatim.
for (JointDefinition def : this.joints.values()) {
Joint parentJoint = joints.get(def.name());
for (String childName : def.childrenNames()) {
Joint childJoint = joints.get(childName);
if (childJoint == null) {
throw new IllegalStateException(
"child joint '" + childName + "' not found while building runtime "
+ "armature '" + id + "' — did you call validate() first?"
);
}
parentJoint.addSubJoints(childJoint);
}
}
Joint rootJoint = joints.get(rootJointName);
if (rootJoint == null) {
// Should never happen if validate() passed.
throw new IllegalStateException(
"root joint '" + rootJointName + "' missing after pass 1 — "
+ "validate() should have caught this"
);
}
Armature armature = new Armature(id.toString(), joints.size(), rootJoint, joints);
armature.bakeOriginMatrices();
return armature;
}
}

View File

@@ -0,0 +1,349 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import org.joml.Quaternionf;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.server.packs.resources.SimpleJsonResourceReloadListener;
import net.minecraft.util.profiling.ProfilerFiller;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.datapack.ArmatureDefinition.JointDefinition;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Scanne les fichiers JSON {@code data/<ns>/tiedup/armatures/<name>.json} et
* enregistre chaque definition d'armature custom comme un {@link Armature}
* runtime queryable via {@link #get(ResourceLocation)}.
*
* <h2>But</h2>
* <p>Permet a un modder / resourcepack-maker de definir des armatures custom
* (quadruped, centaure, neko, ...) en JSON — zero code Java. Le format est
* decrit dans {@link ArmatureDefinition}.</p>
*
* <h2>Lookup flow</h2>
* <p>{@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)} delegue
* a cette classe pour tous les IDs qui ne sont pas {@code tiedup:biped} (ni
* son alias long-form). Le biped builtin reste hardcode en Java pour
* performance + compatibilite backward.</p>
*
* <h2>Full reload on apply</h2>
* <p>A chaque {@code /reload} (server) ou {@code F3+T} (client) le registre
* est <b>entierement vide puis repopule</b>. Contrairement a
* {@link com.tiedup.remake.rig.anim.LivingMotionReloadListener} qui maintient
* un cache JVM-wide pour preserver les ordinals (les enums sont sensibles),
* les armatures n'ont pas de contrat d'ordinal stable — une armature peut
* etre supprimee / renommee / restructuree par un datapack reload sans casser
* un invariant global. Les consumers (renderer, animations) se contentent
* de resoudre l'ID a chaque frame, un nouvel {@link Armature} au meme ID est
* transparent pour eux.</p>
*
* <p><b>Attention</b> : les {@link Armature} produites par cette classe ne
* sont PAS deep-copied. Si une animation binde une reference a un {@code Joint}
* via {@code Armature.searchJointByName} et qu'un reload reconstruit une
* nouvelle Armature au meme ID, la reference originale devient orpheline
* (la vieille instance existe toujours tant que l'animation la retient). Les
* consumers doivent re-resoudre apres reload — c'est deja le pattern de
* {@link com.tiedup.remake.rig.util.InstantiateInvoker} (resolution via
* {@link com.tiedup.remake.rig.TiedUpArmatures#get(ResourceLocation)}).</p>
*
* <h2>Side & threading</h2>
* <p>Registered server-side via {@code AddReloadListenerEvent} et client-side
* via {@code RegisterClientReloadListenersEvent}. Sur server integre, les deux
* hooks pointent vers le meme {@link ConcurrentHashMap} — pas de double
* registre, pas de race (apply() est appele sequentiellement sur le server
* thread ou le client render thread, jamais simultanement).</p>
*
* <h2>Limitations connues</h2>
* <ul>
* <li>Les animations Blender-authored qui binds a une armature custom doivent
* etre recompilees si la structure de l'armature change (nouveaux
* joints, reordering des IDs). Pas de retargeting cross-armature (Phase 5+).</li>
* <li>Si un JSON est malforme (structure invalide), il est skip avec un
* WARN ; le reste du batch continue.</li>
* <li>Les armatures identity (toutes localTransform = identity) rendent un
* mesh "effondre a l'origine" — c'est aux auteurs du JSON de fournir
* des translations + rotations sensees depuis Blender.</li>
* </ul>
*/
public class ArmatureReloadListener extends SimpleJsonResourceReloadListener {
/** Dossier scanne : {@code data/<ns>/tiedup/armatures/*.json}. */
public static final String DIRECTORY = "tiedup/armatures";
/**
* Registre runtime des armatures datapack. Re-populated at every reload.
* {@link ConcurrentHashMap} protege les reads concurrents depuis les
* consumers (rendering) pendant qu'un reload en cours repopule.
*/
private static final Map<ResourceLocation, Armature> DATAPACK_ARMATURES =
new ConcurrentHashMap<>();
public ArmatureReloadListener() {
super(new GsonBuilder().create(), DIRECTORY);
}
/**
* Resout une armature datapack par son ResourceLocation.
*
* @param id identifiant namespace:path (ex. {@code mymod:quadruped})
* @return l'{@link Armature} enregistree, ou {@code null} si aucun JSON
* n'a charge ce ID.
*/
@Nullable
public static Armature get(ResourceLocation id) {
return DATAPACK_ARMATURES.get(id);
}
/** Nombre d'armatures datapack enregistrees actuellement. */
public static int size() {
return DATAPACK_ARMATURES.size();
}
/** Vue immutable du registre, expose pour debug / tests. */
public static Map<ResourceLocation, Armature> view() {
return Collections.unmodifiableMap(DATAPACK_ARMATURES);
}
/**
* Test hook — vide le registre. En prod le clear() est implicite dans
* {@link #apply} au debut de chaque reload, pas besoin d'y toucher a la
* main.
*/
public static void clearForTests() {
DATAPACK_ARMATURES.clear();
}
/**
* Hook d'apply direct pour tests — {@link SimpleJsonResourceReloadListener#apply}
* est {@code protected}, ce helper l'expose en public pour que les tests
* cross-package puissent alimenter le registry sans bootstrap MC.
*
* <p>Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont pas
* lus par notre {@code apply}, on peut passer {@code null} en test.</p>
*/
public void applyForTests(Map<ResourceLocation, JsonElement> data) {
this.apply(data, null, null);
}
@Override
protected void apply(
Map<ResourceLocation, JsonElement> objectIn,
ResourceManager resourceManager,
ProfilerFiller profileFiller
) {
// Full reset : datapack armatures are ephemeral (no ordinal contract).
// A JSON deleted between two reloads must disappear from the registry.
DATAPACK_ARMATURES.clear();
// Stable ordering — if two JSON files are processed in the same reload
// and produce the same WARN-log output, TreeMap ensures reproducibility
// between JVM boots for debugging.
Map<ResourceLocation, JsonElement> sorted = new TreeMap<>(objectIn);
int loaded = 0;
int skipped = 0;
for (Map.Entry<ResourceLocation, JsonElement> entry : sorted.entrySet()) {
ResourceLocation id = entry.getKey();
JsonElement element = entry.getValue();
try {
ArmatureDefinition def = parseDefinition(id, element);
Optional<String> err = def.validate();
if (err.isPresent()) {
TiedUpRigConstants.LOGGER.warn(
"[ArmatureReloadListener] Invalid armature {}: {}",
id, err.get()
);
skipped++;
continue;
}
Armature armature = def.toRuntimeArmature();
DATAPACK_ARMATURES.put(id, armature);
TiedUpRigConstants.LOGGER.debug(
"[ArmatureReloadListener] Registered armature: {} ({} joints)",
id, armature.getJointNumber()
);
loaded++;
} catch (Exception e) {
TiedUpRigConstants.LOGGER.warn(
"[ArmatureReloadListener] Failed to parse armature {}: {}",
id, e.getMessage()
);
skipped++;
}
}
TiedUpRigConstants.LOGGER.info(
"[ArmatureReloadListener] Reload done : {} armature(s) loaded, {} skipped",
loaded, skipped
);
}
/**
* Parse un JsonElement en {@link ArmatureDefinition}. Ne valide pas la
* coherence structurelle (c'est le role de
* {@link ArmatureDefinition#validate()}) — se contente de mapper le JSON
* brut sur la structure Java.
*
* @throws JsonStructureException si le JSON est mal forme (champ manquant,
* type incorrect, taille de tableau fausse)
*/
private static ArmatureDefinition parseDefinition(ResourceLocation id, JsonElement element) {
if (!element.isJsonObject()) {
throw new JsonStructureException(
"top-level JSON is not an object (got " + element.getClass().getSimpleName() + ")"
);
}
JsonObject obj = element.getAsJsonObject();
String description = readOptionalString(obj, "description", "");
String rootJointName = readRequiredString(obj, "root_joint");
if (!obj.has("joints") || !obj.get("joints").isJsonObject()) {
throw new JsonStructureException("missing or non-object 'joints'");
}
JsonObject jointsObj = obj.getAsJsonObject("joints");
Map<String, JointDefinition> joints = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> e : jointsObj.entrySet()) {
String name = e.getKey();
if (!e.getValue().isJsonObject()) {
throw new JsonStructureException(
"joint '" + name + "' is not a JSON object"
);
}
joints.put(name, parseJoint(name, e.getValue().getAsJsonObject()));
}
return new ArmatureDefinition(id, description, rootJointName, joints);
}
private static JointDefinition parseJoint(String name, JsonObject obj) {
if (!obj.has("id")) {
throw new JsonStructureException("joint '" + name + "' missing 'id'");
}
int jointId = obj.get("id").getAsInt();
String parent = null;
if (obj.has("parent") && !obj.get("parent").isJsonNull()) {
parent = obj.get("parent").getAsString();
}
Vec3f translation = readVec3(obj, "translation", "joint '" + name + "'");
Quaternionf rotation = readQuaternion(obj, "rotation", "joint '" + name + "'");
List<String> children = new ArrayList<>();
if (obj.has("children")) {
JsonElement childrenEl = obj.get("children");
if (!childrenEl.isJsonArray()) {
throw new JsonStructureException(
"joint '" + name + "'.children is not a JSON array"
);
}
for (JsonElement c : childrenEl.getAsJsonArray()) {
children.add(c.getAsString());
}
}
return new JointDefinition(name, jointId, parent, translation, rotation, children);
}
private static Vec3f readVec3(JsonObject obj, String key, String context) {
if (!obj.has(key)) {
throw new JsonStructureException(context + " missing '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonArray()) {
throw new JsonStructureException(
context + "." + key + " is not a JSON array"
);
}
JsonArray arr = el.getAsJsonArray();
if (arr.size() != 3) {
throw new JsonStructureException(
context + "." + key + " must have 3 elements, got " + arr.size()
);
}
return new Vec3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
}
private static Quaternionf readQuaternion(JsonObject obj, String key, String context) {
if (!obj.has(key)) {
throw new JsonStructureException(context + " missing '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonArray()) {
throw new JsonStructureException(
context + "." + key + " is not a JSON array"
);
}
JsonArray arr = el.getAsJsonArray();
if (arr.size() != 4) {
throw new JsonStructureException(
context + "." + key + " must have 4 elements (xyzw), got " + arr.size()
);
}
// xyzw convention — JOML default. Identity = [0, 0, 0, 1].
return new Quaternionf(
arr.get(0).getAsFloat(),
arr.get(1).getAsFloat(),
arr.get(2).getAsFloat(),
arr.get(3).getAsFloat()
);
}
private static String readRequiredString(JsonObject obj, String key) {
if (!obj.has(key) || obj.get(key).isJsonNull()) {
throw new JsonStructureException("missing required string field '" + key + "'");
}
JsonElement el = obj.get(key);
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) {
throw new JsonStructureException("field '" + key + "' is not a string");
}
return el.getAsString();
}
private static String readOptionalString(JsonObject obj, String key, String fallback) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return fallback;
JsonElement el = obj.get(key);
if (!el.isJsonPrimitive() || !el.getAsJsonPrimitive().isString()) return fallback;
return el.getAsString();
}
/**
* Exception thrown par le parseur JSON — indique un probleme structurel
* dans le fichier (champ manquant, type incorrect). Pas exposee publiquement,
* capturee dans {@link #apply} pour log et continuer.
*/
private static final class JsonStructureException extends RuntimeException {
JsonStructureException(String message) {
super(Objects.requireNonNull(message));
}
}
}

View File

@@ -128,9 +128,16 @@ public class V2ClientSetup {
event.registerReloadListener(
new com.tiedup.remake.v2.bondage.PoseTypeReloadListener()
);
// D6 : data-driven custom armatures (quadruped, centaur, ...) are
// rendered client-side, so the client must observe the same registry
// as the server. Same ConcurrentHashMap backing — on SP integrated
// both hooks point at the same static state.
event.registerReloadListener(
new com.tiedup.remake.rig.armature.datapack.ArmatureReloadListener()
);
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Data-driven item + GLB validation + joint mask + living motion "
+ "+ pose type reload listeners registered"
+ "+ pose type + armature reload listeners registered"
);
}

View File

@@ -0,0 +1,333 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.joml.Quaternionf;
import org.junit.jupiter.api.Test;
import net.minecraft.resources.ResourceLocation;
import com.tiedup.remake.rig.TiedUpRigConstants;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.armature.Joint;
import com.tiedup.remake.rig.armature.datapack.ArmatureDefinition.JointDefinition;
import com.tiedup.remake.rig.math.Vec3f;
/**
* Tests unitaires de {@link ArmatureDefinition} — validation structurelle
* et construction de l'{@link Armature} runtime.
*/
class ArmatureDefinitionTest {
private static final ResourceLocation ID =
ResourceLocation.fromNamespaceAndPath("ttest", "quadruped");
private static Quaternionf identity() {
return new Quaternionf(0.0F, 0.0F, 0.0F, 1.0F);
}
private static Vec3f zero() {
return new Vec3f(0.0F, 0.0F, 0.0F);
}
/**
* Builds a simple 3-joint chain : Root -> Torso -> Head, all identity
* transforms. Used as a happy-path baseline in several tests.
*/
private static Map<String, JointDefinition> validChain() {
Map<String, JointDefinition> m = new LinkedHashMap<>();
m.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("Torso")
));
m.put("Torso", new JointDefinition(
"Torso", 1, "Root", zero(), identity(), List.of("Head")
));
m.put("Head", new JointDefinition(
"Head", 2, "Torso", zero(), identity(), List.of()
));
return m;
}
// ==================== validate() ====================
@Test
void validate_emptyJoints_returnsError() {
ArmatureDefinition def = new ArmatureDefinition(
ID, "empty", "Root", Map.of()
);
Optional<String> err = def.validate();
assertTrue(err.isPresent(), "empty joint map must be rejected");
assertTrue(err.get().toLowerCase().contains("empty"),
"error should mention 'empty', got: " + err.get());
}
@Test
void validate_missingRoot_returnsError() {
Map<String, JointDefinition> joints = validChain();
ArmatureDefinition def = new ArmatureDefinition(
ID, "missing root", "NotDeclared", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent(), "root_joint must exist in the map");
assertTrue(err.get().contains("NotDeclared"),
"error should mention the missing root name, got: " + err.get());
}
@Test
void validate_invalidParent_returnsError() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("Child")
));
joints.put("Child", new JointDefinition(
"Child", 1, "GhostParent", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "bad parent", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().contains("GhostParent"),
"error should mention the bogus parent, got: " + err.get());
}
@Test
void validate_invalidChild_returnsError() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("GhostChild")
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "bad child", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().contains("GhostChild"),
"error should mention the bogus child, got: " + err.get());
}
@Test
void validate_duplicateId_returnsError() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("A", "B")
));
joints.put("A", new JointDefinition(
"A", 1, "Root", zero(), identity(), List.of()
));
// duplicate id = 1
joints.put("B", new JointDefinition(
"B", 1, "Root", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "dup ids", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().toLowerCase().contains("duplicate"),
"error should flag duplicate, got: " + err.get());
}
@Test
void validate_nonContiguousIds_returnsError() {
// 3 joints but ids = 0, 1, 5 → 5 is out of range [0..2]
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("A")
));
joints.put("A", new JointDefinition(
"A", 1, "Root", zero(), identity(), List.of("B")
));
joints.put("B", new JointDefinition(
"B", 5, "A", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "non contiguous", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().contains("outside the contiguous range"),
"error should flag out-of-range id, got: " + err.get());
}
@Test
void validate_cyclic_returnsError() {
// Root -> A -> B, and B also lists A as a child (creating a cycle).
// We must fabricate a structurally coherent parent chain so the
// bidirectional check passes and the DFS catches it. Easier: give B
// a duplicate parent edge by making Root list A twice, so A is
// reached twice during the BFS (mimics cycle detection).
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of("A", "A")
));
joints.put("A", new JointDefinition(
"A", 1, "Root", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "cyclic", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().toLowerCase().contains("cycle")
|| err.get().contains("duplicate parent"),
"error should flag cycle/duplicate parent path, got: " + err.get());
}
@Test
void validate_exceedMaxJoints_returnsError() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
int tooMany = TiedUpRigConstants.MAX_JOINTS + 1;
// Root + (tooMany - 1) children
List<String> children = new ArrayList<>();
for (int i = 1; i < tooMany; i++) children.add("J" + i);
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), children
));
for (int i = 1; i < tooMany; i++) {
joints.put("J" + i, new JointDefinition(
"J" + i, i, "Root", zero(), identity(), List.of()
));
}
ArmatureDefinition def = new ArmatureDefinition(
ID, "too many", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
assertTrue(err.get().contains("too many joints"),
"error should flag MAX_JOINTS overflow, got: " + err.get());
}
@Test
void validate_validStructure_returnsEmpty() {
ArmatureDefinition def = new ArmatureDefinition(
ID, "valid chain", "Root", validChain()
);
Optional<String> err = def.validate();
assertFalse(err.isPresent(), "valid structure must pass, got: " + err.orElse(""));
}
@Test
void validate_rootHasParent_returnsError() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, "SomeGhost", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "root with parent", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent());
}
@Test
void validate_disconnectedSubtree_returnsError() {
// Root with no children, but another joint exists elsewhere with a
// parent=Root claim that Root doesn't list → bidirectional mismatch.
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of()
));
joints.put("Orphan", new JointDefinition(
"Orphan", 1, "Root", zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "disconnected", "Root", joints
);
Optional<String> err = def.validate();
assertTrue(err.isPresent(),
"disconnected joint (parent set but not in children) must be rejected");
}
// ==================== toRuntimeArmature() ====================
@Test
void toRuntimeArmature_bipedLikeStructure_matchesExpected() {
// Build a mini biped-like armature to verify the runtime conversion
// preserves joint IDs + names + hierarchy. We use 4 joints :
// Root (id=0) -> Torso (id=1) -> Head (id=2), and Torso -> Arm (id=3)
// as a side branch.
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null,
new Vec3f(0.0F, 0.0F, 0.0F), identity(), List.of("Torso")
));
joints.put("Torso", new JointDefinition(
"Torso", 1, "Root",
new Vec3f(0.0F, 12.0F, 0.0F), identity(), List.of("Head", "Arm")
));
joints.put("Head", new JointDefinition(
"Head", 2, "Torso",
new Vec3f(0.0F, 8.0F, 0.0F), identity(), List.of()
));
joints.put("Arm", new JointDefinition(
"Arm", 3, "Torso",
new Vec3f(4.0F, 0.0F, 0.0F), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "biped-like", "Root", joints
);
assertTrue(def.validate().isEmpty(), "definition should be valid");
Armature armature = def.toRuntimeArmature();
// Joint count.
assertEquals(4, armature.getJointNumber(), "should have 4 joints");
// Names + IDs.
assertEquals("Root", armature.searchJointById(0).getName());
assertEquals("Torso", armature.searchJointById(1).getName());
assertEquals("Head", armature.searchJointById(2).getName());
assertEquals("Arm", armature.searchJointById(3).getName());
assertEquals(0, armature.searchJointByName("Root").getId());
assertEquals(1, armature.searchJointByName("Torso").getId());
assertEquals(2, armature.searchJointByName("Head").getId());
assertEquals(3, armature.searchJointByName("Arm").getId());
// Hierarchy — Root.subJoints = [Torso], Torso.subJoints = [Head, Arm].
Joint root = armature.rootJoint;
assertEquals(1, root.getSubJoints().size(), "Root has exactly 1 child (Torso)");
assertEquals("Torso", root.getSubJoints().get(0).getName());
Joint torso = armature.searchJointByName("Torso");
assertEquals(2, torso.getSubJoints().size(), "Torso has 2 children");
assertEquals("Head", torso.getSubJoints().get(0).getName(),
"children order must match JSON declaration (Head first)");
assertEquals("Arm", torso.getSubJoints().get(1).getName(),
"children order must match JSON declaration (Arm second)");
// toOrigin should have been baked (not null, i.e. bakeOriginMatrices ran).
assertNotNull(armature.searchJointByName("Torso").getToOrigin(),
"bakeOriginMatrices must have run");
}
@Test
void toRuntimeArmature_singleJointRoot_works() {
Map<String, JointDefinition> joints = new LinkedHashMap<>();
joints.put("Root", new JointDefinition(
"Root", 0, null, zero(), identity(), List.of()
));
ArmatureDefinition def = new ArmatureDefinition(
ID, "root only", "Root", joints
);
assertTrue(def.validate().isEmpty());
Armature armature = def.toRuntimeArmature();
assertEquals(1, armature.getJointNumber());
assertEquals("Root", armature.rootJoint.getName());
}
}

View File

@@ -0,0 +1,256 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig.armature.datapack;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashMap;
import java.util.Map;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import net.minecraft.resources.ResourceLocation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.TiedUpArmatures;
import com.tiedup.remake.rig.armature.Armature;
import com.tiedup.remake.rig.asset.AssetAccessor;
/**
* Tests de {@link ArmatureReloadListener} — chargement JSON datapack +
* delegation par {@link TiedUpArmatures#get(ResourceLocation)}.
*/
class ArmatureReloadListenerTest {
private static JsonElement jsonElem(String s) {
return JsonParser.parseString(s);
}
private static ResourceLocation id(String path) {
return ResourceLocation.fromNamespaceAndPath("ttest", path);
}
@BeforeEach
void resetRegistry() {
ArmatureReloadListener.clearForTests();
}
// ==================== apply() ====================
@Test
void apply_validJson_registersArmature() {
Map<ResourceLocation, JsonElement> data = new HashMap<>();
data.put(id("quadruped"), jsonElem("""
{
"description": "Four-legged pet",
"root_joint": "Root",
"joints": {
"Root": {
"id": 0,
"parent": null,
"translation": [0.0, 0.0, 0.0],
"rotation": [0.0, 0.0, 0.0, 1.0],
"children": ["Torso"]
},
"Torso": {
"id": 1,
"parent": "Root",
"translation": [0.0, 12.0, 0.0],
"rotation": [0.0, 0.0, 0.0, 1.0],
"children": []
}
}
}
"""));
new ArmatureReloadListener().applyForTests(data);
Armature armature = ArmatureReloadListener.get(id("quadruped"));
assertNotNull(armature, "armature must be registered after valid apply");
assertEquals(2, armature.getJointNumber());
assertEquals("Root", armature.rootJoint.getName());
assertEquals("Torso", armature.searchJointByName("Torso").getName());
}
@Test
void apply_invalidJson_skipsAndContinues() {
Map<ResourceLocation, JsonElement> data = new HashMap<>();
// 1) Top-level not an object
data.put(id("bad_toplevel"), jsonElem("42"));
// 2) Missing root_joint field
data.put(id("no_root"), jsonElem("""
{
"description": "no root field",
"joints": {
"A": {
"id": 0, "parent": null,
"translation": [0,0,0], "rotation": [0,0,0,1],
"children": []
}
}
}
"""));
// 3) root_joint references unknown joint → validate() rejects
data.put(id("bad_root_ref"), jsonElem("""
{
"description": "bad root ref",
"root_joint": "Nope",
"joints": {
"Root": {
"id": 0, "parent": null,
"translation": [0,0,0], "rotation": [0,0,0,1],
"children": []
}
}
}
"""));
// 4) Good one — must still register despite siblings failing
data.put(id("good"), jsonElem("""
{
"description": "good",
"root_joint": "Root",
"joints": {
"Root": {
"id": 0, "parent": null,
"translation": [0,0,0], "rotation": [0,0,0,1],
"children": []
}
}
}
"""));
new ArmatureReloadListener().applyForTests(data);
assertNull(ArmatureReloadListener.get(id("bad_toplevel")),
"non-object top-level must be skipped");
assertNull(ArmatureReloadListener.get(id("no_root")),
"missing root_joint must be skipped");
assertNull(ArmatureReloadListener.get(id("bad_root_ref")),
"bad root ref must be skipped");
assertNotNull(ArmatureReloadListener.get(id("good")),
"good JSON in same batch must still register");
}
@Test
void apply_twice_replacesRegistry() {
// First reload : register A + B.
Map<ResourceLocation, JsonElement> first = new HashMap<>();
first.put(id("a"), jsonElem("""
{"description":"a","root_joint":"Root","joints":{
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":[]}
}}
"""));
first.put(id("b"), jsonElem("""
{"description":"b","root_joint":"Root","joints":{
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":[]}
}}
"""));
ArmatureReloadListener listener = new ArmatureReloadListener();
listener.applyForTests(first);
Armature aFirst = ArmatureReloadListener.get(id("a"));
assertNotNull(aFirst);
assertNotNull(ArmatureReloadListener.get(id("b")));
assertEquals(2, ArmatureReloadListener.size());
// Second reload : only A (with a different structure).
Map<ResourceLocation, JsonElement> second = new HashMap<>();
second.put(id("a"), jsonElem("""
{"description":"a v2","root_joint":"Root","joints":{
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":["Head"]},
"Head":{"id":1,"parent":"Root","translation":[0,8,0],"rotation":[0,0,0,1],"children":[]}
}}
"""));
listener.applyForTests(second);
// A must be replaced with the new armature (new instance, 2 joints now).
Armature aSecond = ArmatureReloadListener.get(id("a"));
assertNotNull(aSecond);
assertNotSame(aFirst, aSecond, "reload must replace the registered Armature");
assertEquals(2, aSecond.getJointNumber(),
"new Armature has the new structure (2 joints)");
// B must disappear (full clear+reload semantics).
assertNull(ArmatureReloadListener.get(id("b")),
"armature removed from the dataset must be gone after reload");
assertEquals(1, ArmatureReloadListener.size(), "only 'a' remains");
}
@Test
void get_registered_returnsArmature() {
Map<ResourceLocation, JsonElement> data = new HashMap<>();
data.put(id("simple"), jsonElem("""
{"description":"x","root_joint":"Root","joints":{
"Root":{"id":0,"parent":null,"translation":[1,2,3],"rotation":[0,0,0,1],"children":[]}
}}
"""));
new ArmatureReloadListener().applyForTests(data);
Armature a = ArmatureReloadListener.get(id("simple"));
assertNotNull(a);
assertEquals(1, a.getJointNumber());
}
@Test
void get_unknown_returnsNull() {
assertNull(ArmatureReloadListener.get(id("never_loaded")),
"unknown id must return null");
}
// ==================== TiedUpArmatures delegation ====================
@Test
void tiedUpArmaturesGet_builtinBiped_returnsHardcoded() {
// The biped hardcoded path should not be affected by the datapack
// registry state. Both long and short IDs resolve.
ResourceLocation shortBiped = ResourceLocation.fromNamespaceAndPath("tiedup", "biped");
ResourceLocation longBiped = ResourceLocation.fromNamespaceAndPath("tiedup", "armature/biped");
AssetAccessor<?> a1 = TiedUpArmatures.get(shortBiped);
AssetAccessor<?> a2 = TiedUpArmatures.get(longBiped);
assertNotNull(a1);
assertNotNull(a2);
// Both should point at the same BIPED singleton instance.
assertEquals(a1.get(), a2.get(),
"both biped IDs must resolve to the same Armature instance");
}
@Test
void tiedUpArmaturesGet_datapackId_delegatesToListener() {
Map<ResourceLocation, JsonElement> data = new HashMap<>();
ResourceLocation customId = id("my_custom");
data.put(customId, jsonElem("""
{"description":"custom","root_joint":"Root","joints":{
"Root":{"id":0,"parent":null,"translation":[0,0,0],"rotation":[0,0,0,1],"children":["Torso"]},
"Torso":{"id":1,"parent":"Root","translation":[0,12,0],"rotation":[0,0,0,1],"children":[]}
}}
"""));
new ArmatureReloadListener().applyForTests(data);
AssetAccessor<?> accessor = TiedUpArmatures.get(customId);
assertNotNull(accessor, "datapack armature must be resolved by TiedUpArmatures.get()");
assertEquals(customId, accessor.registryName());
assertTrue(accessor.inRegistry(),
"datapack-sourced accessor must report inRegistry() == true");
assertNotNull(accessor.get());
assertEquals(2, ((Armature) accessor.get()).getJointNumber());
}
@Test
void tiedUpArmaturesGet_unknownId_returnsNull() {
// Registry clear; an arbitrary unknown ID must NOT resolve.
assertNull(TiedUpArmatures.get(id("does_not_exist")),
"TiedUpArmatures.get() must return null for unknown datapack IDs "
+ "(caller decides the fallback)");
}
}