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> * <li>walk seul → {@link LivingMotions#WALK}</li>
* </ul> * </ul>
* </li> * </li>
* <li>Défaut (idle) → {@link LivingMotions#IDLE} vanilla — les items * <li>Défaut (idle) → {@link LivingMotions#IDLE} vanilla. Les items
* bondage bindent leurs poses spécifiques ailleurs (Option B du * bondage peuvent binder IDLE via deux paths :
* design doc, §5.1 BONDAGE_ANIMATION_DESIGN.md).</li> * 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> * </ol>
* *
* <p><b>Pattern EF</b> : {@code EntityState.inaction()} gate la transition * <p><b>Pattern EF</b> : {@code EntityState.inaction()} gate la transition

View File

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

View File

@@ -85,11 +85,24 @@ import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
* *
* <h2>Préconditions</h2> * <h2>Préconditions</h2>
* <ul> * <ul>
* <li><b>Option B modder convention</b> : les items bondage JSON NE bindent * <li><b>IDLE bindings are first-class</b> (user decision 2026-04-24) : les
* PAS {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. Le default * items bondage PEUVENT binder
* EF IDLE re-injecté par {@code resetLivingAnimations} reste donc * {@link com.tiedup.remake.rig.anim.LivingMotions#IDLE}. EF supporte
* visible. Laisser un item binder IDLE est toléré (l'anim custom * nativement deux paths via sa structure 2-map :
* écrase) mais c'est un code smell — voir docs plan P3.</li> * <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 * <li>Appelé depuis le <b>client main thread</b> uniquement. L'animator
* n'est pas thread-safe.</li> * n'est pas thread-safe.</li>
* <li>Le package {@code v2.client} est strictement client-only malgré * <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)); 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 * IDLE bindings sont supportés sans warning (user decision 2026-04-24). EF a deux
* ne devraient PAS binder {@link LivingMotions#IDLE} car * maps séparées dans {@code ClientAnimator} :
* {@code ClientAnimator.resetLivingAnimations()} re-injecte le default EF * <ul>
* IDLE post-rebuild, et le binding custom l'écrase silencieusement — comportement * <li>{@code livingAnimations} (BASE layer) — un binding IDLE
* fonctionnel mais non-idiomatique. Le parser log un WARN explicite pour pointer * {@code LayerType=BASE_LAYER} écrase le default EF injecté par
* le modder vers la convention, mais accepte le binding quand même (rétrocompat). * {@code resetLivingAnimations()} via last-put-wins.</li>
* * <li>{@code compositeLivingAnimations} (COMPOSITE layers, JointMask) — un
* <p>Ce test verrouille le comportement "accepte + ne throw pas" — la présence * binding IDLE {@code LayerType=COMPOSITE_LAYER} va dans cette map séparée,
* du WARN log n'est pas directement vérifiable sans log capture, mais le * composition automatique avec vanilla IDLE sur les joints non-masqués.</li>
* binding doit être bel et bien présent dans le résultat.</p> * </ul>
* Les 2 paths fonctionnent nativement. Le parser accepte le binding IDLE
* sans log warn.
*/ */
@Test @Test
void parseAnimations_IDLEBinding_logsWarnButAccepts() { void parseAnimations_IDLEBinding_isAcceptedSilently() {
String jsonStr = """ String jsonStr = """
{ {
"animations": { "animations": {
@@ -199,13 +201,52 @@ class DataDrivenItemParserAnimationsTest {
DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID); DataDrivenItemParser.parseAnimationBindings(json(jsonStr), ITEM_ID);
assertNotNull(result, 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(), 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( assertEquals(
ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"), ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle"),
result.livingMotions().get(LivingMotions.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()
); );
} }