Revert Option B — IDLE bindings are first-class

User decision 2026-04-24 : IDLE bindings must be supported because
modders need to define idle poses for their items (armbinder can't
work without arms-behind-back idle pose). TiedUp will also ship
default IDLE bindings for its core items once assets are authored.

Technical : EF ClientAnimator has 2 separate maps (livingAnimations
for BASE layer + compositeLivingAnimations for COMPOSITE layers).
Both support IDLE bindings without race — last-put-wins for base
override, or automatic composition via JointMask for partial overlay.
The previous WARN was a reviewer over-correction based on misread
of the 2-map structure.

- Removed WARN parse-time in DataDrivenItemParser.parseAnimationBindings
- Updated test : IDLE binding is accepted silently (no warn)
- Cleaned 'Option B' mentions in javadoc comments
- Design doc (gitignored) updated with 2-path explanation.
This commit is contained in:
notevil
2026-04-24 03:46:12 +02:00
parent e969131ad2
commit 4648107ebe
4 changed files with 90 additions and 41 deletions

View File

@@ -90,9 +90,12 @@ public abstract class PlayerPatch<T extends Player> extends LivingEntityPatch<T>
* <li>walk seul → {@link LivingMotions#WALK}</li>
* </ul>
* </li>
* <li>Défaut (idle) → {@link LivingMotions#IDLE} vanilla — les items
* bondage bindent leurs poses spécifiques ailleurs (Option B du
* design doc, §5.1 BONDAGE_ANIMATION_DESIGN.md).</li>
* <li>Défaut (idle) → {@link LivingMotions#IDLE} vanilla. Les items
* bondage peuvent binder IDLE via deux paths :
* BASE layer override (écrase vanilla IDLE dans {@code livingAnimations})
* ou COMPOSITE overlay avec JointMask (vanilla IDLE joue sur le corps,
* overlay masque seulement certains joints). Voir §5.1
* BONDAGE_ANIMATION_DESIGN.md.</li>
* </ol>
*
* <p><b>Pattern EF</b> : {@code EntityState.inaction()} gate la transition

View File

@@ -717,24 +717,16 @@ public final class DataDrivenItemParser {
);
continue;
}
// P3 Wave α — Option B convention warn : binding IDLE fonctionne
// techniquement mais écrase silencieusement le default EF re-injecté
// par ClientAnimator.resetLivingAnimations() post-rebuild. Design
// BONDAGE_ANIMATION_DESIGN.md §5.1 recommande d'utiliser des motions
// custom (POSE_KNEEL_BOUND / STRUGGLE_BOUND / WALK_BOUND) plutôt que
// d'écraser IDLE. On accepte le binding quand même (rétrocompat +
// cas légitimes rares), juste on pointe le modder vers la convention.
if (motion == LivingMotions.IDLE) {
LOGGER.warn(
"[DataDrivenItemParser] Item {} binds IDLE motion. Convention design Option B "
+ "(BONDAGE_ANIMATION_DESIGN.md §5.1) recommends not binding IDLE — "
+ "ClientAnimator.resetLivingAnimations() restores default EF IDLE after "
+ "rebuild, so binding IDLE overwrites the default silently. Prefer custom "
+ "motions like POSE_KNEEL_BOUND / STRUGGLE_BOUND / WALK_BOUND. The binding "
+ "will still work but may cause surprises.",
itemId
);
}
// IDLE bindings are first-class citizens (user decision 2026-04-24).
// Two paths supported natively by EF's 2-map ClientAnimator structure :
// - BASE layer override : anim LayerType=BASE_LAYER → written into
// livingAnimations, overrides vanilla IDLE after reset (last-put-wins).
// Use case : armbinder forcing "arms behind back" full-body pose.
// - COMPOSITE overlay : anim LayerType=COMPOSITE_LAYER + JointMask →
// written into compositeLivingAnimations (separate map). Vanilla IDLE
// still plays on base body, overlay masks specified joints only.
// Use case : cuffs posing only the arms while rest stays vanilla.
// No WARN — the parser accepts IDLE bindings without any log noise.
ResourceLocation animId = tryParseAnimationRL(
entry.getValue(),
"living_motions['" + motionName + "']",

View File

@@ -85,11 +85,24 @@ import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
*
* <h2>Préconditions</h2>
* <ul>
* <li><b>Option B modder convention</b> : les items bondage JSON NE bindent
* PAS {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. Le default
* EF IDLE re-injecté par {@code resetLivingAnimations} reste donc
* visible. Laisser un item binder IDLE est toléré (l'anim custom
* écrase) mais c'est un code smell — voir docs plan P3.</li>
* <li><b>IDLE bindings are first-class</b> (user decision 2026-04-24) : les
* items bondage PEUVENT binder
* {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. EF supporte
* nativement deux paths via sa structure 2-map :
* <ul>
* <li><b>BASE layer override</b> — anim {@code LayerType=BASE_LAYER}
* écrit dans {@code livingAnimations}, écrase la vanilla IDLE
* re-injectée par {@code resetLivingAnimations} (last-put-wins).
* Usage : armbinder forçant pose "bras derrière dos" totale.</li>
* <li><b>COMPOSITE overlay</b> — anim {@code LayerType=COMPOSITE_LAYER}
* + {@code JointMask} écrit dans {@code compositeLivingAnimations}
* (map séparée). Vanilla IDLE joue sur le corps, overlay écrase
* seulement les joints masqués. Usage : cuffs bras seulement.</li>
* </ul>
* Aucune race condition — EF compose les 2 maps via
* {@code ClientAnimator.getPose}. TiedUp fournira des IDLE defaults
* pour ses items core (armbinder, collar, cuffs, shackles) une fois
* les assets Blender authorés.</li>
* <li>Appelé depuis le <b>client main thread</b> uniquement. L'animator
* n'est pas thread-safe.</li>
* <li>Le package {@code v2.client} est strictement client-only malgré

View File

@@ -169,22 +169,24 @@ class DataDrivenItemParserAnimationsTest {
assertNotNull(result.livingMotions().get(TiedUpLivingMotions.STRUGGLE_BOUND));
}
// ========== P3 Wave α : Option B convention warn on IDLE ==========
// ========== IDLE bindings are first-class (user decision 2026-04-24) ==========
/**
* Option B convention (design BONDAGE_ANIMATION_DESIGN.md §5.1) : les modders
* ne devraient PAS binder {@link LivingMotions#IDLE} car
* {@code ClientAnimator.resetLivingAnimations()} re-injecte le default EF
* IDLE post-rebuild, et le binding custom l'écrase silencieusement — comportement
* fonctionnel mais non-idiomatique. Le parser log un WARN explicite pour pointer
* le modder vers la convention, mais accepte le binding quand même (rétrocompat).
*
* <p>Ce test verrouille le comportement "accepte + ne throw pas" — la présence
* du WARN log n'est pas directement vérifiable sans log capture, mais le
* binding doit être bel et bien présent dans le résultat.</p>
* IDLE bindings sont supportés sans warning (user decision 2026-04-24). EF a deux
* maps séparées dans {@code ClientAnimator} :
* <ul>
* <li>{@code livingAnimations} (BASE layer) — un binding IDLE
* {@code LayerType=BASE_LAYER} écrase le default EF injecté par
* {@code resetLivingAnimations()} via last-put-wins.</li>
* <li>{@code compositeLivingAnimations} (COMPOSITE layers, JointMask) — un
* binding IDLE {@code LayerType=COMPOSITE_LAYER} va dans cette map séparée,
* composition automatique avec vanilla IDLE sur les joints non-masqués.</li>
* </ul>
* Les 2 paths fonctionnent nativement. Le parser accepte le binding IDLE
* sans log warn.
*/
@Test
void parseAnimations_IDLEBinding_logsWarnButAccepts() {
void parseAnimations_IDLEBinding_isAcceptedSilently() {
String jsonStr = """
{
"animations": {
@@ -199,13 +201,52 @@ class DataDrivenItemParserAnimationsTest {
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result,
"IDLE binding est tolere (non-idiomatique mais fonctionnel) => result non-null");
"IDLE binding est accepte (first-class) => result non-null");
assertEquals(1, result.livingMotions().size(),
"Le binding IDLE est accepte apres le WARN de convention");
"Le binding IDLE est present dans la map livingMotions");
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"),
result.livingMotions().get(LivingMotions.IDLE),
"Le binding IDLE est bien present dans le resultat malgre le WARN"
"Le binding IDLE resout vers la ResourceLocation attendue"
);
}
/**
* Sanity check : un binding IDLE aux côtés d'autres motions (WALK, SNEAK,
* STRUGGLE_BOUND) est parsé sans différence — IDLE est une motion comme les
* autres du point de vue du parser. Aucun traitement spécial.
*/
@Test
void parseAnimations_IDLEBinding_coexistsWithOtherMotions() {
String jsonStr = """
{
"animations": {
"living_motions": {
"IDLE": "tiedup:arms_cuffed_idle",
"WALK": "tiedup:arms_cuffed_walk",
"SNEAK": "tiedup:arms_cuffed_sneak",
"STRUGGLE_BOUND": "tiedup:arms_cuffed_struggle"
},
"on_equip": "tiedup:cuffs_equip_oneshot"
}
}
""";
AnimationBindings result =
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result);
assertEquals(4, result.livingMotions().size(),
"4 motions parsees (IDLE + WALK + SNEAK + STRUGGLE_BOUND)");
assertTrue(result.livingMotions().containsKey(LivingMotions.IDLE),
"Le binding IDLE est present");
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"),
result.livingMotions().get(LivingMotions.IDLE)
);
assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip_oneshot"),
result.onEquip()
);
}