From a7a1c774f7a34d07ada1c1e192021cd539ae00d7 Mon Sep 17 00:00:00 2001 From: notevil Date: Fri, 24 Apr 2026 15:16:25 +0200 Subject: [PATCH] D6 Custom armatures via datapack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modders can now define custom armatures (quadruped, centaur, neko, etc.) via data//tiedup/armatures/*.json — zero Java code required. Format : - root_joint : name of root joint - joints : map 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. --- .../com/tiedup/remake/core/TiedUpMod.java | 11 + .../tiedup/remake/rig/TiedUpArmatures.java | 67 +++- .../armature/datapack/ArmatureDefinition.java | 340 +++++++++++++++++ .../datapack/ArmatureReloadListener.java | 349 ++++++++++++++++++ .../remake/v2/client/V2ClientSetup.java | 9 +- .../datapack/ArmatureDefinitionTest.java | 333 +++++++++++++++++ .../datapack/ArmatureReloadListenerTest.java | 256 +++++++++++++ 7 files changed, 1354 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinition.java create mode 100644 src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListener.java create mode 100644 src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinitionTest.java create mode 100644 src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListenerTest.java diff --git a/src/main/java/com/tiedup/remake/core/TiedUpMod.java b/src/main/java/com/tiedup/remake/core/TiedUpMod.java index 5b44c09..52e2804 100644 --- a/src/main/java/com/tiedup/remake/core/TiedUpMod.java +++ b/src/main/java/com/tiedup/remake/core/TiedUpMod.java @@ -627,6 +627,17 @@ public class TiedUpMod { "Registered LivingMotionReloadListener for data-driven motion additions" ); + // Data-driven custom armatures (server-side, from data//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//tiedup/pose_types/) // Additive registry — the 6 builtin PoseType enum values remain the // only poses consumable by legacy V1 call-sites. Datapack types are diff --git a/src/main/java/com/tiedup/remake/rig/TiedUpArmatures.java b/src/main/java/com/tiedup/remake/rig/TiedUpArmatures.java index 82d6e5e..aacbca0 100644 --- a/src/main/java/com/tiedup/remake/rig/TiedUpArmatures.java +++ b/src/main/java/com/tiedup/remake/rig/TiedUpArmatures.java @@ -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)}. * - *

Phase 2.4 scope : 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).

+ *

Builtin : 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.

* - *

Phase 2.7 future : 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.

+ *

Datapack : pour tout autre ID, on delegate au + * {@link ArmatureReloadListener} — chargement depuis + * {@code data//tiedup/armatures/.json} (cf. D6). Permet aux + * modders de définir quadruped / centaure / neko / ... sans coder Java.

* - * @param id l'ID à résoudre (ex. {@code tiedup:biped} ou - * {@code tiedup:armature/biped}). Jamais null — caller check. + *

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).

+ * + * @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. + * + *

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.

+ */ + private static AssetAccessor wrapDatapackArmature( + ResourceLocation id, Armature armature + ) { + return new AssetAccessor() { + @Override + public Armature get() { + return armature; + } + + @Override + public ResourceLocation registryName() { + return id; + } + + @Override + public boolean inRegistry() { + return true; + } + }; + } } diff --git a/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinition.java b/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinition.java new file mode 100644 index 0000000..33640b9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinition.java @@ -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//tiedup/armatures/.json}). + * + *

But

+ *

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()}.

+ * + *

Format JSON attendu

+ *
{@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": []
+ *     }
+ *   }
+ * }
+ * }
+ * + *

Invariants (enforces par {@link #validate()})

+ *
    + *
  • {@code root_joint} doit exister dans {@code joints}.
  • + *
  • Tous les {@code parent} (sauf null) et tous les {@code children} + * doivent referencer un joint declare dans la map.
  • + *
  • 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).
  • + *
  • La hierarchie parent-children doit former un DAG acyclique a racine + * unique (le root_joint).
  • + *
  • Le nombre total de joints est plafonne a + * {@link TiedUpRigConstants#MAX_JOINTS}.
  • + *
  • Relation parent-child bidirectionnelle coherente : si A liste B dans + * {@code children}, B doit avoir {@code parent = A}.
  • + *
+ * + *

Quaternion convention

+ *

Format xyzw (JOML standard, aligne sur {@link Quaternionf}). + * Une rotation identity = {@code [0, 0, 0, 1]}.

+ */ +public record ArmatureDefinition( + ResourceLocation id, + String description, + String rootJointName, + Map 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 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 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 seenIds = new HashSet<>(); + int jointCount = joints.size(); + + for (Map.Entry 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 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 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 visited = new LinkedHashSet<>(); + Deque 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 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. + * + *

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.

+ * + *

L'armature retournee a ses {@code toOrigin} matrices calcules via + * {@link Armature#bakeOriginMatrices()} — prete a etre utilisee par + * le renderer sans etape supplementaire.

+ * + * @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 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; + } +} diff --git a/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListener.java b/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListener.java new file mode 100644 index 0000000..fe049b9 --- /dev/null +++ b/src/main/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListener.java @@ -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//tiedup/armatures/.json} et + * enregistre chaque definition d'armature custom comme un {@link Armature} + * runtime queryable via {@link #get(ResourceLocation)}. + * + *

But

+ *

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}.

+ * + *

Lookup flow

+ *

{@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.

+ * + *

Full reload on apply

+ *

A chaque {@code /reload} (server) ou {@code F3+T} (client) le registre + * est entierement vide puis repopule. 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.

+ * + *

Attention : 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)}).

+ * + *

Side & threading

+ *

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).

+ * + *

Limitations connues

+ *
    + *
  • 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+).
  • + *
  • Si un JSON est malforme (structure invalide), il est skip avec un + * WARN ; le reste du batch continue.
  • + *
  • 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.
  • + *
+ */ +public class ArmatureReloadListener extends SimpleJsonResourceReloadListener { + + /** Dossier scanne : {@code data//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 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 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. + * + *

Le {@code ResourceManager} et le {@code ProfilerFiller} ne sont pas + * lus par notre {@code apply}, on peut passer {@code null} en test.

+ */ + public void applyForTests(Map data) { + this.apply(data, null, null); + } + + @Override + protected void apply( + Map 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 sorted = new TreeMap<>(objectIn); + + int loaded = 0; + int skipped = 0; + + for (Map.Entry entry : sorted.entrySet()) { + ResourceLocation id = entry.getKey(); + JsonElement element = entry.getValue(); + + try { + ArmatureDefinition def = parseDefinition(id, element); + + Optional 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 joints = new LinkedHashMap<>(); + for (Map.Entry 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 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)); + } + } +} diff --git a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java index 919a1a3..763b7c1 100644 --- a/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java +++ b/src/main/java/com/tiedup/remake/v2/client/V2ClientSetup.java @@ -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" ); } diff --git a/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinitionTest.java b/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinitionTest.java new file mode 100644 index 0000000..a859a91 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureDefinitionTest.java @@ -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 validChain() { + Map 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 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 joints = validChain(); + ArmatureDefinition def = new ArmatureDefinition( + ID, "missing root", "NotDeclared", joints + ); + Optional 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 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 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 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 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 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 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 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 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 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 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 joints = new LinkedHashMap<>(); + int tooMany = TiedUpRigConstants.MAX_JOINTS + 1; + // Root + (tooMany - 1) children + List 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 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 err = def.validate(); + assertFalse(err.isPresent(), "valid structure must pass, got: " + err.orElse("—")); + } + + @Test + void validate_rootHasParent_returnsError() { + Map 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 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 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 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 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 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()); + } +} diff --git a/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListenerTest.java b/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListenerTest.java new file mode 100644 index 0000000..d514d95 --- /dev/null +++ b/src/test/java/com/tiedup/remake/rig/armature/datapack/ArmatureReloadListenerTest.java @@ -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 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 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 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 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 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 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)"); + } +}