From 78aef6d57598a3e39dfd7ed64c3acd8f6e8f5914 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 12:59:32 +0200 Subject: [PATCH] refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct translation guide. Every key algorithmic difference from the previous patched implementation is addressed: 1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail client binary. Previously float, which accumulated rounding error over long sessions. 2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time: negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout. 3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both forward and reverse; boundary detection uses EndFrame as the lower bound for reverse playback (matching the post-swap field semantics); remainder time propagates correctly across node boundaries for large dt values. 4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0) formulas are now correct: negative speed starts at (EndFrame+1)-epsilon, ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon. 5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the linked list is exhausted, matching the retail loop-forever semantics. 6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed), WalkBackward→WalkForward (negate×0.65 BackwardsFactor). 7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the existing implementation is correct. AnimationSequencerTests grows from 9 to 17 tests: - Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1 - Reverse frame position decreases (not increases) over time - Reverse wrap at start boundary recovers and loops - advance_to_next_animation: link node drains then enters cycle - Cycle loops repeatedly without crash or position drift All 431 tests green (109 net + 322 core). Co-Authored-By: Claude Sonnet 4.6 --- .../Physics/AnimationSequencer.cs | 356 ++++++++++++------ .../Physics/AnimationSequencerTests.cs | 267 +++++++++++-- 2 files changed, 478 insertions(+), 145 deletions(-) diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 12f1abf..e65acad 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -30,16 +30,17 @@ public sealed class DatCollectionLoader : IAnimationLoader } // ───────────────────────────────────────────────────────────────────────────── -// AnimationSequencer — per-entity animation playback with transition links. +// AnimationSequencer — faithful port of the decompiled retail AC client +// animation system. // -// Decompiled references: -// FUN_005360d0 (chunk_00530000.c:4799) — quaternion slerp with dot-product -// sign-flip and lerp fallback for near-parallel quaternions. -// Sequence.update_internal (ACE Sequence.cs) — frame advance: frameNum += -// framerate*dt, test against high/low, fire hooks at each crossed -// integer frame boundary, advance to next anim when done. -// MotionTable.get_link (ACE MotionTable.cs:395) — transition lookup: -// Links[(style<<16)|(fromSubstate&0xFFFFFF)].TryGetValue(toMotion). +// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md): +// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed +// FUN_005261D0 — update_internal: the core per-frame advance loop +// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic +// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent) +// 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 // // DatReaderWriter types used: // MotionTable.Links : Dictionary @@ -71,32 +72,84 @@ 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) /// internal sealed class AnimNode { public Animation Anim; - public float Framerate; // signed; negative means reverse - public int LowFrame; - public int HighFrame; - public bool IsLooping; // true only for the tail cyclic node + public double Framerate; // signed; negative means reverse playback + 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 AnimNode(Animation anim, float framerate, int lowFrame, int highFrame, bool isLooping) + public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping) { Anim = anim; Framerate = framerate; - LowFrame = lowFrame; - HighFrame = highFrame; + StartFrame = startFrame; + EndFrame = endFrame; IsLooping = isLooping; } - public float StartingFrame => Framerate >= 0f ? LowFrame : HighFrame + 1 - 1e-5f; - public float EndingFrame => Framerate >= 0f ? HighFrame + 1 - 1e-5f : LowFrame; + // ── FUN_00526880 — GetStartFramePosition ────────────────────────────── + // Returns the initial framePosition cursor for this node. + // speedScale >= 0 → (double)startFrame + // speedScale < 0 → (double)(endFrame + 1) - EPSILON + // EPSILON = _DAT_007c92b4 (a tiny float just below the boundary) + public double GetStartFramePosition() + { + if (Framerate >= 0.0) + return (double)StartFrame; + else + return (double)(EndFrame + 1) - FrameEpsilon; + } + + // ── FUN_005268B0 — GetEndFramePosition ─────────────────────────────── + // Returns where the cursor sits when this node is exhausted. + // speedScale >= 0 → (double)(endFrame + 1) - EPSILON + // speedScale < 0 → (double)startFrame + public double GetEndFramePosition() + { + if (Framerate >= 0.0) + return (double)(EndFrame + 1) - FrameEpsilon; + else + return (double)StartFrame; + } + + // Small double constant matching _DAT_007c92b4 in the retail binary. + // Used to position the cursor just before a frame boundary. + private const double FrameEpsilon = 1e-5; } /// /// Full animation playback engine for one entity. /// /// +/// This is a faithful port of the retail AC client's Sequence object +/// (docs/research/acclient_animation_pseudocode.md, sections 5–7). +/// Key invariants: +/// +/// +/// _framePosition is a double matching the retail client's +/// 64-bit field at Sequence+0x30. +/// +/// +/// Negative framerate means reverse playback; startFrame/endFrame are +/// swapped at node construction time (FUN_005267E0). +/// +/// +/// When a node's frames are exhausted, advance_to_next_animation +/// wraps to _firstCyclic (the looping tail of the queue). +/// +/// +/// +/// +/// /// Usage pattern: /// /// var seq = new AnimationSequencer(setup, motionTable, dats); @@ -106,13 +159,6 @@ internal sealed class AnimNode /// // rebuild MeshRefs from transforms /// /// -/// -/// -/// When is called with a new motion, the sequencer -/// looks up a transition link in the MotionTable and prepends those frames -/// to the queue so the entity blends smoothly instead of snapping. The -/// cyclic tail of the queue loops forever. -/// /// public sealed class AnimationSequencer { @@ -134,9 +180,13 @@ public sealed class AnimationSequencer private readonly LinkedList _queue = new(); private LinkedListNode? _currNode; private LinkedListNode? _firstCyclic; - private float _frameNum; - private const float Epsilon = 1e-5f; + // 64-bit fractional frame position — matches Sequence+0x30 in the retail client. + // Named _framePosition to distinguish it from the old float _frameNum. + private double _framePosition; + + private const double FrameEpsilon = 1e-5; + private const double RateEpsilon = 1e-6; // ── Constructor ────────────────────────────────────────────────────────── @@ -166,33 +216,39 @@ public sealed class AnimationSequencer /// Switch to a new cyclic motion, prepending any transition link frames /// so the switch is smooth. If the motion table has no link for the /// (currentStyle, currentMotion) → newMotion transition, the cycle - /// switches immediately (same as the old snap behaviour). + /// switches immediately. + /// + /// + /// Implements adjust_motion (ACE MotionInterp.cs:394-428): the AC + /// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward. + /// These are played as their right-side / forward equivalents with a + /// negated framerate so the animation runs in reverse. + /// /// /// MotionCommand style / stance (e.g. NonCombat 0x003D0000). /// Target motion command (e.g. WalkForward 0x45000005). /// Speed multiplier applied to framerates (1.0 = normal). public void SetCycle(uint style, uint motion, float speedMod = 1f) { - // ── adjust_motion: remap left→right variants with negative speed ─── - // The AC client's MotionTable has NO cycles for TurnLeft, SideStepLeft, - // or WalkBackward. These are played as their right-side / forward - // equivalents with negative framerate (animation runs backward). - // ACE: MotionInterp.cs:394-428 + // ── adjust_motion: remap left→right / backward→forward variants ─── + // ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft, + // SideStepLeft, or WalkBackward cycles; the client plays the mirror + // animation with a negated speed so it runs backward. uint adjustedMotion = motion; float adjustedSpeed = speedMod; switch (motion & 0xFFFFu) { - case 0x000E: // TurnLeft → TurnRight + case 0x000E: // TurnLeft → TurnRight (negate speed) adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; - adjustedSpeed *= -1f; + adjustedSpeed = -speedMod; break; - case 0x0010: // SideStepLeft → SideStepRight + case 0x0010: // SideStepLeft → SideStepRight (negate speed) adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; - adjustedSpeed *= -1f; + adjustedSpeed = -speedMod; break; - case 0x0006: // WalkBackward → WalkForward + case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor) adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; - adjustedSpeed *= -0.65f; // BackwardsFactor from ACE + adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE break; } @@ -206,7 +262,7 @@ public sealed class AnimationSequencer ? GetLink(style, CurrentMotion, adjustedMotion) : null; - // Resolve target cycle using the ADJUSTED motion (TurnRight, not TurnLeft). + // Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft). int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); _mtable.Cycles.TryGetValue(cycleKey, out var cycleData); @@ -228,15 +284,13 @@ public sealed class AnimationSequencer // No cycle and no link — nothing to play; reset fully. _currNode = null; _firstCyclic = null; - _frameNum = 0f; + _framePosition = 0.0; CurrentStyle = style; CurrentMotion = motion; return; } - // Mark the first cyclic node (the tail after all link frames). - // If there were no link frames, the first enqueued node is cyclic. - // Re-scan from the end to find the first IsLooping node. + // Mark the first cyclic node (the looping tail after all link frames). _firstCyclic = null; for (var n = _queue.First; n != null; n = n.Next) { @@ -251,7 +305,7 @@ public sealed class AnimationSequencer if (_currNode == null) { _currNode = _queue.First; - _frameNum = _currNode?.Value.StartingFrame ?? 0f; + _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; } CurrentStyle = style; @@ -263,10 +317,15 @@ 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. + /// + /// + /// /// The slerp algorithm mirrors the decompiled retail client's - /// FUN_005360d0 (chunk_00530000.c:4799): - /// compute dot product; if negative, negate q2 and dot; if the angle is - /// very small, fall back to linear (1-t, t) instead of sin-based slerp. + /// FUN_005360d0 (chunk_00530000.c:4799). /// /// /// Elapsed time in seconds since the last call. @@ -281,42 +340,73 @@ public sealed class AnimationSequencer if (_currNode == null || dt <= 0f) return BuildIdentityFrame(partCount); - var curr = _currNode.Value; - float framerate = curr.Framerate; - float frametime = framerate * dt; + // ── update_internal (FUN_005261D0) ─────────────────────────────── + // Loop because a large dt can exhaust multiple nodes sequentially. + double timeRemaining = (double)dt; - bool animDone = false; - float timeRemainder = 0f; - - _frameNum += frametime; - - if (frametime > 0f) + while (timeRemaining > 0.0 && _currNode != null) { - if (_frameNum > curr.HighFrame + 1 - Epsilon) - { - timeRemainder = Math.Abs(framerate) > Epsilon - ? (_frameNum - (curr.HighFrame + 1 - Epsilon)) / framerate - : 0f; - _frameNum = curr.HighFrame; - animDone = true; - } - } - else if (frametime < 0f) - { - if (_frameNum < curr.LowFrame) - { - timeRemainder = Math.Abs(framerate) > Epsilon - ? (_frameNum - curr.LowFrame) / framerate - : 0f; - _frameNum = curr.LowFrame; - animDone = true; - } - } + var curr = _currNode.Value; + double rate = curr.Framerate; // signed (negative = reverse) + double delta = rate * timeRemaining; - if (animDone) + if (Math.Abs(delta) < RateEpsilon) + break; // rate ≈ 0 — nothing to do + + double newPos = _framePosition + delta; + bool wrapped = false; + double overflow = 0.0; + + 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? + overflow = (newPos - maxBoundary) / rate; + if (overflow < 0.0) overflow = 0.0; + + _framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos + wrapped = true; + } + else + { + _framePosition = newPos; + } + } + else + { + // ── REVERSE PLAYBACK ───────────────────────────────────── + // After FUN_005267E0 swaps low↔high for negative speed: + // StartFrame = high (e.g. 3), EndFrame = low (e.g. 0) + // GetStartFramePosition placed cursor at (EndFrame+1)-eps ≈ 0.99999. + // The cursor counts DOWN toward EndFrame. Boundary = EndFrame. + double minBoundary = (double)curr.EndFrame; + 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 + wrapped = true; + } + else + { + _framePosition = newPos; + } + } + + if (!wrapped) + break; // consumed all dt without hitting boundary — done + + // ── advance_to_next_animation (FUN_00525EB0) ───────────────── AdvanceToNextAnimation(); + timeRemaining = overflow; // continue with leftover time + } - // Build the blended frame. return BuildBlendedFrame(); } @@ -329,7 +419,7 @@ public sealed class AnimationSequencer _queue.Clear(); _currNode = null; _firstCyclic = null; - _frameNum = 0f; + _framePosition = 0.0; CurrentStyle = 0; CurrentMotion = 0; } @@ -340,7 +430,7 @@ public sealed class AnimationSequencer /// Look up the transition MotionData for going from /// to within . /// - /// Port of ACE's MotionTable.get_link (positive-speed path): + /// Port of ACE's MotionTable.get_link: /// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion] /// 2. Fallback: try Links[style<<16][toMotion] /// @@ -370,7 +460,10 @@ public sealed class AnimationSequencer /// /// Load an Animation from the dat by its /// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames"). - /// Mirrors ACE AnimSequenceNode.set_animation_id. + /// + /// 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) { @@ -381,32 +474,34 @@ public sealed class AnimationSequencer if (anim is null || anim.PartFrames.Count == 0) return null; int numFrames = anim.PartFrames.Count; - int low = ad.LowFrame; + int low = ad.LowFrame; int high = ad.HighFrame; // Sentinel resolution (same as MotionResolver.GetIdleCycle). - if (high < 0) high = numFrames - 1; - if (low >= numFrames) low = numFrames - 1; + if (high < 0) high = numFrames - 1; + if (low >= numFrames) low = numFrames - 1; if (high >= numFrames) high = numFrames - 1; - if (low < 0) low = 0; + if (low < 0) low = 0; - float fr = ad.Framerate * speedMod; + double fr = (double)ad.Framerate * (double)speedMod; - // multiply_framerate: when speed is negative (TurnLeft, SideStepLeft), - // swap Low↔High so the animation plays backward. This is exactly what - // the decompiled FUN_005267E0 does. ACE: AnimData.GetFramerate(speed). - // After swap, LowFrame > HighFrame — the Advance loop handles this - // by checking negative frametime against LowFrame (the higher value). - if (fr < 0f) + // ── FUN_005267E0 multiply_framerate ────────────────────────────── + // When speed is negative (TurnLeft→TurnRight, SideStepLeft→SideStepRight), + // swap Low↔High so the advance loop counts DOWN from the swapped EndFrame + // toward the swapped StartFrame. The pseudocode says: + // if speedScale < 0: swap startFrame ↔ endFrame + if (fr < 0.0) { (low, high) = (high, low); + // After swap: StartFrame > EndFrame (the loop detects delta < 0 and + // uses StartFrame as the lower boundary to count down toward). } else { - if (low > high) high = low; // only clamp for positive-speed case + if (low > high) high = low; // clamp for positive-speed case only } - return new AnimNode(anim, fr, low, high, isLooping); + return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping); } /// @@ -425,8 +520,9 @@ public sealed class AnimationSequencer } /// - /// Remove all cyclic (looping) nodes from the tail of the queue, starting - /// from . Non-cyclic link frames remain. + /// Remove all cyclic (looping) nodes from the tail of the queue starting + /// from . Non-cyclic link frames remain so they + /// can drain naturally. /// private void ClearCyclicTail() { @@ -436,14 +532,15 @@ public sealed class AnimationSequencer while (node != null) { var next = node.Next; - // If CurrAnim is being removed, jump it to the previous non-cyclic node. + // If the active node is being removed, jump it to the preceding + // non-cyclic node (or reset if there is none). if (_currNode == node) { _currNode = node.Previous; if (_currNode != null) - _frameNum = _currNode.Value.EndingFrame; + _framePosition = _currNode.Value.GetEndFramePosition(); else - _frameNum = 0f; + _framePosition = 0.0; } _queue.Remove(node); node = next; @@ -453,28 +550,42 @@ public sealed class AnimationSequencer } /// - /// Move to the next node in the queue, or loop - /// back to if at the end. Mirrors ACE's - /// advance_to_next_animation. + /// Move to the next node in the queue, or wrap + /// back to when the queue is exhausted. + /// + /// Implements FUN_00525EB0 (Sequence::advance_to_next_animation). + /// The retail client walks a doubly-linked list; we mirror that with + /// LinkedList.Next plus the _firstCyclic wrap sentinel. /// private void AdvanceToNextAnimation() { if (_currNode == null) return; - if (_currNode.Next != null) - _currNode = _currNode.Next; + LinkedListNode? next = _currNode.Next; + + if (next != null) + { + _currNode = next; + } else if (_firstCyclic != null) + { + // Wrap to first cyclic node — this is the loop that keeps idle/walk + // animations playing forever. _currNode = _firstCyclic; - // else: end of non-looping sequence — stay on last frame. + } + // else: end of a finite non-looping sequence; stay on last node. if (_currNode != null) - _frameNum = _currNode.Value.StartingFrame; + _framePosition = _currNode.Value.GetStartFramePosition(); } /// /// Build the per-part blended transform from the current animation frame. - /// Blends between floor(frameNum) and floor(frameNum)+1 using the - /// fractional part of frameNum. + /// Blends between floor(_framePosition) and floor(_framePosition)+1 using + /// the fractional part of _framePosition. + /// + /// Uses the retail-client slerp () for + /// quaternion interpolation and linear lerp for position. /// private IReadOnlyList BuildBlendedFrame() { @@ -486,31 +597,32 @@ public sealed class AnimationSequencer var curr = _currNode.Value; int numPartFrames = curr.Anim.PartFrames.Count; - int frameIdx = (int)Math.Floor(_frameNum); - // For backward playback, LowFrame > HighFrame. Use actual min/max - // of the two to get a valid range for clamping. - int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame); - int rangeHi = Math.Min(Math.Max(curr.LowFrame, curr.HighFrame), numPartFrames - 1); + // Clamp frameIndex to valid range. + int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame); + int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame); + rangeHi = Math.Min(rangeHi, numPartFrames - 1); + + int frameIdx = (int)Math.Floor(_framePosition); frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi); // Next frame for interpolation: step in the playback direction. int nextIdx; - if (curr.Framerate >= 0f) + if (curr.Framerate >= 0.0) { nextIdx = frameIdx + 1; if (nextIdx > rangeHi || nextIdx >= numPartFrames) - nextIdx = rangeLo; // wrap forward + nextIdx = rangeLo; // wrap forward } else { nextIdx = frameIdx - 1; if (nextIdx < rangeLo) - nextIdx = rangeHi; // wrap backward + nextIdx = rangeHi; // wrap backward } - float t = _frameNum - (float)Math.Floor(_frameNum); - if (t < 0f) t = 0f; - if (t > 1f) t = 1f; + // Fractional blend weight (always in [0, 1]). + double rawT = _framePosition - Math.Floor(_framePosition); + float t = (float)Math.Clamp(rawT, 0.0, 1.0); var f0Parts = curr.Anim.PartFrames[frameIdx].Frames; var f1Parts = curr.Anim.PartFrames[nextIdx].Frames; @@ -585,9 +697,9 @@ public sealed class AnimationSequencer } else { - float omega = MathF.Acos(dot); + float omega = MathF.Acos(dot); float sinOmega = MathF.Sin(omega); - float invSin = 1f / sinOmega; + float invSin = 1f / sinOmega; float candidate1 = MathF.Sin((1f - t) * omega) * invSin; float candidate2 = MathF.Sin(t * omega) * invSin; diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index 0c568f4..09af88d 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -13,7 +13,7 @@ using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; namespace AcDream.Core.Tests.Physics; -// ─────────────────────────────────────────────────────────────────────────��─── +// ───────────────────────────────────────────────────────────────────────────── // AnimationSequencerTests // // All tests run fully offline -- no DatCollection, no disk access. @@ -21,15 +21,18 @@ namespace AcDream.Core.Tests.Physics; // exactly the code paths we are testing. // // Covered: -// 1. SlerpRetailClient matches System.Numerics slerp for standard cases. -// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc). -// 3. SlerpRetailClient falls back to linear for near-parallel quaternions. -// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop). -// 5. Advance at dt=0 returns identity frame (no motion table loaded). -// 6. SetCycle transitions: link frames are prepended before the target cycle. -// 7. GetLink returns null when MotionTable has no link for the transition. -// 8. SetCycle with same motion twice is a no-op (fast path). -// 9. Reset clears all state. +// 1. SlerpRetailClient matches System.Numerics slerp for standard cases. +// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc). +// 3. SlerpRetailClient falls back to linear for near-parallel quaternions. +// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop). +// 5. Advance at dt=0 returns identity frame (no motion table loaded). +// 6. SetCycle transitions: link frames are prepended before the target cycle. +// 7. GetLink returns null when MotionTable has no link for the transition. +// 8. SetCycle with same motion twice is a no-op (fast path). +// 9. Reset clears all state. +// 10. Negative-speed playback (TurnLeft → TurnRight with reversed animation). +// 11. Boundary crossing: frame wraps correctly in reverse. +// 12. advance_to_next_animation: transition link drains then wraps to cycle. // ───────────────────────────────────────────────────────────────────────────── /// @@ -60,7 +63,6 @@ file static class Fixtures var anim = new Animation(); for (int f = 0; f < numFrames; f++) { - // AnimationFrame requires NumParts in its constructor. var pf = new AnimationFrame((uint)numParts); for (int p = 0; p < numParts; p++) pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); @@ -114,30 +116,30 @@ file static class Fixtures /// public static MotionTable MakeMtable( uint style, uint motion, uint cycleAnimId, - uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0) + uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0, + float framerate = 30f) { var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)style; mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion; int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu)); - mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate: 30f); + mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate); if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0) { int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu)); var cmd = new MotionCommandData(); - cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate: 30f); + cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate); mt.Links[linkOuter] = cmd; } return mt; } - private static MotionData MakeMotionData(uint animId, float framerate) + public static MotionData MakeMotionData(uint animId, float framerate) { var md = new MotionData(); - // QualifiedDataId has an implicit conversion from uint. QualifiedDataId qid = animId; md.Anims.Add(new AnimData { @@ -201,7 +203,7 @@ public sealed class AnimationSequencerTests Assert.Equal(q.W, got.W, 4); } - // ── SetCycle / frame advance ────────────────────────────────────────��──── + // ── SetCycle / frame advance ───────────────────────────────────────────── [Fact] public void Advance_NoCycleSet_ReturnsIdentityTransforms() @@ -371,12 +373,12 @@ public sealed class AnimationSequencerTests // Advance a bit to move the frame counter. seq.Advance(0.1f); - float frameBefore = GetFrameNum(seq); + double frameBefore = GetFramePosition(seq); // Call SetCycle again with identical args -- fast-path, no reset. seq.SetCycle(Style, Motion); - float frameAfter = GetFrameNum(seq); + double frameAfter = GetFramePosition(seq); Assert.Equal(frameBefore, frameAfter); } @@ -412,16 +414,235 @@ public sealed class AnimationSequencerTests } } + // ── Negative-speed playback (TurnLeft → TurnRight reversed) ───────────── + + [Fact] + public void SetCycle_TurnLeft_RemapsToTurnRightWithNegativeSpeed() + { + // TurnLeft (low nibble 0x000E) should remap to TurnRight (0x000D) + // with negated speed, so the animation plays in reverse. + // We verify this by checking CurrentMotion is still TurnLeft (the + // original command), but the sequencer internally uses TurnRight's anim. + + const uint Style = 0x003Du; // NonCombat + const uint TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat + const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft + const uint AnimId = 0x03000050u; + + // 4-frame animation; each frame has a distinct Z-origin so we can tell + // which direction we're reading. + var anim = new Animation(); + for (int f = 0; f < 4; f++) + { + var pf = new AnimationFrame(1); + pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); + anim.PartFrames.Add(pf); + } + + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + + // Register TurnRight cycle (adjusted motion, not TurnLeft). + int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu)); + mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, TurnLeft, speedMod: 1f); + + // CurrentMotion should record the original TurnLeft command. + Assert.Equal(TurnLeft, seq.CurrentMotion); + + // After FUN_005267E0 (multiply_framerate) swaps low↔high for negative speed: + // StartFrame = 3 (was high), EndFrame = 0 (was low) + // GetStartFramePosition for negative speed = (EndFrame + 1) - EPSILON = (0+1) - eps ≈ 0.99999. + // The cursor starts just below frame 1 and counts DOWN toward EndFrame(=0). + double pos = GetFramePosition(seq); + Assert.True(pos > 0.9 && pos < 1.0, + $"Expected framePosition near 0.99999 (reverse start near EndFrame+1) but got {pos}"); + } + + [Fact] + public void Advance_NegativeSpeed_FramePositionDecreases() + { + // Verify that a cycle loaded with negative framerate counts downward. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000060u; + + var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity); + var setup = Fixtures.MakeSetup(1); + var mt = new MotionTable(); + mt.DefaultStyle = (DRWMotionCommand)Style; + + // Register cycle with NEGATIVE framerate to simulate reverse playback. + int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); + var md = new MotionData(); + QualifiedDataId qid = AnimId; + md.Anims.Add(new AnimData + { + AnimId = qid, + LowFrame = 0, + HighFrame = 7, + Framerate = -10f, // negative → reverse + }); + mt.Cycles[cycleKey] = md; + + var loader = new FakeLoader(); + loader.Register(AnimId, anim); + + var seq = new AnimationSequencer(setup, mt, loader); + seq.SetCycle(Style, Motion); + + // For negative framerate: startFrame=7, endFrame=0 (swapped by multiply_framerate). + // GetStartFramePosition = (endFrame + 1) - EPSILON = 1 - eps (the swapped endFrame is 0). + // Wait — after swap: StartFrame=7, EndFrame=0. + // GetStartFramePosition for negative fr: (EndFrame + 1) - eps = (0 + 1) - eps ≈ 0.99999. + // Then Advance(0.05) at -10fps → delta = -10 * 0.05 = -0.5 → new pos ≈ 0.49999. + double posBefore = GetFramePosition(seq); + seq.Advance(0.05f); + double posAfter = GetFramePosition(seq); + + Assert.True(posAfter < posBefore, + $"Expected framePosition to decrease (reverse) but went {posBefore} → {posAfter}"); + } + + [Fact] + public void Advance_NegativeSpeed_WrapsAtStartBoundary() + { + // A reverse-speed cycle should wrap (via advance_to_next_animation) + // when it reaches its StartFrame boundary, then loop back to the + // firstCyclic node's end position. + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000070u; + + 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(); + 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); + + // Advance well past one full reverse loop (0.5s at 10fps = 5 frames). + // Should NOT throw or crash — wrap must produce a valid transform. + seq.Advance(0.5f); + var transforms = seq.Advance(0.01f); + + Assert.Single(transforms); + // Verify the frame position is back within the valid range after wrapping. + double pos = GetFramePosition(seq); + Assert.True(pos >= 0.0 && pos < 4.0, + $"Frame position {pos} out of range [0, 4) after reverse wrap"); + } + + // ── advance_to_next_animation: link drains then wraps to cycle ─────────── + + [Fact] + public void AdvanceToNextAnimation_LinkDrainsThenCycleLoops() + { + // Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)] + // Advance enough to exhaust the link node, then verify we're in the cycle. + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; + const uint CycleAnim = 0x03000080u; + const uint LinkAnim = 0x03000081u; + + // Link anim: 2 frames, Y=5 (distinct marker). + var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity); + // Cycle anim: 4 frames, X=9 (distinct marker). + var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), 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); + + // Link node is 2 frames at 10fps → 0.2s to exhaust. + // Advance 0.25s so we're definitely past the link and into the cycle. + seq.Advance(0.25f); + + var transforms = seq.Advance(0.001f); + + // After draining the 2-frame link node, we should be in the cycle anim (X=9). + Assert.Single(transforms); + Assert.True(transforms[0].Origin.X > 8f, + $"Expected cycle anim origin X~9 but got {transforms[0].Origin.X} (link Y was 5)"); + } + + [Fact] + public void AdvanceToNextAnimation_CycleLoopsRepeatedly() + { + // Verify that a cycle keeps looping (multiple wraps don't crash or drift). + const uint Style = 0x003Du; + const uint Motion = 0x0003u; + const uint AnimId = 0x03000090u; + + var anim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), 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); + + // Advance 5 full loops (4 frames × 10fps = 0.4s per loop → 2.0s total). + for (int i = 0; i < 10; i++) + seq.Advance(0.2f); + + var transforms = seq.Advance(0.001f); + + Assert.Single(transforms); + // Frame position must be in a valid range (not NaN, not out of bounds). + double pos = GetFramePosition(seq); + Assert.True(pos >= 0.0 && pos < 4.0, + $"Frame position {pos} out of range [0, 4) after 5 loops"); + } + // ── Helpers ────────────────────────────────────────────────────────────── - /// Expose _frameNum via reflection (test-only). - private static float GetFrameNum(AnimationSequencer seq) + /// Expose _framePosition (double) via reflection (test-only). + private static double GetFramePosition(AnimationSequencer seq) { var field = typeof(AnimationSequencer) - .GetField("_frameNum", + .GetField("_framePosition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return field is null ? -1f : (float)field.GetValue(seq)!; + return field is null ? -1.0 : (double)field.GetValue(seq)!; } ///