diff --git a/src/AcDream.Core/Physics/Motion/CSequence.cs b/src/AcDream.Core/Physics/Motion/CSequence.cs
index 06689e0b..a9da00b8 100644
--- a/src/AcDream.Core/Physics/Motion/CSequence.cs
+++ b/src/AcDream.Core/Physics/Motion/CSequence.cs
@@ -6,6 +6,26 @@ using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
+///
+/// R1-P4 host seam standing in for retail's CPhysicsObj.anim_hooks
+/// SmartArray + the global AnimDoneHook singleton
+/// (add_anim_hook 0x00514c20; process_hooks 0x00511550 drains
+/// once per physics tick — the drain point stays with the host until R6
+/// places it per retail's UpdateObjectInternal order).
+///
+public interface IAnimHookQueue
+{
+ /// Queue a matched AnimFrame hook (already direction-filtered
+ /// by execute_hooks).
+ void AddAnimHook(DatReaderWriter.Types.AnimationHook hook);
+
+ /// Queue the global animation-done hook — retail's
+ /// AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
+ /// CPartArray::AnimationDone(1) chain (R2 consumes it as
+ /// MotionDone).
+ void AddAnimDoneHook();
+}
+
///
/// R1-P2 — verbatim port of retail's CSequence container + list
/// surgery (Phase R plan stage R1; oracle
@@ -273,6 +293,211 @@ public sealed class CSequence
FrameOps.Rotate(frame, Omega * sq);
}
+ // ── R1-P4: the frame-advance core ───────────────────────────────────
+
+ /// Host hook queue (hook_obj); null = hooks dropped
+ /// (objects without a physics host).
+ public IAnimHookQueue? HookObj;
+
+ ///
+ /// CSequence::update (0x00525b80, §22): non-empty list →
+ /// then ; empty list
+ /// with a Frame → accumulated-physics free motion.
+ ///
+ public void Update(double timeElapsed, Frame? frame)
+ {
+ if (_animList.Count > 0 && _currAnim is not null)
+ {
+ UpdateInternal(timeElapsed, frame);
+ Apricot();
+ }
+ else if (frame is not null)
+ {
+ ApplyPhysics(frame, timeElapsed, timeElapsed);
+ }
+ }
+
+ ///
+ /// CSequence::update_internal (0x005255d0, §21 — ACE-verified
+ /// skeleton, P0-pins.md): advance by
+ /// framerate·dt; on overshoot clamp to the RAW high/low frame, compute
+ /// the leftover time, and mark animDone; fire the pose/physics/hook
+ /// triple for EVERY crossed integer frame (ascending forward,
+ /// descending reverse); queue the global AnimDoneHook when the list
+ /// HEAD is no longer the cyclic tail (G5's structural gate); advance to
+ /// the next node and LOOP with the carried leftover (P0 pin).
+ /// Iterative (retail while-true), NO safety cap (G4), NO boundary
+ /// epsilon (G1/G3).
+ ///
+ public void UpdateInternal(double timeElapsed, Frame? frame)
+ {
+ while (true)
+ {
+ if (_currAnim is null)
+ return;
+
+ var currAnim = _currAnim.Value;
+ double framerate = currAnim.Framerate;
+ double frametime = framerate * timeElapsed;
+ int lastFrame = (int)Math.Floor(FrameNumber);
+
+ FrameNumber += frametime;
+ double frameTimeElapsed = 0.0;
+ bool animDone = false;
+
+ if (frametime > 0.0)
+ {
+ if (currAnim.HighFrame < Math.Floor(FrameNumber))
+ {
+ double frameOffset = FrameNumber - currAnim.HighFrame - 1.0;
+ if (frameOffset < 0.0)
+ frameOffset = 0.0;
+ if (Math.Abs(framerate) > FrameOps.FEpsilon)
+ frameTimeElapsed = frameOffset / framerate;
+ FrameNumber = currAnim.HighFrame;
+ animDone = true;
+ }
+ while (Math.Floor(FrameNumber) > lastFrame)
+ {
+ if (frame is not null)
+ {
+ var pos = currAnim.GetPosFrame(lastFrame);
+ if (pos is not null)
+ FrameOps.Combine(frame, pos);
+ if (Math.Abs(framerate) > FrameOps.FEpsilon)
+ ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
+ }
+ ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1);
+ lastFrame++;
+ }
+ }
+ else if (frametime < 0.0)
+ {
+ if (currAnim.LowFrame > Math.Floor(FrameNumber))
+ {
+ double frameOffset = FrameNumber - currAnim.LowFrame;
+ if (frameOffset > 0.0)
+ frameOffset = 0.0;
+ if (Math.Abs(framerate) > FrameOps.FEpsilon)
+ frameTimeElapsed = frameOffset / framerate;
+ FrameNumber = currAnim.LowFrame;
+ animDone = true;
+ }
+ while (Math.Floor(FrameNumber) < lastFrame)
+ {
+ if (frame is not null)
+ {
+ var pos = currAnim.GetPosFrame(lastFrame);
+ if (pos is not null)
+ FrameOps.Subtract1(frame, pos);
+ if (Math.Abs(framerate) > FrameOps.FEpsilon)
+ ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
+ }
+ ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1);
+ lastFrame--;
+ }
+ }
+ else
+ {
+ if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon)
+ ApplyPhysics(frame, timeElapsed, timeElapsed);
+ return;
+ }
+
+ if (!animDone)
+ return;
+
+ // AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) —
+ // fire only when the consumed head is not already the cyclic
+ // tail (retail 0x00525943-0x00525968).
+ if (HookObj is not null
+ && _animList.First is not null
+ && !ReferenceEquals(_animList.First, _firstCyclic))
+ {
+ HookObj.AddAnimDoneHook();
+ }
+
+ AdvanceToNextAnimation(timeElapsed, frame);
+ timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry
+ }
+ }
+
+ ///
+ /// CSequence::advance_to_next_animation (0x005252b0, §23): four
+ /// pose operations per transition — un-apply the outgoing node's pose
+ /// (subtract1 + residual physics), step (forward wraps to
+ /// first_cyclic; REVERSE wraps to the LIST TAIL — asymmetric by
+ /// design), reseed from the incoming node's
+ /// direction-aware boundary, apply the incoming pose (combine +
+ /// physics). Pose ops run in BOTH directions (ACE's framerate-sign
+ /// gates are ACE-isms; the decomp is unconditional apart from the
+ /// degenerate-framerate guard).
+ ///
+ public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
+ {
+ if (_currAnim is null)
+ return;
+
+ var outgoing = _currAnim.Value;
+
+ // (a) un-apply the outgoing node's pose at the CURRENT FrameNumber.
+ if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon)
+ {
+ var pos = outgoing.GetPosFrame((int)FrameNumber);
+ if (pos is not null)
+ FrameOps.Subtract1(frame, pos);
+ ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
+ }
+
+ // (b) step; (c) reseed FrameNumber from the incoming boundary.
+ if (timeElapsed >= 0.0)
+ {
+ _currAnim = _currAnim.Next ?? _firstCyclic;
+ if (_currAnim is null)
+ return;
+ FrameNumber = _currAnim.Value.GetStartingFrame();
+ }
+ else
+ {
+ _currAnim = _currAnim.Previous ?? _animList.Last;
+ if (_currAnim is null)
+ return;
+ FrameNumber = _currAnim.Value.GetEndingFrame();
+ }
+
+ // (d) apply the incoming node's pose at the new FrameNumber.
+ var incoming = _currAnim.Value;
+ if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon)
+ {
+ var pos = incoming.GetPosFrame((int)FrameNumber);
+ if (pos is not null)
+ FrameOps.Combine(frame, pos);
+ ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
+ }
+ }
+
+ ///
+ /// CSequence::execute_hooks (0x00524830, §18): QUEUE the part
+ /// frame's hooks whose direction is Both (0) or matches the playback
+ /// direction (+1 forward / −1 backward) into the host. Null part frame
+ /// guarded (retail has a latent null-deref here — documented safe
+ /// divergence, G18).
+ ///
+ private void ExecuteHooks(AnimationFrame? partFrame, int direction)
+ {
+ if (partFrame is null || HookObj is null)
+ return;
+
+ foreach (var hook in partFrame.Hooks)
+ {
+ if (hook is null)
+ continue;
+ int dir = (int)hook.Direction;
+ if (dir == 0 || dir == direction)
+ HookObj.AddAnimHook(hook);
+ }
+ }
+
// ── internal cursors for P4 (update_internal operates on nodes) ─────
internal LinkedListNode? CurrAnimNode
diff --git a/src/AcDream.Core/Physics/Motion/FrameOps.cs b/src/AcDream.Core/Physics/Motion/FrameOps.cs
index 650d90b3..a36a1292 100644
--- a/src/AcDream.Core/Physics/Motion/FrameOps.cs
+++ b/src/AcDream.Core/Physics/Motion/FrameOps.cs
@@ -55,4 +55,28 @@ public static class FrameOps
/// global through the orientation, then .
public static void Rotate(Frame frame, Vector3 rotationLocal)
=> GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation));
+
+ ///
+ /// Frame::combine as used by the CSequence pose path (ACE
+ /// AFrame.Combine, verified against the pre-R1 acdream port):
+ /// apply the pose — origin += rotate(pos.Origin by orientation), then
+ /// orientation ∘= pos.Orientation.
+ ///
+ public static void Combine(Frame frame, Frame pos)
+ {
+ frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
+ frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
+ }
+
+ ///
+ /// Frame::subtract1 — un-apply the pose: orientation ∘=
+ /// conj(pos.Orientation) FIRST, then origin −= rotate(pos.Origin by the
+ /// UPDATED orientation) (inverse order of ).
+ ///
+ public static void Subtract1(Frame frame, Frame pos)
+ {
+ frame.Orientation = Quaternion.Normalize(
+ frame.Orientation * Quaternion.Conjugate(pos.Orientation));
+ frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs
new file mode 100644
index 00000000..4b486bb1
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs
@@ -0,0 +1,269 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// R1-P4 — the frame-advance core: update_internal (0x005255d0),
+/// update (0x00525b80), advance_to_next_animation
+/// (0x005252b0), execute_hooks (0x00524830) — gaps
+/// G3/G4/G5/G6/G8/G9/G19.
+///
+/// Skeleton per the ACE-verified structure (P0-pins.md): overshoot →
+/// clamp to the RAW high/low frame + leftover carry + animDone →
+/// per-crossed-frame pose/physics/hook loop → AnimDone gate
+/// (list HEAD != first_cyclic) → advance (pose-out both directions,
+/// forward wraps to first_cyclic, reverse wraps to the LIST TAIL) →
+/// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary
+/// epsilon.
+///
+public class CSequenceUpdateTests
+{
+ private sealed class MapLoader : IAnimationLoader
+ {
+ private readonly Dictionary _map = new();
+ public void Add(uint id, Animation anim) => _map[id] = anim;
+ public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null;
+ }
+
+ private sealed class RecordingHookQueue : IAnimHookQueue
+ {
+ public readonly List Events = new();
+ public void AddAnimHook(AnimationHook hook)
+ => Events.Add($"hook:{((TaggedHook)hook).Tag}:{hook.Direction}");
+ public void AddAnimDoneHook() => Events.Add("ANIMDONE");
+ }
+
+ /// Hook subclass carrying a test tag (frame index).
+ private sealed class TaggedHook : AnimationHook
+ {
+ public string Tag = "";
+ public override AnimationHookType HookType => (AnimationHookType)0;
+ }
+
+ /// numFrames frames; one Forward-direction tagged hook per frame.
+ private static Animation MakeAnim(int numFrames, bool posFrames = false, string hookPrefix = "f")
+ {
+ var anim = new Animation();
+ for (int f = 0; f < numFrames; f++)
+ {
+ var pf = new AnimationFrame(1u);
+ pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity });
+ pf.Hooks.Add(new TaggedHook { Tag = $"{hookPrefix}{f}", Direction = AnimationHookDir.Both });
+ anim.PartFrames.Add(pf);
+ if (posFrames)
+ anim.PosFrames.Add(new Frame { Origin = new Vector3(1, 0, 0), Orientation = Quaternion.Identity });
+ }
+ return anim;
+ }
+
+ private static AnimData Ad(uint animId, float framerate = 30f, int low = 0, int high = -1)
+ {
+ QualifiedDataId qid = animId;
+ return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate };
+ }
+
+ private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f)
+ {
+ var loader = new MapLoader();
+ loader.Add(1u, MakeAnim(frames));
+ var seq = new CSequence(loader);
+ var hooks = new RecordingHookQueue();
+ seq.HookObj = hooks;
+ seq.AppendAnimation(Ad(1u, framerate));
+ return (seq, hooks);
+ }
+
+ // ── forward advance basics (G3) ─────────────────────────────────────
+
+ [Fact]
+ public void Forward_SingleTick_AdvancesOneFrame_FiresExitedFrameHook()
+ {
+ var (seq, hooks) = Cyclic(5);
+
+ seq.Update(1.0 / 30.0, null);
+
+ Assert.Equal(1.0, seq.FrameNumber, 9);
+ Assert.Equal(new[] { "hook:f0:Both" }, hooks.Events);
+ }
+
+ [Fact]
+ public void Forward_ExactBoundaryLanding_NoAdvance()
+ {
+ // Boundary test is STRICT (>): landing exactly at high_frame+0.0
+ // integer positions must NOT advance (the G3 bug class: acdream's
+ // old epsilon-shifted boundary reclassified these).
+ var (seq, hooks) = Cyclic(5);
+ seq.FrameNumber = 3.5;
+
+ seq.Update(0.5 / 30.0, null); // lands exactly at 4.0 == high_frame
+
+ Assert.Equal(4.0, seq.FrameNumber, 9);
+ Assert.Equal(new[] { "hook:f3:Both" }, hooks.Events); // crossed 3→4
+ Assert.DoesNotContain("ANIMDONE", hooks.Events);
+ }
+
+ [Fact]
+ public void Forward_CyclicWrap_NoAnimDone_RestartsAtStartingFrame()
+ {
+ // Single cyclic node: overshoot wraps to itself via first_cyclic;
+ // head == first_cyclic → NO AnimDoneHook (G5's structural gate).
+ var (seq, hooks) = Cyclic(3);
+ seq.FrameNumber = 2.0;
+
+ seq.Update(1.0 / 30.0, null); // 2→3 > high(2) → clamp, advance, wrap
+
+ Assert.Equal(0.0, seq.FrameNumber, 9);
+ Assert.DoesNotContain("ANIMDONE", hooks.Events);
+ Assert.Same(seq.FirstCyclic, seq.CurrAnim);
+ }
+
+ // ── multi-node fast-forward + the P0-pinned carry (G3/G5) ───────────
+
+ [Fact]
+ public void Forward_LinkToCycle_FiresAnimDone_AndCarriesLeftover()
+ {
+ // link (2 frames) + cycle (4 frames): a 0.1s tick (3 frames at
+ // 30fps) exhausts the link (2 frames) and carries 1 frame of time
+ // into the cycle. Retail order: link's crossed-frame hooks →
+ // AnimDoneHook (head != first_cyclic) → cycle's crossed hooks.
+ var loader = new MapLoader();
+ loader.Add(1u, MakeAnim(2, hookPrefix: "link"));
+ loader.Add(2u, MakeAnim(4, hookPrefix: "cyc"));
+ var seq = new CSequence(loader);
+ var hooks = new RecordingHookQueue();
+ seq.HookObj = hooks;
+ seq.AppendAnimation(Ad(1u)); // link — first_cyclic slides…
+ seq.AppendAnimation(Ad(2u)); // …to the cycle
+
+ seq.Update(0.1, null); // 3 frames of time
+
+ // link: FrameNumber 0→3, clamp to high(1), leftover = 1 frame;
+ // crossing fires link f0 only (floor(1)>0), then AnimDone, then
+ // advance → cycle at starting(0); carry 1/30 → cycle 0→1 fires cyc f0.
+ Assert.Equal(new[] { "hook:link0:Both", "ANIMDONE", "hook:cyc0:Both" }, hooks.Events);
+ Assert.Equal(1.0, seq.FrameNumber, 6); // the carry (P0 pin) — zeroing would leave 0.0
+ Assert.Same(seq.FirstCyclic, seq.CurrAnim); // now inside the cycle
+ }
+
+ // ── reverse playback (G3/G9) ────────────────────────────────────────
+
+ [Fact]
+ public void Reverse_DescendingHooks_BackwardDirection()
+ {
+ // Natively-reverse node (framerate −30): seeded at starting =
+ // high+1 = 5. Each tick fires the hook of the frame being LEFT
+ // (the pre-tick floor index), descending; the very first tick's
+ // index (5 = high+1) is OOB → null part frame → no hook, exactly
+ // the retail structure.
+ var (seq, hooks) = Cyclic(5, framerate: -30f);
+ Assert.Equal(5.0, seq.FrameNumber, 9); // get_starting_frame = high+1
+
+ seq.Update(1.0 / 30.0, null); // 5→4 (leaves OOB index 5 — nothing)
+ seq.Update(1.0 / 30.0, null); // 4→3 (leaves frame 4)
+ seq.Update(1.0 / 30.0, null); // 3→2 (leaves frame 3)
+
+ Assert.Equal(2.0, seq.FrameNumber, 9);
+ Assert.Equal(new[] { "hook:f4:Both", "hook:f3:Both" }, hooks.Events);
+ }
+
+ [Fact]
+ public void Reverse_AdvanceWrapsToListTail()
+ {
+ // Two nodes A(reverse),B(forward): exhausting A backward wraps
+ // curr_anim to the LIST TAIL (B) — retail's asymmetric wrap (G9).
+ var loader = new MapLoader();
+ loader.Add(1u, MakeAnim(3, hookPrefix: "a"));
+ loader.Add(2u, MakeAnim(4, hookPrefix: "b"));
+ var seq = new CSequence(loader);
+ var hooks = new RecordingHookQueue();
+ seq.HookObj = hooks;
+ seq.AppendAnimation(Ad(1u, framerate: -30f)); // A — curr seeds here
+ seq.AppendAnimation(Ad(2u)); // B = first_cyclic = tail
+ seq.FrameNumber = 0.5; // mid-window of A
+
+ seq.Update(0.02, null); // frametime = −0.6 → crosses low(0)
+
+ // After the reverse advance, curr must have left A via the TAIL
+ // wrap (B), whatever the carry then does within B.
+ Assert.NotNull(seq.CurrAnim);
+ Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3)
+ }
+
+ // ── stationary / degenerate (G8 + else-branch) ──────────────────────
+
+ [Fact]
+ public void ZeroFramerate_PhysicsOnlyAndReturn()
+ {
+ var (seq, hooks) = Cyclic(3, framerate: 0f);
+ seq.SetVelocity(new Vector3(2, 0, 0));
+ var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
+
+ seq.Update(0.5, frame);
+
+ // frametime == 0 → apply_physics(frame, elapsed, elapsed) then return.
+ Assert.Equal(1.0f, frame.Origin.X, 5);
+ Assert.Empty(hooks.Events);
+ Assert.Equal(0.0, seq.FrameNumber, 9);
+ }
+
+ [Fact]
+ public void EmptyList_Update_AppliesAccumulatedPhysics()
+ {
+ // update (0x00525b80): empty anim_list + frame != null →
+ // apply_physics(frame, elapsed, elapsed) — free-fall/knockback
+ // with no animation (G8).
+ var seq = new CSequence(new MapLoader());
+ seq.SetVelocity(new Vector3(0, 3, 0));
+ var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
+
+ seq.Update(2.0, frame);
+
+ Assert.Equal(6.0f, frame.Origin.Y, 5);
+ }
+
+ // ── root motion into the caller Frame (G7 wiring half) ──────────────
+
+ [Fact]
+ public void Forward_PosFrames_CombinedIntoCallerFrame()
+ {
+ // Each crossed frame combines the node's pos_frame into the caller
+ // Frame (retail root motion): 2 crossed frames → +2 X.
+ var loader = new MapLoader();
+ loader.Add(1u, MakeAnim(5, posFrames: true));
+ var seq = new CSequence(loader);
+ seq.AppendAnimation(Ad(1u));
+ var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
+
+ seq.Update(2.0 / 30.0, frame); // crosses frames 0 and 1
+
+ Assert.Equal(2.0f, frame.Origin.X, 5);
+ Assert.Equal(2.0, seq.FrameNumber, 9);
+ }
+
+ // ── update entry contract (G19) ─────────────────────────────────────
+
+ [Fact]
+ public void Update_RunsApricot_TrimmingConsumedLinkNodes()
+ {
+ // After the link→cycle advance, apricot (called by update) frees
+ // the consumed link node.
+ var loader = new MapLoader();
+ loader.Add(1u, MakeAnim(2, hookPrefix: "link"));
+ loader.Add(2u, MakeAnim(4, hookPrefix: "cyc"));
+ var seq = new CSequence(loader);
+ seq.AppendAnimation(Ad(1u));
+ seq.AppendAnimation(Ad(2u));
+ Assert.Equal(2, seq.Count);
+
+ seq.Update(0.1, null); // exhausts the link, advances into the cycle
+
+ Assert.Equal(1, seq.Count); // link trimmed by apricot
+ Assert.Same(seq.FirstCyclic, seq.CurrAnim);
+ }
+}