Phase 2.4 review fixes : P0-BUG-001 joint order + P0-BUG-002 singleton + tests

P0-BUG-001 — Ordre IDs joints Elbow/Hand/Tool ≠ EF
  TiedUpArmatures.buildBiped() assignait Arm_R=11, Elbow_R=12, Hand_R=13,
  Tool_R=14 (idem gauche 16/17/18/19) alors que VanillaModelTransformer.RIGHT_ARM
  encode upperJoint=11, lowerJoint=12, middleJoint=14 — donc Arm_R=11, Hand_R=12,
  Elbow_R=14. Résultat : lowerJoint=17 (SimpleTransformer qui attache les
  vertices à Hand) pointait en fait vers Elbow_L → bras tordus au rendu.
  Fix : réassigner les IDs dans buildBiped() pour matcher le layout EF
  (Arm_R=11, Hand_R=12, Tool_R=13, Elbow_R=14 ; symétrique 16-19).
  VanillaModelTransformer non touché (source de vérité EF).

P0-BUG-002 — Singleton BIPED non thread-safe
  if (BIPED_INSTANCE == null) BIPED_INSTANCE = buildBiped() est un double-init
  race. En SP intégré (client + server threads sur la même JVM), deux threads
  peuvent entrer simultanément dans le if null et créer deux HumanoidArmature
  distincts — pose matrices incohérentes selon les call sites.
  Fix : Holder idiome (static inner class). Le class-init lock JVM garantit
  (JLS §12.4.1) qu'une seule init, visible à tous les threads, sans
  synchronized ni volatile. Zero overhead après init.

P1 — Nouveau TiedUpArmaturesTest (4 tests)
  - bipedHas20Joints : BIPED.get().getJointNumber() == 20
  - searchBipedJointByNameReturnsNonNull : vérifie les 20 noms EF
  - jointIdsMatchEfLayout : verrou P0-BUG-001 (id=12→Hand_R, id=14→Elbow_R,
    etc.) — aurait attrapé le bug en review initiale
  - bipedSingleton : BIPED.get() == BIPED.get() (verrou P0-BUG-002)

P2 backlog tracé dans docs/plans/rig/PHASE0_DEGRADATIONS.md :
  biped collapsed visuellement jusqu'à Phase 2.7, PlayerPatch.yBodyRot sans
  lerp, ridingEntity non géré, isFirstPersonHidden nommage ambigu,
  ServerPlayerPatch hérite méthodes client-only sans @OnlyIn.

Tests : 15 GREEN (11 bridge pré-existants + 4 nouveaux biped).
Compile clean.
This commit is contained in:
notevil
2026-04-22 22:27:01 +02:00
parent 79fc470aa0
commit 39f6177595
2 changed files with 173 additions and 35 deletions

View File

@@ -41,16 +41,20 @@ import com.tiedup.remake.rig.math.OpenMatrix4f;
* <h2>Hiérarchie biped EF (20 joints)</h2>
*
* <pre>
* Root
* ├─ Thigh_R ── Leg_R ── Knee_R
* ├─ Thigh_L ── Leg_L ── Knee_L
* └─ Torso
* └─ Chest
* ├─ Head
* ├─ Shoulder_R ── Arm_R ── Elbow_R ── Hand_R ── Tool_R
* └─ Shoulder_L ── Arm_L ── Elbow_L ── Hand_L ── Tool_L
* Root id=0
* ├─ Thigh_R ── Leg_R ── Knee_R id=1,2,3
* ├─ Thigh_L ── Leg_L ── Knee_L id=4,5,6
* └─ Torso id=7
* └─ Chest id=8
* ├─ Head id=9
* ├─ Shoulder_R ── Arm_R ── Elbow_R ── Hand_R ── Tool_R ids=10,11,14,12,13
* └─ Shoulder_L ── Arm_L ── Elbow_L ── Hand_L ── Tool_L ids=15,16,19,17,18
* </pre>
*
* <p>Les IDs des bras ne suivent pas l'ordre hiérarchique parent→enfant : c'est
* voulu pour rester aligné avec le layout attendu par {@code VanillaModelTransformer}
* (upperJoint=Arm, lowerJoint=Hand, middleJoint=Elbow). Voir {@link #buildBiped()}.</p>
*
* <p><b>Noms conservés verbatim EF</b> (pas renommés en TiedUp style) car :</p>
* <ul>
* <li>Le {@code VanillaModelTransformer} forké EF (Phase 2.2) référence ces
@@ -69,19 +73,29 @@ public final class TiedUpArmatures {
ResourceLocation.fromNamespaceAndPath(TiedUpRigConstants.MODID, "armature/biped");
/**
* Singleton biped — chargé à la première ref (init order :
* {@link TiedUpRigConstants} → chaînes d'ids → ici via
* {@link com.tiedup.remake.rig.patch.PlayerPatch#getArmature()}).
* Holder idiome pour init lazy + thread-safe sans synchronized.
*
* <p>Si construit plus tôt (ex. static init combat-lourd avant FMLCommonSetup)
* risquerait de toucher des classes client — garder cet accès lazy si les
* call sites évoluent.</p>
* <p>Le class loader JVM garantit qu'une classe est initialisée au plus une
* fois, et que l'init est visible à tous les threads (JLS §12.4.1 — the class
* initialization lock est acquis automatiquement). Deux threads qui touchent
* {@code Holder.INSTANCE} simultanément ne peuvent pas observer l'instance
* non-initialisée ni en créer deux exemplaires. Intégré SP (client + server
* threads concurrents sur la même JVM) safe.</p>
*
* <p>Raison du fix (review Phase 2.4, P0-BUG-002) : le pattern précédent
* {@code if (BIPED_INSTANCE == null) BIPED_INSTANCE = buildBiped();} est
* un double-init race — deux threads entrent tous les deux dans le if,
* les deux créent un HumanoidArmature distinct, dernier gagne et pollue
* le cache.</p>
*/
private static HumanoidArmature BIPED_INSTANCE;
private static final class Holder {
static final HumanoidArmature INSTANCE = buildBiped();
private Holder() {}
}
/**
* AssetAccessor biped biped TiedUp. Construit à la première lecture de
* {@link AssetAccessor#get()}.
* AssetAccessor biped TiedUp. L'instance est construite lazy à la première
* référence {@code Holder.INSTANCE} (thread-safe via class-init lock JVM).
*
* <p>Utilisé par {@link com.tiedup.remake.rig.patch.PlayerPatch#getArmature()}
* et par les futurs {@code StaticAnimation(… , BIPED)} Phase 2.7+.</p>
@@ -89,10 +103,7 @@ public final class TiedUpArmatures {
public static final AssetAccessor<HumanoidArmature> BIPED = new AssetAccessor<>() {
@Override
public HumanoidArmature get() {
if (BIPED_INSTANCE == null) {
BIPED_INSTANCE = buildBiped();
}
return BIPED_INSTANCE;
return Holder.INSTANCE;
}
@Override
@@ -110,17 +121,24 @@ public final class TiedUpArmatures {
};
/**
* Build procédural de la hiérarchie biped EF. 20 joints, IDs 0..19 dans
* l'ordre d'insertion (important pour {@link Joint#getId()} stabilité
* inter-runs).
* Build procédural de la hiérarchie biped EF. 20 joints, IDs 0..19 assignés
* explicitement pour matcher le layout attendu par
* {@link com.tiedup.remake.rig.mesh.transformer.VanillaModelTransformer}
* (cf. constantes {@code RIGHT_ARM}/{@code LEFT_ARM} — upperJoint/lowerJoint/middleJoint).
*
* <p>Ordre d'insertion LinkedHashMap ≠ ordre des IDs pour les bras (hand
* vient AVANT elbow dans les IDs, pour aligner sur la sémantique EF où
* {@code middleJoint = elbow}). Voir commentaires inline.</p>
*
* <p>Toutes les transforms sont identity — Phase 2.7 remplacera par les
* offsets Blender mesurés (cf. doc header).</p>
*/
private static HumanoidArmature buildBiped() {
// Tous les joints (ID == ordre d'insertion dans la map).
// On utilise LinkedHashMap pour garantir l'ordre d'itération stable
// (OpenMatrix4f.allocateMatrixArray dimensionne sur jointCount).
// ID Joint assigné explicitement au constructeur (pas par position dans
// la map) — Armature.jointById est construit depuis joint.getId().
// On utilise LinkedHashMap malgré tout pour garantir un ordre d'itération
// stable (utile pour le debug et pour OpenMatrix4f.allocateMatrixArray
// qui dimensionne sur jointCount).
Map<String, Joint> joints = new LinkedHashMap<>(20);
// Pattern EF : tous les joints démarrent avec une localTransform identity.
@@ -141,19 +159,25 @@ public final class TiedUpArmatures {
Joint chest = joint(joints, "Chest", 8);
Joint head = joint(joints, "Head", 9);
// Bras droit
// Bras droit — IDs alignés sur le layout EF (VanillaModelTransformer.RIGHT_ARM
// encode upperJoint=11, lowerJoint=12, middleJoint=14, cf.
// VanillaModelTransformer:50). L'insertion dans le LinkedHashMap reste
// dans l'ordre hiérarchique (shoulder → arm → elbow → hand → tool) pour
// préserver la lisibilité de l'iteration ; les IDs déterminent le
// mapping jointById utilisé par VanillaModelTransformer + SimpleTransformer.
Joint shoulderR = joint(joints, "Shoulder_R", 10);
Joint armR = joint(joints, "Arm_R", 11);
Joint elbowR = joint(joints, "Elbow_R", 12);
Joint handR = joint(joints, "Hand_R", 13);
Joint toolR = joint(joints, "Tool_R", 14);
Joint handR = joint(joints, "Hand_R", 12);
Joint toolR = joint(joints, "Tool_R", 13);
Joint elbowR = joint(joints, "Elbow_R", 14);
// Bras gauche
// Bras gauche — symétrique : Arm_L=16, Hand_L=17, Tool_L=18, Elbow_L=19
// (VanillaModelTransformer.LEFT_ARM upperJoint=16, lowerJoint=17, middleJoint=19).
Joint shoulderL = joint(joints, "Shoulder_L", 15);
Joint armL = joint(joints, "Arm_L", 16);
Joint elbowL = joint(joints, "Elbow_L", 17);
Joint handL = joint(joints, "Hand_L", 18);
Joint toolL = joint(joints, "Tool_L", 19);
Joint handL = joint(joints, "Hand_L", 17);
Joint toolL = joint(joints, "Tool_L", 18);
Joint elbowL = joint(joints, "Elbow_L", 19);
// Hiérarchie. addSubJoints est idempotent (skip si déjà présent) — safe
// de le réappeler, utile si on étend plus tard.

View File

@@ -0,0 +1,114 @@
/*
* © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
*/
package com.tiedup.remake.rig;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import org.junit.jupiter.api.Test;
import com.tiedup.remake.rig.armature.HumanoidArmature;
/**
* Tests du biped procédural TiedUp.
*
* <p>Ces tests verrouillent en particulier le mapping {@code id → joint name}
* qui doit rester aligné sur les constantes {@code RIGHT_ARM} / {@code LEFT_ARM}
* de {@code VanillaModelTransformer} — sinon les bras rendent tordus (ex :
* {@code lowerJoint=17} pointerait vers Elbow_L au lieu de Hand_L).</p>
*
* <p>La review Phase 2.4 (P0-BUG-001) a montré que l'ordre précédent
* Arm_R=11, Elbow_R=12, Hand_R=13, Tool_R=14 cassait silencieusement le
* transformer. {@link #jointIdsMatchEfLayout()} aurait attrapé ce bug.</p>
*/
class TiedUpArmaturesTest {
@Test
void bipedHas20Joints() {
assertEquals(20, TiedUpArmatures.BIPED.get().getJointNumber(),
"Le biped EF standard doit avoir exactement 20 joints");
}
@Test
void searchBipedJointByNameReturnsNonNull() {
HumanoidArmature biped = TiedUpArmatures.BIPED.get();
String[] expectedNames = {
"Root", "Head",
"Torso", "Chest",
"Shoulder_L", "Shoulder_R",
"Arm_L", "Arm_R",
"Hand_L", "Hand_R",
"Elbow_L", "Elbow_R",
"Tool_L", "Tool_R",
"Thigh_L", "Thigh_R",
"Leg_L", "Leg_R",
"Knee_L", "Knee_R"
};
for (String name : expectedNames) {
assertNotNull(biped.searchJointByName(name),
"Joint '" + name + "' introuvable dans le biped — alignement EF cassé");
}
}
/**
* Verrou explicite contre P0-BUG-001. Les constantes EF
* {@code VanillaModelTransformer.RIGHT_ARM} encodent
* {@code upperJoint=11, lowerJoint=12, middleJoint=14}, i.e.
* Arm_R=11, Hand_R=12, Elbow_R=14 (symétrique gauche : 16/17/19).
*
* <p>Ce test DOIT échouer si quelqu'un réordonne les IDs dans
* {@link TiedUpArmatures#buildBiped()} sans mettre à jour
* {@code VanillaModelTransformer} en miroir.</p>
*/
@Test
void jointIdsMatchEfLayout() {
HumanoidArmature biped = TiedUpArmatures.BIPED.get();
// Right arm layout (VanillaModelTransformer.RIGHT_ARM : 11, 12, _, 14)
assertEquals("Arm_R", biped.searchJointById(11).getName(),
"id=11 doit être Arm_R (VanillaModelTransformer.RIGHT_ARM.upperJoint)");
assertEquals("Hand_R", biped.searchJointById(12).getName(),
"id=12 doit être Hand_R (VanillaModelTransformer.RIGHT_ARM.lowerJoint)");
assertEquals("Elbow_R", biped.searchJointById(14).getName(),
"id=14 doit être Elbow_R (VanillaModelTransformer.RIGHT_ARM.middleJoint)");
// Left arm layout (VanillaModelTransformer.LEFT_ARM : 16, 17, _, 19)
assertEquals("Arm_L", biped.searchJointById(16).getName(),
"id=16 doit être Arm_L (VanillaModelTransformer.LEFT_ARM.upperJoint)");
assertEquals("Hand_L", biped.searchJointById(17).getName(),
"id=17 doit être Hand_L (VanillaModelTransformer.LEFT_ARM.lowerJoint)");
assertEquals("Elbow_L", biped.searchJointById(19).getName(),
"id=19 doit être Elbow_L (VanillaModelTransformer.LEFT_ARM.middleJoint)");
// Tool_R=13, Tool_L=18 (pas dans les LimbPartTransformer mais utilisé
// par StaticAnimation itemInHand — on verrouille l'emplacement aussi).
assertEquals("Tool_R", biped.searchJointById(13).getName(),
"id=13 doit être Tool_R (attaché après Hand_R dans la chaîne)");
assertEquals("Tool_L", biped.searchJointById(18).getName(),
"id=18 doit être Tool_L (attaché après Hand_L dans la chaîne)");
}
/**
* Verrou contre P0-BUG-002. La première implémentation utilisait un
* double-null-check non thread-safe — SP intégré (client + server threads
* concurrent) pouvait créer deux instances distinctes.
*
* <p>Le Holder idiome (static inner class) garantit via le class-init lock
* JVM qu'il n'y a qu'une seule init. Ce test vérifie que deux appels
* successifs de {@code BIPED.get()} renvoient bien la même référence —
* ne prouve pas l'atomicité mais couvre le cas régression "retour à un
* nouveau build à chaque get".</p>
*/
@Test
void bipedSingleton() {
HumanoidArmature first = TiedUpArmatures.BIPED.get();
HumanoidArmature second = TiedUpArmatures.BIPED.get();
assertSame(first, second,
"BIPED.get() doit retourner la même instance (singleton Holder)");
}
}