diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java
new file mode 100644
index 0000000..4b06345
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindings.java
@@ -0,0 +1,55 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.v2.bondage.datadriven;
+
+import java.util.Collections;
+import java.util.Map;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.jetbrains.annotations.Nullable;
+
+import com.tiedup.remake.rig.anim.LivingMotion;
+
+/**
+ * Bindings animation pour un item bondage data-driven.
+ *
+ *
Porte le mapping {@link LivingMotion} → {@link ResourceLocation} animation
+ * + eventuels one-shots {@code onEquip} / {@code onUnequip}. Consomme par
+ * {@code ClientRigEquipmentHandler.rebuildBondageAnimations} (P3-05) pour
+ * builder la map {@code livingAnimations} de l'animator quand un item
+ * bondage est equipe.
+ *
+ *
Parse via {@code DataDrivenItemParser} (P3-03) depuis JSON :
+ *
{@code
+ * "animations": {
+ * "living_motions": { "IDLE": "mymod:arms_cuffed_idle", ... },
+ * "on_equip": "mymod:cuffs_equip_oneshot",
+ * "on_unequip": "mymod:cuffs_unequip_oneshot"
+ * }
+ * }
+ *
+ * @param livingMotions map immutable motion → anim ID (jamais null, peut etre vide)
+ * @param onEquip optionnel one-shot au moment de l'equipement (null = pas de trigger)
+ * @param onUnequip optionnel one-shot au moment du desequipement (null = pas de trigger)
+ */
+public record AnimationBindings(
+ Map livingMotions,
+ @Nullable ResourceLocation onEquip,
+ @Nullable ResourceLocation onUnequip
+) {
+ /** Shortcut pour bindings totalement vides (equivalent no-op). */
+ public static final AnimationBindings EMPTY = new AnimationBindings(Map.of(), null, null);
+
+ /** Constructor canonique : force livingMotions immutable + non-null. */
+ public AnimationBindings {
+ livingMotions = livingMotions == null ? Collections.emptyMap() : Map.copyOf(livingMotions);
+ }
+
+ /** {@code true} si aucun binding n'est defini (ni living motions, ni one-shots). */
+ public boolean isEmpty() {
+ return livingMotions.isEmpty() && onEquip == null && onUnequip == null;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
index 0656db4..d44af1b 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java
@@ -105,6 +105,20 @@ public record DataDrivenItemDefinition(
*/
Map> animationBones,
+ /**
+ * Optional living-motion animation bindings (P3 rig system).
+ *
+ * When non-null, the client rig equipment handler rebuilds the player's
+ * living animation map with these bindings whenever this item is equipped.
+ * When null, no animation bindings are applied (vanilla behavior).
+ *
+ * Distinct from {@link #animationBones} : {@code animationBones} is a
+ * per-clip bone whitelist for the PlayerAnimator pipeline (V1-era),
+ * whereas {@code animations} drives the Epic Fight-style rig animator
+ * (P3-05).
+ */
+ @Nullable AnimationBindings animations,
+
/** Raw component configs from JSON, keyed by ComponentType. */
Map componentConfigs
) {
diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
index 2084750..426db2f 100644
--- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
+++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java
@@ -347,6 +347,9 @@ public final class DataDrivenItemParser {
movementModifier,
creator,
animationBones,
+ // P3-02 : animations parsing will be wired in P3-03. For now pass
+ // null so existing items keep their vanilla animator behavior.
+ null,
componentConfigs
);
}
diff --git a/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java
new file mode 100644
index 0000000..9ff55c5
--- /dev/null
+++ b/src/test/java/com/tiedup/remake/v2/bondage/datadriven/AnimationBindingsTest.java
@@ -0,0 +1,154 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.v2.bondage.datadriven;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import net.minecraft.resources.ResourceLocation;
+
+import org.junit.jupiter.api.Test;
+
+import com.tiedup.remake.rig.anim.LivingMotion;
+import com.tiedup.remake.rig.anim.LivingMotions;
+
+/**
+ * Tests unitaires pour {@link AnimationBindings} :
+ *
+ * - construction normale et cas nullables (P3-02),
+ * - comportement du compact ctor (null -> empty map),
+ * - immutabilite defensive de {@code livingMotions},
+ * - contrat de {@link AnimationBindings#isEmpty()},
+ * - constante {@link AnimationBindings#EMPTY}.
+ *
+ *
+ * Aucun MC runtime requis — {@link ResourceLocation} est utilisable hors
+ * MinecraftServer, et {@link LivingMotion} est un interface pur.
+ */
+class AnimationBindingsTest {
+
+ private static final ResourceLocation ANIM_IDLE =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_idle");
+ private static final ResourceLocation ANIM_WALK =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "arms_cuffed_walk");
+ private static final ResourceLocation ANIM_EQUIP =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_equip");
+ private static final ResourceLocation ANIM_UNEQUIP =
+ ResourceLocation.fromNamespaceAndPath("tiedup", "cuffs_unequip");
+
+ /** Construction nominale avec des donnees valides. */
+ @Test
+ void construction_withValidData_succeeds() {
+ Map motions = Map.of(
+ LivingMotions.IDLE, ANIM_IDLE,
+ LivingMotions.WALK, ANIM_WALK
+ );
+
+ AnimationBindings bindings = new AnimationBindings(motions, ANIM_EQUIP, ANIM_UNEQUIP);
+
+ assertEquals(2, bindings.livingMotions().size());
+ assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE));
+ assertEquals(ANIM_WALK, bindings.livingMotions().get(LivingMotions.WALK));
+ assertEquals(ANIM_EQUIP, bindings.onEquip());
+ assertEquals(ANIM_UNEQUIP, bindings.onUnequip());
+ }
+
+ /**
+ * Le compact ctor doit remplacer un {@code livingMotions} null par
+ * {@link java.util.Collections#emptyMap()} — jamais laisser le champ a null.
+ */
+ @Test
+ void construction_withNullLivingMotions_usesEmptyMap() {
+ AnimationBindings bindings = new AnimationBindings(null, null, null);
+
+ assertNotNull(bindings.livingMotions(),
+ "livingMotions ne doit jamais etre null (compact ctor garantit non-null)");
+ assertTrue(bindings.livingMotions().isEmpty());
+ }
+
+ /** {@code onEquip} / {@code onUnequip} null sont legaux (optionnels). */
+ @Test
+ void construction_withNullEquip_isTolerated() {
+ Map motions = Map.of(LivingMotions.IDLE, ANIM_IDLE);
+
+ AnimationBindings bindings = new AnimationBindings(motions, null, null);
+
+ assertEquals(1, bindings.livingMotions().size());
+ assertNull(bindings.onEquip());
+ assertNull(bindings.onUnequip());
+ }
+
+ /**
+ * {@link AnimationBindings#isEmpty()} renvoie {@code true} quand tous les
+ * champs sont vides/absents.
+ */
+ @Test
+ void isEmpty_returnsTrueForFullyEmpty() {
+ AnimationBindings bindings = new AnimationBindings(Map.of(), null, null);
+
+ assertTrue(bindings.isEmpty());
+ }
+
+ /**
+ * {@link AnimationBindings#isEmpty()} doit renvoyer {@code false} des qu'au
+ * moins un champ est present (motion OU one-shot equip OU one-shot unequip).
+ */
+ @Test
+ void isEmpty_returnsFalseIfAnyField() {
+ AnimationBindings onlyMotion =
+ new AnimationBindings(Map.of(LivingMotions.IDLE, ANIM_IDLE), null, null);
+ AnimationBindings onlyEquip =
+ new AnimationBindings(Map.of(), ANIM_EQUIP, null);
+ AnimationBindings onlyUnequip =
+ new AnimationBindings(Map.of(), null, ANIM_UNEQUIP);
+
+ assertFalse(onlyMotion.isEmpty(), "isEmpty doit etre false si livingMotions non vide");
+ assertFalse(onlyEquip.isEmpty(), "isEmpty doit etre false si onEquip non null");
+ assertFalse(onlyUnequip.isEmpty(), "isEmpty doit etre false si onUnequip non null");
+ }
+
+ /**
+ * Le compact ctor doit faire une copie defensive du Map passe — mutation de la
+ * source apres construction ne doit PAS affecter le record.
+ */
+ @Test
+ void immutability_livingMotionsCopyIsDefensive() {
+ Map mutable = new HashMap<>();
+ mutable.put(LivingMotions.IDLE, ANIM_IDLE);
+
+ AnimationBindings bindings = new AnimationBindings(mutable, null, null);
+
+ // Mutation de la map source apres construction
+ mutable.put(LivingMotions.WALK, ANIM_WALK);
+ mutable.remove(LivingMotions.IDLE);
+
+ // Le record garde bien l'etat initial
+ assertEquals(1, bindings.livingMotions().size(),
+ "La map du record ne doit pas refleter les mutations de la source");
+ assertEquals(ANIM_IDLE, bindings.livingMotions().get(LivingMotions.IDLE),
+ "La map du record doit toujours contenir l'entry initiale");
+
+ // Et la map retournee doit elle-meme etre immutable (Map.copyOf)
+ assertThrows(UnsupportedOperationException.class,
+ () -> bindings.livingMotions().put(LivingMotions.RUN, ANIM_WALK),
+ "livingMotions() doit retourner un Map immutable");
+ }
+
+ /** La constante {@link AnimationBindings#EMPTY} est bien vide. */
+ @Test
+ void empty_constant_isEmpty() {
+ assertTrue(AnimationBindings.EMPTY.isEmpty());
+ assertTrue(AnimationBindings.EMPTY.livingMotions().isEmpty());
+ assertNull(AnimationBindings.EMPTY.onEquip());
+ assertNull(AnimationBindings.EMPTY.onUnequip());
+ }
+}