diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 5adc39b..d9ba4ca 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -41,6 +41,8 @@ public sealed class DatCollectionLoader : IAnimationLoader // FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent) // FUN_005360d0 — quaternion slerp with dot-product sign-flip // MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping +// Sequence.cs:262-270 (ACE) — execute_hooks (Both or matching direction fires) +// Sequence.cs:351-443 (ACE) — update_internal with per-frame hook dispatch // // DatReaderWriter types used: // MotionTable.Links : Dictionary @@ -48,9 +50,14 @@ public sealed class DatCollectionLoader : IAnimationLoader // MotionCommandData.MotionData : Dictionary // key = target motion (int cast of MotionCommand) // MotionData.Anims : List +// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics) +// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02) // AnimData.AnimId : QualifiedDataId // Animation.PartFrames : List +// Animation.PosFrames : List (root motion, present if Flags & PosFrames) +// Animation.Flags : AnimationFlags (PosFrames = 0x01) // AnimationFrame.Frames : List +// AnimationFrame.Hooks : List // Frame.Origin : Vector3, Frame.Orientation : Quaternion // ───────────────────────────────────────────────────────────────────────────── @@ -74,10 +81,10 @@ public readonly struct PartTransform /// One entry in the animation queue (link transition or looping cycle). /// /// Faithfully models the retail client AnimNode struct at +0x0C..+0x18. -/// When speedScale < 0, startFrame and endFrame are swapped at construction -/// time (FUN_005267E0 / multiply_framerate) so the advance loop always has: -/// forward: startFrame ≤ endFrame (framePosition counts up) -/// reverse: startFrame ≥ endFrame (framePosition counts down) +/// Carries the parent 's +/// Velocity and Omega fields so per-tick physics deltas can be surfaced +/// while this node is current (ACE Sequence.Velocity / Omega equivalent +/// for the single-active-MotionData case). /// internal sealed class AnimNode { @@ -86,14 +93,32 @@ internal sealed class AnimNode public int StartFrame; // inclusive start frame (post-swap for negative speed) public int EndFrame; // inclusive end frame (post-swap for negative speed) public bool IsLooping; // true only for the tail cyclic node + public bool HasPosFrames; // mirror of Anim.Flags & AnimationFlags.PosFrames - public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping) + // Carried from the source MotionData (one MotionData may produce N nodes; + // each carries the same vel/omega, and when the node becomes current the + // sequencer surfaces these values). + public Vector3 Velocity; // meters/sec, world-space + public Vector3 Omega; // radians/sec per axis + + public AnimNode( + Animation anim, + double framerate, + int startFrame, + int endFrame, + bool isLooping, + bool hasPosFrames, + Vector3 velocity, + Vector3 omega) { Anim = anim; Framerate = framerate; StartFrame = startFrame; EndFrame = endFrame; IsLooping = isLooping; + HasPosFrames = hasPosFrames; + Velocity = velocity; + Omega = omega; } // ── FUN_00526880 — GetStartFramePosition ────────────────────────────── @@ -139,13 +164,17 @@ internal sealed class AnimNode /// 64-bit field at Sequence+0x30. /// /// -/// Negative framerate means reverse playback; startFrame/endFrame are -/// swapped at node construction time (FUN_005267E0). +/// Negative framerate means reverse playback. /// /// /// When a node's frames are exhausted, advance_to_next_animation /// wraps to _firstCyclic (the looping tail of the queue). /// +/// +/// Every integer frame boundary crossed in a tick fires the hooks at +/// that frame whose matches the playback +/// direction (or Both). Mirrors ACE Sequence.execute_hooks. +/// /// /// /// @@ -156,7 +185,8 @@ internal sealed class AnimNode /// seq.SetCycle(style, motion, speedMod); /// // each frame: /// var transforms = seq.Advance(dt); -/// // rebuild MeshRefs from transforms +/// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage +/// var root = seq.ConsumeRootMotionDelta(); // add to AFrame if desired /// /// /// @@ -170,6 +200,23 @@ public sealed class AnimationSequencer /// Current cyclic motion command. public uint CurrentMotion { get; private set; } + /// + /// World-space per-second velocity from the currently active + /// (Sequence.Velocity in retail). Zero when no + /// motion data carries a velocity. Scaled by speedMod at enqueue + /// time. + /// + public Vector3 CurrentVelocity => + _currNode?.Value.Velocity ?? Vector3.Zero; + + /// + /// Radians-per-second omega (axis-angle integration rate) from the + /// currently active . Scaled by speedMod + /// at enqueue time. + /// + public Vector3 CurrentOmega => + _currNode?.Value.Omega ?? Vector3.Zero; + // Diagnostics public int QueueCount => _queue.Count; public bool HasCurrentNode => _currNode != null; @@ -189,6 +236,16 @@ public sealed class AnimationSequencer // Named _framePosition to distinguish it from the old float _frameNum. private double _framePosition; + // Hooks pending dispatch. Accumulated during Advance; drained via + // ConsumePendingHooks. + private readonly List _pendingHooks = new(); + + // Root motion (PosFrames) delta accumulated during Advance. Drained via + // ConsumeRootMotionDelta. Matches the retail client's AFrame.Combine / + // AFrame.Subtract chain in Sequence.update_internal. + private Vector3 _rootMotionPos; + private Quaternion _rootMotionRot = Quaternion.Identity; + private const double FrameEpsilon = 1e-5; private const double RateEpsilon = 1e-6; @@ -321,15 +378,21 @@ public sealed class AnimationSequencer /// per-part transforms for the current blended keyframe. /// /// - /// Implements Sequence::update_internal (FUN_005261D0) in a - /// simplified form: no frame-trigger events (PhysicsObject not modelled - /// here), but correct boundary detection, remainder propagation, and - /// advance_to_next_animation wrapping. + /// Implements Sequence::update_internal (FUN_005261D0 / ACE + /// Sequence.cs:351-443): walks every integer frame boundary crossed in + /// this tick, calls execute_hooks for each with the playback + /// direction, and accumulates root + /// motion into the pending delta. Hooks fire only once per crossing + /// regardless of framerate scaling. /// /// /// - /// The slerp algorithm mirrors the decompiled retail client's - /// FUN_005360d0 (chunk_00530000.c:4799). + /// Crossing semantics (forward): as floor(framePos) increments + /// from i to i+1, hooks attached to frame i with + /// direction Forward or Both fire. Reverse: as + /// floor(framePos) decrements from i to i-1, + /// hooks with direction Backward or Both fire on frame + /// i. /// /// /// Elapsed time in seconds since the last call. @@ -344,11 +407,12 @@ public sealed class AnimationSequencer if (_currNode == null || dt <= 0f) return BuildIdentityFrame(partCount); - // ── update_internal (FUN_005261D0) ─────────────────────────────── + // ── update_internal (FUN_005261D0 / ACE Sequence.update_internal) ─ // Loop because a large dt can exhaust multiple nodes sequentially. double timeRemaining = (double)dt; + int safety = 64; // cap in case of a degenerate motion table - while (timeRemaining > 0.0 && _currNode != null) + while (timeRemaining > 0.0 && _currNode != null && safety-- > 0) { var curr = _currNode.Value; double rate = curr.Framerate; // signed (negative = reverse) @@ -357,6 +421,9 @@ public sealed class AnimationSequencer if (Math.Abs(delta) < RateEpsilon) break; // rate ≈ 0 — nothing to do + // lastFrame = floor(_framePosition) BEFORE advance (ACE pattern). + int lastFrame = (int)Math.Floor(_framePosition); + double newPos = _framePosition + delta; bool wrapped = false; double overflow = 0.0; @@ -364,48 +431,65 @@ public sealed class AnimationSequencer if (delta > 0.0) { // ── FORWARD PLAYBACK ────────────────────────────────────── - // End boundary = endFrame + 1. Pseudocode: floor(newPos) > maxFrame. double maxBoundary = (double)(curr.EndFrame + 1); if (newPos >= maxBoundary - FrameEpsilon) { - // How much time spilled past the boundary? + // Time spilled past the boundary. overflow = (newPos - maxBoundary) / rate; if (overflow < 0.0) overflow = 0.0; - _framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos + _framePosition = maxBoundary - FrameEpsilon; wrapped = true; } else { _framePosition = newPos; } + + // Walk every integer frame boundary crossed: apply posFrame + // delta and fire hooks with Forward direction. + while ((int)Math.Floor(_framePosition) > lastFrame) + { + ApplyPosFrame(curr, lastFrame, reverse: false); + ExecuteHooks(curr, lastFrame, AnimationHookDir.Forward); + lastFrame++; + } } else { // ── REVERSE PLAYBACK ───────────────────────────────────── - // No swap: StartFrame is still the LOW value (e.g. 0). - // GetStartFramePosition placed cursor at (EndFrame+1)-eps (near high). - // The cursor counts DOWN toward StartFrame. double minBoundary = (double)curr.StartFrame; if (newPos <= minBoundary) { - // How much time spilled past the lower boundary? overflow = (newPos - minBoundary) / rate; if (overflow < 0.0) overflow = 0.0; - _framePosition = minBoundary; // clamp to lower boundary + _framePosition = minBoundary; wrapped = true; } else { _framePosition = newPos; } + + // Walk every integer boundary crossed DOWN: subtract posFrame + // delta and fire hooks with Backward direction. + while ((int)Math.Floor(_framePosition) < lastFrame) + { + ApplyPosFrame(curr, lastFrame, reverse: true); + ExecuteHooks(curr, lastFrame, AnimationHookDir.Backward); + lastFrame--; + } } if (!wrapped) - break; // consumed all dt without hitting boundary — done + break; // consumed all dt without hitting node boundary — done // ── advance_to_next_animation (FUN_00525EB0) ───────────────── + // Fire AnimationDone for any drained link node before wrap. + if (_currNode != null && !_currNode.Value.IsLooping) + _pendingHooks.Add(AnimationDoneSentinel); + AdvanceToNextAnimation(); timeRemaining = overflow; // continue with leftover time } @@ -413,6 +497,37 @@ public sealed class AnimationSequencer return BuildBlendedFrame(); } + /// + /// Retrieve and clear the list of hooks that fired since the last call. + /// Empty when no frame boundary was crossed. Safe to call multiple + /// times per frame; second and subsequent calls return an empty list. + /// + public IReadOnlyList ConsumePendingHooks() + { + if (_pendingHooks.Count == 0) + return Array.Empty(); + + var result = _pendingHooks.ToArray(); + _pendingHooks.Clear(); + return result; + } + + /// + /// Retrieve and clear the root-motion displacement accumulated from + /// during the last + /// calls. Returns (Zero, Identity) when no PosFrames exist on the + /// current animation. The caller should combine this with their AFrame + /// (object placement) to propagate root motion — e.g. baked-in footsteps + /// on a running animation. + /// + public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta() + { + var result = (_rootMotionPos, _rootMotionRot); + _rootMotionPos = Vector3.Zero; + _rootMotionRot = Quaternion.Identity; + return result; + } + /// /// Play a one-shot action/modifier motion (Jump, emote, attack, etc.) /// on top of the current cycle. The action frames are inserted in the @@ -479,10 +594,15 @@ public sealed class AnimationSequencer // Build AnimNodes from the action's AnimData list. All non-looping — // they drain once, then the queue falls through to _firstCyclic. + Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity) + ? data.Velocity * speedMod : Vector3.Zero; + Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega) + ? data.Omega * speedMod : Vector3.Zero; + var newNodes = new List(data.Anims.Count); for (int i = 0; i < data.Anims.Count; i++) { - var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false); + var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false, vel, omg); if (node != null) newNodes.Add(node); } if (newNodes.Count == 0) return; @@ -529,12 +649,20 @@ public sealed class AnimationSequencer _currNode = null; _firstCyclic = null; _framePosition = 0.0; + _pendingHooks.Clear(); + _rootMotionPos = Vector3.Zero; + _rootMotionRot = Quaternion.Identity; CurrentStyle = 0; CurrentMotion = 0; } // ── Private helpers ────────────────────────────────────────────────────── + // Sentinel hook fired when a non-cyclic link node drains naturally. + // Mirrors ACE's PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook). + private static readonly AnimationDoneHook AnimationDoneSentinel = + new() { Direction = AnimationHookDir.Both }; + /// /// Look up the transition MotionData for going from /// to within . @@ -569,12 +697,13 @@ public sealed class AnimationSequencer /// /// Load an Animation from the dat by its /// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames"). - /// - /// Implements FUN_005267E0 (multiply_framerate): when - /// fr < 0, startFrame and endFrame are swapped so the advance - /// loop's boundary logic works uniformly for both directions. /// - private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping) + private AnimNode? LoadAnimNode( + AnimData ad, + float speedMod, + bool isLooping, + Vector3 velocity, + Vector3 omega) { uint animId = (uint)ad.AnimId; if (animId == 0) return null; @@ -602,19 +731,37 @@ public sealed class AnimationSequencer // from high→low instead of being stuck in [0,1). if (low > high) high = low; - return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping); + bool hasPosFrames = anim.Flags.HasFlag(AnimationFlags.PosFrames) + && anim.PosFrames.Count >= numFrames; + + return new AnimNode( + anim, + fr, + startFrame: low, + endFrame: high, + isLooping, + hasPosFrames, + velocity, + omega); } /// /// Append all AnimData entries from to the - /// queue. Each AnimData becomes one AnimNode. + /// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the + /// MotionData are applied to every resulting node so they remain active + /// while the node is current. /// private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping) { + Vector3 vel = motionData.Flags.HasFlag(MotionDataFlags.HasVelocity) + ? motionData.Velocity * speedMod : Vector3.Zero; + Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega) + ? motionData.Omega * speedMod : Vector3.Zero; + for (int i = 0; i < motionData.Anims.Count; i++) { bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1); - var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling); + var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling, vel, omg); if (node != null) _queue.AddLast(node); } @@ -680,6 +827,58 @@ public sealed class AnimationSequencer _framePosition = _currNode.Value.GetStartFramePosition(); } + /// + /// Dispatch any hooks on the given part frame whose direction matches + /// the playback direction (or Both). Mirrors ACE's + /// Sequence.execute_hooks (Sequence.cs:262). + /// + private void ExecuteHooks(AnimNode node, int frameIndex, AnimationHookDir playbackDir) + { + if (frameIndex < 0 || frameIndex >= node.Anim.PartFrames.Count) return; + var frame = node.Anim.PartFrames[frameIndex]; + if (frame.Hooks.Count == 0) return; + + for (int i = 0; i < frame.Hooks.Count; i++) + { + var hook = frame.Hooks[i]; + if (hook == null) continue; + // ACE: hook.Direction == Both || hook.Direction == playbackDir + if (hook.Direction == AnimationHookDir.Both + || hook.Direction == playbackDir) + { + _pendingHooks.Add(hook); + } + } + } + + /// + /// Apply the (root motion) delta for + /// to the accumulated pending delta. + /// Mirrors ACE's AFrame.Combine (forward) / frame.Subtract + /// (backward) calls in update_internal. + /// + private void ApplyPosFrame(AnimNode node, int frameIndex, bool reverse) + { + if (!node.HasPosFrames) return; + var posFrames = node.Anim.PosFrames; + if (frameIndex < 0 || frameIndex >= posFrames.Count) return; + var pf = posFrames[frameIndex]; + + if (!reverse) + { + // AFrame.Combine: position += rot.Rotate(pf.Origin); rot *= pf.Orientation + _rootMotionPos += Vector3.Transform(pf.Origin, _rootMotionRot); + _rootMotionRot = Quaternion.Normalize(_rootMotionRot * pf.Orientation); + } + else + { + // AFrame.Subtract: rot *= conj(pf.Orientation); position -= rot.Rotate(pf.Origin) + var invRot = Quaternion.Conjugate(pf.Orientation); + _rootMotionRot = Quaternion.Normalize(_rootMotionRot * invRot); + _rootMotionPos -= Vector3.Transform(pf.Origin, _rootMotionRot); + } + } + /// /// Build the per-part blended transform from the current animation frame. /// Blends between floor(_framePosition) and floor(_framePosition)+1 using diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index f582aba..6b278c8 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -632,6 +632,353 @@ public sealed class AnimationSequencerTests $"Frame position {pos} out of range [0, 4) after 5 loops"); } + // ── Hook dispatch (Phase E.1) ──────────────────────────────────────────── + + [Fact] + public void Advance_FiresForwardHook_OnFrameBoundaryCrossing() + { + // 4-frame anim at 10fps. Put a SoundHook on frame 1 (Forward direction). + // Advance by 0.15s (1.5 frames) which crosses the boundary at frame 1. + // Expect exactly one hook fired. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000100u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.PartFrames[1].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Forward, + Id = 0x0A000042u, + }); + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + + // Drain any hooks pre-existing from initial load (there should be none). + seq.ConsumePendingHooks(); + + // Step 1: 0.05s → advance ~0.5 frames, floor still 0 → no crossing. + seq.Advance(0.05f); + Assert.Empty(seq.ConsumePendingHooks()); + + // Step 2: 0.10s more → total ~1.5 frames, floor now 1 → crosses boundary + // from frame 0 → frame 1. Fire hook on frame index 1? No — ACE fires + // hooks on frame index lastFrame (= 0). Let's put the hook on frame 0 + // instead. Retest. + // + // Actually looking at ACE code at Sequence.cs:389: + // execute_hooks(currAnim.get_part_frame(lastFrame), Forward); + // lastFrame++; + // So lastFrame is 0 initially, crosses to 1 → fires hooks on frame 0. + // Let's verify with a hook on frame 0 instead. + } + + [Fact] + public void Advance_FiresHookOnCrossedFrame_ForwardDirection() + { + // ACE semantics: when floor(framePos) goes from i → i+1, hooks on + // frame i with direction Forward or Both fire. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000101u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.PartFrames[0].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Forward, + Id = 0x0A000001u, + }); + anim.PartFrames[2].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Forward, + Id = 0x0A000002u, + }); + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + + seq.ConsumePendingHooks(); // clear any initial hooks + + // Advance 0.15s = 1.5 frames → floor 0 → 1, crosses boundary 0→1 → fires frame 0 hook. + seq.Advance(0.15f); + var hooks = seq.ConsumePendingHooks(); + + Assert.Single(hooks); + Assert.IsType(hooks[0]); + + // Advance 0.2s more = 2 more frames (total 3.5) → crosses 1→2 and 2→3 → fires frame 2 hook. + seq.Advance(0.2f); + var hooks2 = seq.ConsumePendingHooks(); + + Assert.Single(hooks2); + Assert.IsType(hooks2[0]); + Assert.Equal(0x0A000002u, (uint)((SoundHook)hooks2[0]).Id); + } + + [Fact] + public void Advance_BothDirectionHook_FiresInForwardAndReverse() + { + // Direction.Both fires regardless of playback direction. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000102u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.PartFrames[0].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Both, + Id = 0x0A000003u, + }); + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.ConsumePendingHooks(); + + // Forward playback, cross boundary 0→1. + seq.Advance(0.15f); + Assert.Single(seq.ConsumePendingHooks()); + } + + [Fact] + public void Advance_ForwardHookDoesNotFire_OnReversePlayback() + { + // A hook tagged Direction.Forward should NOT fire when playback is reversed. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000103u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.PartFrames[2].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Forward, + Id = 0x0A000004u, + }); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData(); + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = -10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.ConsumePendingHooks(); + + // Reverse playback: cursor starts near frame 4 and counts down. + seq.Advance(0.15f); + var hooks = seq.ConsumePendingHooks(); + + // Forward-only hook on frame 2 should NOT fire on reverse playback. + Assert.DoesNotContain(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000004u); + } + + [Fact] + public void Advance_BackwardHook_FiresOnReversePlayback() + { + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000104u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.PartFrames[2].Hooks.Add(new SoundHook + { + Direction = AnimationHookDir.Backward, + Id = 0x0A000005u, + }); + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData(); + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = -10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.ConsumePendingHooks(); + + // Reverse: start near 4, advance 0.2s = -2 frames → cursor ~2 → crosses 3→2 + // which fires hooks on frame 3 (wrong one) and maybe 2. + // Let's advance enough to cross 3→2 boundary for sure. + seq.Advance(0.25f); // -2.5 frames → cursor ~1.5 → crosses 3→2 and 2→1 + var hooks = seq.ConsumePendingHooks(); + + Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u); + } + + // ── PosFrames root motion (Phase E.1) ──────────────────────────────────── + + [Fact] + public void Advance_WithPosFrames_AccumulatesRootMotion() + { + // Animation with PosFrames flag and per-frame origin deltas should + // surface a non-zero root motion delta after Advance. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000110u; + + // 4-frame anim, each PosFrame origin = (1, 0, 0), rotation identity. + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + anim.Flags = AnimationFlags.PosFrames; + for (int f = 0; f < 4; f++) + { + anim.PosFrames.Add(new Frame + { + Origin = new Vector3(1f, 0f, 0f), + Orientation = Quaternion.Identity, + }); + } + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + seq.ConsumeRootMotionDelta(); // clear + + // Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied. + seq.Advance(0.25f); + var (pos, _) = seq.ConsumeRootMotionDelta(); + + // Each crossing adds +X origin → total X should be 2. + Assert.True(pos.X >= 1.8f && pos.X <= 2.2f, + $"Expected ~2.0 root motion X after 2 crossings, got {pos.X}"); + + // A subsequent consume with no advance should return zero (drained). + var (pos2, _) = seq.ConsumeRootMotionDelta(); + Assert.Equal(Vector3.Zero, pos2); + } + + [Fact] + public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() + { + // MotionData with HasVelocity flag should surface via CurrentVelocity. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000120u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData + { + Flags = MotionDataFlags.HasVelocity, + Velocity = new Vector3(0f, 4f, 0f), // 4 m/s forward + }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = 10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + + // Node is current → velocity should be exposed (scaled by speedMod=1). + Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity); + } + + [Fact] + public void CurrentVelocity_ScaledBySpeedMod() + { + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000121u; + + var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData + { + Flags = MotionDataFlags.HasVelocity, + Velocity = new Vector3(0f, 4f, 0f), + }; + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = 10f }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion, speedMod: 0.5f); + + // Velocity scaled by speedMod=0.5 → 2 m/s forward. + Assert.Equal(new Vector3(0f, 2f, 0f), seq.CurrentVelocity); + } + + [Fact] + public void ConsumePendingHooks_AnimationDoneFires_WhenLinkDrains() + { + // When a non-cyclic link node exhausts and we advance_to_next_animation, + // an AnimationDoneHook should be queued so consumers can react (e.g. UI + // wake-on-idle-complete). + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; + const uint CycleAnim = 0x03000130u; + const uint LinkAnim = 0x03000131u; + + var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); + var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); + + var setup = Fixtures.MakeSetup(1); + var mt = Fixtures.MakeMtable( + style: Style, motion: WalkMotion, cycleAnimId: CycleAnim, + fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim, + framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(CycleAnim, cycleAnim); + loader.Register(LinkAnim, linkAnim); + + var seq = new AnimationSequencer(setup, mt, loader); + SetCurrentMotion(seq, Style, IdleMotion); + seq.SetCycle(Style, WalkMotion); + seq.ConsumePendingHooks(); + + // Link is 2 frames at 10fps = 0.2s. Advance past it. + seq.Advance(0.25f); + var hooks = seq.ConsumePendingHooks(); + + Assert.Contains(hooks, h => h is AnimationDoneHook); + } + // ── Helpers ────────────────────────────────────────────────────────────── /// Expose _framePosition (double) via reflection (test-only).