diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java
new file mode 100644
index 0000000..30eae2c
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierImpl.java
@@ -0,0 +1,39 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import com.mojang.serialization.Codec;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackSpeedModifier;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Phase 3 D1 — data-driven concrete implementation of
+ * {@link PlaybackSpeedModifier}. The base functional interface takes five
+ * parameters ; datapack authors never need all of them, so concrete impls
+ * ignore the irrelevant ones.
+ *
+ *
JSON schema :
+ *
{@code
+ * "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+ * }
+ *
+ * Consumer : {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} reads
+ * the modifier and multiplies the current {@code playbackSpeed} by whatever
+ * the modifier returns each tick — so a modifier that returns {@code 0.5F}
+ * halves the animation speed.
+ */
+public interface PlaybackSpeedModifierImpl extends PlaybackSpeedModifier {
+
+ Codec CODEC = PlaybackSpeedModifierRegistry.dispatchCodec();
+
+ ResourceLocation type();
+
+ @Override
+ float modify(DynamicAnimation self, LivingEntityPatch> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java
new file mode 100644
index 0000000..ac79b8e
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackSpeedModifierRegistry.java
@@ -0,0 +1,70 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.modifier.impl.ConstantFactorSpeedModifier;
+import com.tiedup.remake.rig.anim.modifier.impl.LinearRampSpeedModifier;
+
+/**
+ * Registry of {@link PlaybackSpeedModifierImpl} type ids → codecs. Same
+ * dispatch pattern as {@link PoseModifierRegistry}.
+ *
+ * Base impls :
+ *
+ * - {@code tiedup:constant_factor} — multiply speed by a fixed factor
+ * - {@code tiedup:linear_ramp} — linear interpolation between a start and
+ * end factor over {@code elapsedTime} from 0 to {@code duration}
+ *
+ */
+public final class PlaybackSpeedModifierRegistry {
+
+ private static final Map> TYPES = new HashMap<>();
+
+ private PlaybackSpeedModifierRegistry() {}
+
+ public static void register(ResourceLocation id, Codec codec) {
+ if (TYPES.containsKey(id)) {
+ throw new IllegalStateException("Playback speed modifier type " + id + " is already registered.");
+ }
+
+ TYPES.put(id, codec);
+ }
+
+ public static Map> types() {
+ return Collections.unmodifiableMap(TYPES);
+ }
+
+ public static Codec extends PlaybackSpeedModifierImpl> getCodec(ResourceLocation id) {
+ return TYPES.get(id);
+ }
+
+ public static Codec dispatchCodec() {
+ return ResourceLocation.CODEC.partialDispatch(
+ "type",
+ mod -> DataResult.success(mod.type()),
+ id -> {
+ Codec extends PlaybackSpeedModifierImpl> codec = TYPES.get(id);
+ if (codec == null) {
+ return DataResult.error(() -> "Unknown playback speed modifier type: " + id);
+ }
+ return DataResult.success(codec);
+ }
+ );
+ }
+
+ static {
+ register(ConstantFactorSpeedModifier.ID, ConstantFactorSpeedModifier.CODEC);
+ register(LinearRampSpeedModifier.ID, LinearRampSpeedModifier.CODEC);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java
new file mode 100644
index 0000000..d391ebd
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierImpl.java
@@ -0,0 +1,37 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import com.mojang.datafixers.util.Pair;
+import com.mojang.serialization.Codec;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.property.AnimationProperty.PlaybackTimeModifier;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Phase 3 D1 — data-driven concrete implementation of
+ * {@link PlaybackTimeModifier}. The base interface returns a
+ * {@code Pair} of {@code (prevElapsed, elapsed)}, typically used
+ * to loop a sub-section of an animation (set {@code elapsed} back to the
+ * looppoint when it crosses a threshold).
+ *
+ * JSON schema :
+ *
{@code
+ * "elapsed_time_modifier": { "type": "tiedup:loop_section",
+ * "loop_start": 0.3, "loop_end": 0.8 }
+ * }
+ */
+public interface PlaybackTimeModifierImpl extends PlaybackTimeModifier {
+
+ Codec CODEC = PlaybackTimeModifierRegistry.dispatchCodec();
+
+ ResourceLocation type();
+
+ @Override
+ Pair modify(DynamicAnimation self, LivingEntityPatch> entitypatch, float speed, float prevElapsedTime, float elapsedTime);
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java
new file mode 100644
index 0000000..1a8da04
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PlaybackTimeModifierRegistry.java
@@ -0,0 +1,70 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.modifier.impl.LoopSectionTimeModifier;
+
+/**
+ * Registry of {@link PlaybackTimeModifierImpl} type ids → codecs.
+ *
+ * One base impl today :
+ *
+ * - {@code tiedup:loop_section} — rewind elapsed time back to a loop
+ * start when it crosses a loop end threshold
+ *
+ *
+ * Not a ton of base impls because the common «replay this window»
+ * behaviour is what 95% of bondage loops need ; more exotic time warps
+ * (ping-pong, jitter) can be added later without a schema break.
+ */
+public final class PlaybackTimeModifierRegistry {
+
+ private static final Map> TYPES = new HashMap<>();
+
+ private PlaybackTimeModifierRegistry() {}
+
+ public static void register(ResourceLocation id, Codec codec) {
+ if (TYPES.containsKey(id)) {
+ throw new IllegalStateException("Playback time modifier type " + id + " is already registered.");
+ }
+
+ TYPES.put(id, codec);
+ }
+
+ public static Map> types() {
+ return Collections.unmodifiableMap(TYPES);
+ }
+
+ public static Codec extends PlaybackTimeModifierImpl> getCodec(ResourceLocation id) {
+ return TYPES.get(id);
+ }
+
+ public static Codec dispatchCodec() {
+ return ResourceLocation.CODEC.partialDispatch(
+ "type",
+ mod -> DataResult.success(mod.type()),
+ id -> {
+ Codec extends PlaybackTimeModifierImpl> codec = TYPES.get(id);
+ if (codec == null) {
+ return DataResult.error(() -> "Unknown playback time modifier type: " + id);
+ }
+ return DataResult.success(codec);
+ }
+ );
+ }
+
+ static {
+ register(LoopSectionTimeModifier.ID, LoopSectionTimeModifier.CODEC);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java
new file mode 100644
index 0000000..50e4680
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierImpl.java
@@ -0,0 +1,65 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import com.mojang.serialization.Codec;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.Pose;
+import com.tiedup.remake.rig.anim.property.AnimationProperty.PoseModifier;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Phase 3 D1 — data-driven concrete implementation of the functional
+ * {@link PoseModifier} interface.
+ *
+ * {@link PoseModifier} is a raw lambda {@code (self, pose, patch, elapsed,
+ * partialTick) -> void}, which is not serializable on its own. This interface
+ * extends it with a {@link #type()} identifier and a dispatch codec so that
+ * datapack authors can write concrete modifiers in JSON :
+ *
{@code
+ * "pose_modifier": { "type": "tiedup:joint_rotation_offset",
+ * "joint": "upper_arm_left",
+ * "pitch": 15.0, "yaw": 0.0, "roll": 0.0 }
+ * }
+ *
+ * Unlike {@link com.tiedup.remake.rig.anim.action.AnimationAction} which
+ * supports lists (a single animation can fire many actions), a single
+ * {@code pose_modifier} slot accepts exactly one modifier — because
+ * {@code StaticAnimationProperty.POSE_MODIFIER} is declared as {@code }
+ * (singular) and the consumer
+ * ({@link com.tiedup.remake.rig.anim.types.StaticAnimation#modifyPose}) calls
+ * {@code poseModifier.modify(...)} exactly once. Authors who want composition
+ * should chain via {@link com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier}.
+ *
+ * All implementations must be side-safe : {@link PoseModifier#modify} is
+ * invoked in both client render and server logic ticks. Implementations that
+ * mutate the pose must do so deterministically from the inputs only, no IO or
+ * world state.
+ */
+public interface PoseModifierImpl extends PoseModifier {
+
+ /**
+ * Dispatch codec — reads the {@code "type"} field and delegates to the
+ * codec registered for that {@link ResourceLocation}.
+ */
+ Codec CODEC = PoseModifierRegistry.dispatchCodec();
+
+ /**
+ * The registered type id of this modifier (e.g.
+ * {@code tiedup:joint_rotation_offset}).
+ */
+ ResourceLocation type();
+
+ /**
+ * Forwarded from {@link PoseModifier#modify}. Subinterface so that the
+ * generic functional contract stays visible at the implementation site
+ * without Java default-method ambiguity.
+ */
+ @Override
+ void modify(DynamicAnimation self, Pose pose, LivingEntityPatch> entitypatch, float elapsedTime, float partialTick);
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java
new file mode 100644
index 0000000..f75d8db
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/PoseModifierRegistry.java
@@ -0,0 +1,82 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.anim.modifier.impl.ChainedPoseModifier;
+import com.tiedup.remake.rig.anim.modifier.impl.JointRotationOffsetModifier;
+import com.tiedup.remake.rig.anim.modifier.impl.JointTranslationOffsetModifier;
+
+/**
+ * Registry of {@link PoseModifierImpl} type ids → codecs. Mirrors the design
+ * of {@link com.tiedup.remake.rig.anim.action.AnimationActionRegistry}.
+ *
+ * Three base impls are registered in the static initializer :
+ *
+ * - {@code tiedup:joint_rotation_offset} — nudge a single joint's rotation
+ * - {@code tiedup:joint_translation_offset} — nudge a single joint's translation
+ * - {@code tiedup:chain} — run a list of modifiers in order
+ *
+ *
+ * Registering via the static init means a single reference to
+ * {@link PoseModifierImpl#CODEC} in a parse path is enough to bootstrap the
+ * dispatch table, same bootstrap contract as the action registry.
+ */
+public final class PoseModifierRegistry {
+
+ private static final Map> TYPES = new HashMap<>();
+
+ private PoseModifierRegistry() {}
+
+ /**
+ * @throws IllegalStateException if {@code id} is already registered
+ */
+ public static void register(ResourceLocation id, Codec codec) {
+ if (TYPES.containsKey(id)) {
+ throw new IllegalStateException("Pose modifier type " + id + " is already registered.");
+ }
+
+ TYPES.put(id, codec);
+ }
+
+ /**
+ * Unmodifiable view of all registered modifier types.
+ */
+ public static Map> types() {
+ return Collections.unmodifiableMap(TYPES);
+ }
+
+ public static Codec extends PoseModifierImpl> getCodec(ResourceLocation id) {
+ return TYPES.get(id);
+ }
+
+ public static Codec dispatchCodec() {
+ return ResourceLocation.CODEC.partialDispatch(
+ "type",
+ mod -> DataResult.success(mod.type()),
+ id -> {
+ Codec extends PoseModifierImpl> codec = TYPES.get(id);
+ if (codec == null) {
+ return DataResult.error(() -> "Unknown pose modifier type: " + id);
+ }
+ return DataResult.success(codec);
+ }
+ );
+ }
+
+ static {
+ register(JointRotationOffsetModifier.ID, JointRotationOffsetModifier.CODEC);
+ register(JointTranslationOffsetModifier.ID, JointTranslationOffsetModifier.CODEC);
+ register(ChainedPoseModifier.ID, ChainedPoseModifier.CODEC);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java
new file mode 100644
index 0000000..4faf4f7
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ChainedPoseModifier.java
@@ -0,0 +1,97 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import java.util.List;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.Decoder;
+import com.mojang.serialization.DynamicOps;
+import com.mojang.serialization.Encoder;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.Pose;
+import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Composite modifier — runs a list of modifiers sequentially on the same pose.
+ * Compensates for the singular {@code POSE_MODIFIER} property slot (only one
+ * modifier can be attached to an animation, but authors often need several
+ * per-joint nudges).
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:chain",
+ * "modifiers": [
+ * { "type": "tiedup:joint_rotation_offset", "joint": "upper_arm_left", "pitch": 15.0 },
+ * { "type": "tiedup:joint_translation_offset","joint": "hand_right", "y": -0.05 }
+ * ] }
+ * }
+ *
+ * Nested chains are allowed (no cycle detection — an author-written cycle
+ * would manifest as a {@link StackOverflowError} at tick time, which is
+ * identifiable and not worth defensive code). Execution order is list order.
+ */
+public record ChainedPoseModifier(
+ List modifiers
+) implements PoseModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("chain");
+
+ /**
+ * Hand-rolled lazy codec. We cannot use a {@link RecordCodecBuilder} that
+ * references {@link PoseModifierImpl#CODEC} at static-init time because
+ * that would create a cycle :
+ * {@code ChainedPoseModifier.CODEC → PoseModifierImpl.CODEC
+ * → PoseModifierRegistry. → ChainedPoseModifier.CODEC}
+ * which leaves ChainedPoseModifier.CODEC null during the registry
+ * registration call. Instead, we defer the recursive lookup by calling
+ * {@link PoseModifierImpl#CODEC} inside the encode/decode bodies, which
+ * run only at (de)serialization time — long after all static inits have
+ * completed.
+ */
+ public static final Codec CODEC = Codec.of(
+ new Encoder() {
+ @Override
+ public DataResult encode(ChainedPoseModifier input, DynamicOps ops, T prefix) {
+ return PoseModifierImpl.CODEC.listOf()
+ .encodeStart(ops, input.modifiers())
+ .flatMap(list -> ops.mergeToMap(prefix, ops.createString("modifiers"), list));
+ }
+ },
+ new Decoder() {
+ @Override
+ public DataResult> decode(DynamicOps ops, T input) {
+ return ops.getMap(input).flatMap(map -> {
+ T modifiersField = map.get("modifiers");
+ if (modifiersField == null) {
+ return DataResult.error(() -> "Missing field 'modifiers' in tiedup:chain");
+ }
+ return PoseModifierImpl.CODEC.listOf()
+ .parse(ops, modifiersField)
+ .map(list -> com.mojang.datafixers.util.Pair.of(new ChainedPoseModifier(list), input));
+ });
+ }
+ }
+ );
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch> entitypatch, float elapsedTime, float partialTick) {
+ for (PoseModifierImpl m : this.modifiers) {
+ m.modify(self, pose, entitypatch, elapsedTime, partialTick);
+ }
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java
new file mode 100644
index 0000000..794271e
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/ConstantFactorSpeedModifier.java
@@ -0,0 +1,47 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Multiply the current playback speed by a fixed factor every tick.
+ *
+ * JSON schema :
+ *
{@code
+ * "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+ * }
+ *
+ * A {@code factor} of {@code 1.0} is a no-op. Negative factors technically
+ * work (animation plays backward) but the reverse flag in
+ * {@link com.tiedup.remake.rig.anim.AnimationPlayer} already handles that
+ * cleanly — prefer that over a negative speed modifier.
+ */
+public record ConstantFactorSpeedModifier(float factor) implements PlaybackSpeedModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("constant_factor");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("factor").forGetter(ConstantFactorSpeedModifier::factor)
+ ).apply(i, ConstantFactorSpeedModifier::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public float modify(DynamicAnimation self, LivingEntityPatch> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
+ return speed * this.factor;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java
new file mode 100644
index 0000000..852d939
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointRotationOffsetModifier.java
@@ -0,0 +1,79 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import org.joml.Quaternionf;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.util.Mth;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.Pose;
+import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.armature.JointTransform;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Rotate a single named joint by a fixed Euler offset (degrees) at every tick
+ * of the animation. Bondage pipeline use-case : lock a restrained arm joint to
+ * a tighter angle than the authored animation provides.
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:joint_rotation_offset",
+ * "joint": "upper_arm_left",
+ * "pitch": 15.0,
+ * "yaw": 0.0,
+ * "roll": 0.0 }
+ * }
+ *
+ * Angles default to zero if omitted — a {@code joint_rotation_offset} with
+ * all three zero is a no-op (author error, logged nowhere — by design, since
+ * it's a cheap operation and a valid edge case during iteration).
+ *
+ *
If the joint is absent from the pose, this modifier is a silent no-op :
+ * the authored animation simply doesn't touch that joint on this frame. This
+ * matches the semantics of {@link Pose#orElseEmpty} used elsewhere.
+ */
+public record JointRotationOffsetModifier(
+ String joint,
+ float pitch,
+ float yaw,
+ float roll
+) implements PoseModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_rotation_offset");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("joint").forGetter(JointRotationOffsetModifier::joint),
+ Codec.FLOAT.optionalFieldOf("pitch", 0.0F).forGetter(JointRotationOffsetModifier::pitch),
+ Codec.FLOAT.optionalFieldOf("yaw", 0.0F).forGetter(JointRotationOffsetModifier::yaw),
+ Codec.FLOAT.optionalFieldOf("roll", 0.0F).forGetter(JointRotationOffsetModifier::roll)
+ ).apply(i, JointRotationOffsetModifier::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch> entitypatch, float elapsedTime, float partialTick) {
+ if (!pose.hasTransform(this.joint)) {
+ return;
+ }
+
+ JointTransform jt = pose.get(this.joint);
+ Quaternionf offset = new Quaternionf().rotationXYZ(
+ this.pitch * Mth.DEG_TO_RAD,
+ this.yaw * Mth.DEG_TO_RAD,
+ this.roll * Mth.DEG_TO_RAD
+ );
+ jt.rotation().mul(offset);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java
new file mode 100644
index 0000000..570f0fa
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/JointTranslationOffsetModifier.java
@@ -0,0 +1,64 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.Pose;
+import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.armature.JointTransform;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Shift a single named joint by a fixed translation offset at every tick of
+ * the animation. Use-case : pull a constrained wrist joint closer to the
+ * furniture anchor than the authored animation puts it.
+ *
+ * JSON schema :
+ *
{@code
+ * { "type": "tiedup:joint_translation_offset",
+ * "joint": "hand_right",
+ * "x": 0.0, "y": -0.05, "z": 0.02 }
+ * }
+ *
+ * All three axes default to zero — a fully-zero offset is a no-op.
+ * Missing joint is a silent no-op (same contract as the rotation variant).
+ */
+public record JointTranslationOffsetModifier(
+ String joint,
+ float x,
+ float y,
+ float z
+) implements PoseModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("joint_translation_offset");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("joint").forGetter(JointTranslationOffsetModifier::joint),
+ Codec.FLOAT.optionalFieldOf("x", 0.0F).forGetter(JointTranslationOffsetModifier::x),
+ Codec.FLOAT.optionalFieldOf("y", 0.0F).forGetter(JointTranslationOffsetModifier::y),
+ Codec.FLOAT.optionalFieldOf("z", 0.0F).forGetter(JointTranslationOffsetModifier::z)
+ ).apply(i, JointTranslationOffsetModifier::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public void modify(DynamicAnimation self, Pose pose, LivingEntityPatch> entitypatch, float elapsedTime, float partialTick) {
+ if (!pose.hasTransform(this.joint)) {
+ return;
+ }
+
+ JointTransform jt = pose.get(this.joint);
+ jt.translation().add(this.x, this.y, this.z);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java
new file mode 100644
index 0000000..65f7fea
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LinearRampSpeedModifier.java
@@ -0,0 +1,72 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.util.Mth;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Ramp the playback speed factor linearly from {@code from} to {@code to} over
+ * the time window {@code [startTime, endTime]} (in seconds of animation
+ * elapsed). Outside the window, clamps to the boundary value.
+ *
+ * JSON schema :
+ *
{@code
+ * "play_speed_modifier": { "type": "tiedup:linear_ramp",
+ * "from": 0.2, "to": 1.0,
+ * "start_time": 0.0, "end_time": 0.5 }
+ * }
+ *
+ * Use-case : hesitant-start animations (slow initial frames, then ramp to
+ * full speed for the payoff). The modifier's output is multiplied against the
+ * base speed by {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick}, so
+ * {@code from=1.0, to=1.0} is a no-op.
+ *
+ *
{@code end_time <= start_time} degenerates to returning {@code to} for
+ * all {@code elapsedTime >= start_time} (clamped path above saturation).
+ */
+public record LinearRampSpeedModifier(
+ float from,
+ float to,
+ float startTime,
+ float endTime
+) implements PlaybackSpeedModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("linear_ramp");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("from").forGetter(LinearRampSpeedModifier::from),
+ Codec.FLOAT.fieldOf("to").forGetter(LinearRampSpeedModifier::to),
+ Codec.FLOAT.optionalFieldOf("start_time", 0.0F).forGetter(LinearRampSpeedModifier::startTime),
+ Codec.FLOAT.optionalFieldOf("end_time", 1.0F).forGetter(LinearRampSpeedModifier::endTime)
+ ).apply(i, LinearRampSpeedModifier::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public float modify(DynamicAnimation self, LivingEntityPatch> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
+ float factor;
+ if (elapsedTime <= this.startTime) {
+ factor = this.from;
+ } else if (elapsedTime >= this.endTime || this.endTime <= this.startTime) {
+ factor = this.to;
+ } else {
+ float progress = (elapsedTime - this.startTime) / (this.endTime - this.startTime);
+ factor = Mth.lerp(progress, this.from, this.to);
+ }
+ return speed * factor;
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java
new file mode 100644
index 0000000..bd87618
--- /dev/null
+++ b/src/main/java/com/tiedup/remake/rig/anim/modifier/impl/LoopSectionTimeModifier.java
@@ -0,0 +1,68 @@
+/*
+ * © 2026 TiedUp! Remake Contributors, distributed under GPLv3.
+ */
+
+package com.tiedup.remake.rig.anim.modifier.impl;
+
+import com.mojang.datafixers.util.Pair;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import net.minecraft.resources.ResourceLocation;
+
+import com.tiedup.remake.rig.TiedUpRigConstants;
+import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl;
+import com.tiedup.remake.rig.anim.types.DynamicAnimation;
+import com.tiedup.remake.rig.patch.LivingEntityPatch;
+
+/**
+ * Rewind the animation playhead back to {@code loopStart} every time it
+ * crosses {@code loopEnd}. Matches the «sustain loop» pattern used e.g. for
+ * breathing idles inside a rest phase — the intro plays once, a middle section
+ * loops, and the outro is triggered by a separate state transition (not this
+ * modifier).
+ *
+ * JSON schema :
+ *
{@code
+ * "elapsed_time_modifier": { "type": "tiedup:loop_section",
+ * "loop_start": 0.3, "loop_end": 0.8 }
+ * }
+ *
+ * This modifier is a pure function of {@code elapsedTime} — it does not
+ * mutate the animation. The {@code prevElapsedTime} is rewound symmetrically
+ * so event triggers using {@code [prev, elapsed]} windows still fire cleanly
+ * after the rewind.
+ *
+ *
Invariant : {@code loopStart < loopEnd}. Violating this yields undefined
+ * behaviour (probably a NaN or an instant loop) — no defensive check because
+ * the author would notice immediately on first playback.
+ */
+public record LoopSectionTimeModifier(
+ float loopStart,
+ float loopEnd
+) implements PlaybackTimeModifierImpl {
+
+ public static final ResourceLocation ID = TiedUpRigConstants.identifier("loop_section");
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.FLOAT.fieldOf("loop_start").forGetter(LoopSectionTimeModifier::loopStart),
+ Codec.FLOAT.fieldOf("loop_end").forGetter(LoopSectionTimeModifier::loopEnd)
+ ).apply(i, LoopSectionTimeModifier::new));
+
+ @Override
+ public ResourceLocation type() {
+ return ID;
+ }
+
+ @Override
+ public Pair modify(DynamicAnimation self, LivingEntityPatch> entitypatch, float speed, float prevElapsedTime, float elapsedTime) {
+ if (elapsedTime >= this.loopEnd) {
+ float overshoot = elapsedTime - this.loopEnd;
+ float sectionLen = this.loopEnd - this.loopStart;
+ float wrapped = this.loopStart + (overshoot % sectionLen);
+ float prevWrapped = prevElapsedTime - (elapsedTime - wrapped);
+ return Pair.of(prevWrapped, wrapped);
+ }
+ return Pair.of(prevElapsedTime, elapsedTime);
+ }
+}
diff --git a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
index b5c439e..4016497 100644
--- a/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
+++ b/src/main/java/com/tiedup/remake/rig/anim/property/AnimationProperty.java
@@ -17,6 +17,7 @@ import com.google.common.collect.Maps;
import com.google.gson.JsonElement;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
import com.mojang.serialization.JsonOps;
import net.minecraft.resources.ResourceLocation;
@@ -29,6 +30,9 @@ import com.tiedup.remake.rig.anim.LivingMotion;
import com.tiedup.remake.rig.anim.Pose;
import com.tiedup.remake.rig.anim.TransformSheet;
import com.tiedup.remake.rig.anim.action.DataDrivenAnimationEvents;
+import com.tiedup.remake.rig.anim.modifier.PlaybackSpeedModifierImpl;
+import com.tiedup.remake.rig.anim.modifier.PlaybackTimeModifierImpl;
+import com.tiedup.remake.rig.anim.modifier.PoseModifierImpl;
import com.tiedup.remake.rig.anim.property.AnimationEvent.SimpleEvent;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordGetter;
import com.tiedup.remake.rig.anim.property.MoveCoordFunctions.MoveCoordSetter;
@@ -46,6 +50,53 @@ import com.tiedup.remake.rig.patch.item.CapabilityItem;
public abstract class AnimationProperty {
private static final Map> SERIALIZABLE_ANIMATION_PROPERTY_KEYS = Maps.newHashMap();
+
+ /**
+ * Phase 3 D1 — {@link LivingMotion} codec. {@code LivingMotion} is an
+ * {@link com.tiedup.remake.rig.util.ExtendableEnum} so its string form is
+ * the {@link #toString()} lowercased ; we deserialize via
+ * {@code ENUM_MANAGER.getOrThrow(String)} which throws if unknown.
+ */
+ public static final Codec LIVING_MOTION_CODEC = Codec.STRING.flatXmap(
+ name -> {
+ try {
+ return DataResult.success(LivingMotion.ENUM_MANAGER.getOrThrow(name));
+ } catch (java.util.NoSuchElementException e) {
+ return DataResult.error(() -> "Unknown living motion: " + name);
+ }
+ },
+ motion -> DataResult.success(motion.toString().toLowerCase())
+ );
+
+ /**
+ * Phase 3 D1 — {@link TimePairList} codec. Authored as a flat list of
+ * floats that must have an even length (pairs of {@code begin, end}).
+ * Odd-length lists surface as a codec error (logged + property skipped).
+ *
+ * JSON shape : {@code [0.0, 0.3, 0.6, 0.9]} → two pairs, {@code [0,0.3]}
+ * and {@code [0.6,0.9]}. We go through a float[] because {@code
+ * TimePairList.create} is varargs. Decoding via
+ * {@link TimePairList#create(float...)} surfaces the odd-count invariant
+ * via {@link IllegalArgumentException} — wrapped into a {@link DataResult}
+ * error.
+ */
+ public static final Codec TIME_PAIR_LIST_CODEC = Codec.FLOAT.listOf().flatXmap(
+ list -> {
+ if ((list.size() & 1) != 0) {
+ return DataResult.error(() -> "TimePairList must have an even number of floats, got " + list.size());
+ }
+ float[] arr = new float[list.size()];
+ for (int i = 0; i < arr.length; i++) {
+ arr[i] = list.get(i);
+ }
+ return DataResult.success(TimePairList.create(arr));
+ },
+ // Encode back : no public accessor on TimePairList's internal pairs,
+ // and the property pipeline is read-only from JSON (never re-serialized
+ // to disk) — we therefore refuse encoding. If round-trip becomes a
+ // requirement, expose TimePairList#asFloatArray() first.
+ tpl -> DataResult.error(() -> "TimePairList encoding is not supported (read-only datapack property)")
+ );
@SuppressWarnings("unchecked")
public static AnimationProperty getSerializableProperty(String name) {
@@ -135,38 +186,116 @@ public abstract class AnimationProperty {
/**
* An event triggered when entity changes an item in hand.
- * Retenu comme hook datapack : un event écrit en JSON par un datapack tiers peut s'abonner
- * au changement d'item porté pour déclencher une réaction (voir Phase 3 data-driven anims).
+ *
+ * Phase 3 D1 — Category C, SKIPPED. The item-change event is a
+ * combat/equipment hook from Epic Fight upstream ; no bondage pipeline
+ * consumer reads this property today. Re-enable (register a codec) when
+ * weapon/equipment reactive animations are reintroduced.
*/
public static final StaticAnimationProperty>> ON_ITEM_CHANGE_EVENT = new StaticAnimationProperty>> ();
-
+
/**
* You can modify the playback speed of the animation.
+ *
+ * Phase 3 D1 — serializable via the
+ * {@link PlaybackSpeedModifierImpl#CODEC} dispatch codec (Category B).
+ * JSON example :
+ *
{@code
+ * "play_speed_modifier": { "type": "tiedup:constant_factor", "factor": 0.5 }
+ * }
+ * The upcast {@code PlaybackSpeedModifierImpl → PlaybackSpeedModifier}
+ * is implicit (sub-interface) so the property stays typed as the wider
+ * functional type for backward-compat with existing consumers in
+ * {@link com.tiedup.remake.rig.anim.AnimationPlayer#tick} and
+ * {@link com.tiedup.remake.rig.anim.client.AnimationSubFileReader}.
*/
- public static final StaticAnimationProperty PLAY_SPEED_MODIFIER = new StaticAnimationProperty ();
+ public static final StaticAnimationProperty PLAY_SPEED_MODIFIER = new StaticAnimationProperty (
+ "play_speed_modifier",
+ PlaybackSpeedModifierImpl.CODEC.xmap(
+ impl -> (PlaybackSpeedModifier) impl,
+ base -> {
+ if (base instanceof PlaybackSpeedModifierImpl impl) return impl;
+ throw new IllegalStateException("PlaybackSpeedModifier value is not a serializable impl: " + base);
+ }
+ )
+ );
+
+ /**
+ * You can modify the elapsed playback time of the animation.
+ *
+ * Phase 3 D1 — serializable via the
+ * {@link PlaybackTimeModifierImpl#CODEC} dispatch codec (Category B).
+ * JSON example :
+ *
{@code
+ * "elapsed_time_modifier": { "type": "tiedup:loop_section",
+ * "loop_start": 0.3, "loop_end": 0.8 }
+ * }
+ */
+ public static final StaticAnimationProperty ELAPSED_TIME_MODIFIER = new StaticAnimationProperty (
+ "elapsed_time_modifier",
+ PlaybackTimeModifierImpl.CODEC.xmap(
+ impl -> (PlaybackTimeModifier) impl,
+ base -> {
+ if (base instanceof PlaybackTimeModifierImpl impl) return impl;
+ throw new IllegalStateException("PlaybackTimeModifier value is not a serializable impl: " + base);
+ }
+ )
+ );
+
+ /**
+ * This property will be called both in client and server when modifying the pose.
+ *
+ * Phase 3 D1 — serializable via the
+ * {@link PoseModifierImpl#CODEC} dispatch codec (Category B). Key
+ * artist unlock for bondage : per-joint nudges can be authored
+ * from JSON :
+ *
{@code
+ * "pose_modifier": { "type": "tiedup:chain",
+ * "modifiers": [
+ * { "type": "tiedup:joint_rotation_offset", "joint": "upper_arm_left", "pitch": 15.0 },
+ * { "type": "tiedup:joint_translation_offset", "joint": "hand_right", "y": -0.05 }
+ * ] }
+ * }
+ */
+ public static final StaticAnimationProperty POSE_MODIFIER = new StaticAnimationProperty (
+ "pose_modifier",
+ PoseModifierImpl.CODEC.xmap(
+ impl -> (PoseModifier) impl,
+ base -> {
+ if (base instanceof PoseModifierImpl impl) return impl;
+ throw new IllegalStateException("PoseModifier value is not a serializable impl: " + base);
+ }
+ )
+ );
+
+ /**
+ * Fix the head rotation to the player's body rotation.
+ *
+ * Phase 3 D1 — Category A, trivial boolean codec.
+ */
+ public static final StaticAnimationProperty FIXED_HEAD_ROTATION = new StaticAnimationProperty (
+ "fixed_head_rotation",
+ Codec.BOOL
+ );
/**
- * You can modify the playback speed of the animation.
- */
- public static final StaticAnimationProperty ELAPSED_TIME_MODIFIER = new StaticAnimationProperty ();
-
- /**
- * This property will be called both in client and server when modifying the pose
- */
- public static final StaticAnimationProperty POSE_MODIFIER = new StaticAnimationProperty ();
-
- /**
- * Fix the head rotation to the player's body rotation
- */
- public static final StaticAnimationProperty FIXED_HEAD_ROTATION = new StaticAnimationProperty ();
-
- /**
- * Defines static animations as link animation when the animation is followed by a specific animation
+ * Defines static animations as link animation when the animation is followed by a specific animation.
+ *
+ * Phase 3 D1 — Category D, SKIPPED. The map value is an
+ * {@link AnimationAccessor} which is an internal registry handle (not a
+ * data-describable object) ; resolving accessors from ids requires the
+ * {@link com.tiedup.remake.rig.anim.AnimationManager} to already be
+ * populated, which is only true after full resource-pack load. The
+ * existing sub-file reader path handles transitions correctly — moving
+ * the parse here would create a chicken-and-egg bootstrapping issue.
*/
public static final StaticAnimationProperty