using System; using System.Collections.Generic; using System.Numerics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; namespace AcDream.Core.Physics; /// /// Minimal interface for resolving Animation objects by id. /// Abstracted so the sequencer can be unit-tested without a real DatCollection. /// public interface IAnimationLoader { /// Load an Animation by its dat id, or return null. Animation? LoadAnimation(uint id); } /// /// Production implementation of backed by /// a . /// public sealed class DatCollectionLoader : IAnimationLoader { private readonly DatCollection _dats; public DatCollectionLoader(DatCollection dats) => _dats = dats; public Animation? LoadAnimation(uint id) => _dats.Get(id); } // ───────────────────────────────────────────────────────────────────────────── // AnimationSequencer — faithful port of the decompiled retail AC client // animation system. // // 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 // key = (style << 16) | (fromSubstate & 0xFFFFFF) // MotionCommandData.MotionData : Dictionary // key = target motion (int cast of MotionCommand) // MotionData.Anims : List // AnimData.AnimId : QualifiedDataId // Animation.PartFrames : List // AnimationFrame.Frames : List // Frame.Origin : Vector3, Frame.Orientation : Quaternion // ───────────────────────────────────────────────────────────────────────────── /// /// Per-part world-local transform produced by . /// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs. /// public readonly struct PartTransform { public readonly Vector3 Origin; public readonly Quaternion Orientation; public PartTransform(Vector3 origin, Quaternion orientation) { Origin = origin; Orientation = orientation; } } /// /// 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 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, double framerate, int startFrame, int endFrame, bool isLooping) { Anim = anim; Framerate = framerate; StartFrame = startFrame; EndFrame = endFrame; IsLooping = isLooping; } // ── 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); /// seq.SetCycle(style, motion, speedMod); /// // each frame: /// var transforms = seq.Advance(dt); /// // rebuild MeshRefs from transforms /// /// /// public sealed class AnimationSequencer { // ── Public state ───────────────────────────────────────────────────────── /// Current style (stance) command. public uint CurrentStyle { get; private set; } /// Current cyclic motion command. public uint CurrentMotion { get; private set; } // ── Private state ──────────────────────────────────────────────────────── private readonly Setup _setup; private readonly MotionTable _mtable; private readonly IAnimationLoader _loader; // Animation queue: non-looping link frames followed by the looping cycle. private readonly LinkedList _queue = new(); private LinkedListNode? _currNode; private LinkedListNode? _firstCyclic; // 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 ────────────────────────────────────────────────────────── /// /// Create a sequencer for one entity. /// /// Entity's Setup dat (for part count / default scale). /// Loaded MotionTable dat for this entity. /// /// Animation loader. Use for production, /// or inject a test double in unit tests. /// public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader) { ArgumentNullException.ThrowIfNull(setup); ArgumentNullException.ThrowIfNull(motionTable); ArgumentNullException.ThrowIfNull(loader); _setup = setup; _mtable = motionTable; _loader = loader; } // ── Public API ─────────────────────────────────────────────────────────── /// /// 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. /// /// /// 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 / 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 (negate speed) adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; adjustedSpeed = -speedMod; break; case 0x0010: // SideStepLeft → SideStepRight (negate speed) adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; adjustedSpeed = -speedMod; break; case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor) adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE break; } // Fast-path: already playing this exact motion at the same speed. if (CurrentStyle == style && CurrentMotion == motion && _firstCyclic != null && _queue.Count > 0) return; // Resolve transition link (currentSubstate → adjustedMotion). MotionData? linkData = CurrentMotion != 0 ? GetLink(style, CurrentMotion, adjustedMotion) : null; // 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); // Clear the old cyclic tail; keep any non-cyclic head that hasn't // been played yet (ACE behaviour: non-cyclic anims drain naturally). ClearCyclicTail(); // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); // Enqueue new cycle. if (cycleData is { Anims.Count: > 0 }) { EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true); } else if (_queue.Count == 0) { // No cycle and no link — nothing to play; reset fully. _currNode = null; _firstCyclic = null; _framePosition = 0.0; CurrentStyle = style; CurrentMotion = motion; return; } // Mark the first cyclic node (the looping tail after all link frames). _firstCyclic = null; for (var n = _queue.First; n != null; n = n.Next) { if (n.Value.IsLooping) { _firstCyclic = n; break; } } // If we have no current anim, start at the beginning of the queue. if (_currNode == null) { _currNode = _queue.First; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; } CurrentStyle = style; CurrentMotion = motion; } /// /// Advance the animation by seconds and return the /// 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). /// /// /// Elapsed time in seconds since the last call. /// /// One per part in the Setup, in part order. /// If no animation is loaded, all parts get identity transforms. /// public IReadOnlyList Advance(float dt) { int partCount = _setup.Parts.Count; if (_currNode == null || dt <= 0f) return BuildIdentityFrame(partCount); // ── update_internal (FUN_005261D0) ─────────────────────────────── // Loop because a large dt can exhaust multiple nodes sequentially. double timeRemaining = (double)dt; while (timeRemaining > 0.0 && _currNode != null) { var curr = _currNode.Value; double rate = curr.Framerate; // signed (negative = reverse) double delta = rate * timeRemaining; 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 } return BuildBlendedFrame(); } /// /// Reset the sequencer to an unplaying state without clearing the /// motion table reference. /// public void Reset() { _queue.Clear(); _currNode = null; _firstCyclic = null; _framePosition = 0.0; CurrentStyle = 0; CurrentMotion = 0; } // ── Private helpers ────────────────────────────────────────────────────── /// /// Look up the transition MotionData for going from /// to within . /// /// Port of ACE's MotionTable.get_link: /// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion] /// 2. Fallback: try Links[style<<16][toMotion] /// /// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData> /// where MotionCommandData.MotionData is Dictionary<int, MotionData>. /// private MotionData? GetLink(uint style, uint fromMotion, uint toMotion) { int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu)); if (_mtable.Links.TryGetValue(outerKey1, out var cmd1)) { if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1)) return result1; } // Fallback: style-level catch-all. int outerKey2 = (int)(style << 16); if (_mtable.Links.TryGetValue(outerKey2, out var cmd2)) { if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2)) return result2; } return null; } /// /// 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) { uint animId = (uint)ad.AnimId; if (animId == 0) return null; var anim = _loader.LoadAnimation(animId); if (anim is null || anim.PartFrames.Count == 0) return null; int numFrames = anim.PartFrames.Count; 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 >= numFrames) high = numFrames - 1; if (low < 0) low = 0; double fr = (double)ad.Framerate * (double)speedMod; // ── 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; // clamp for positive-speed case only } return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping); } /// /// Append all AnimData entries from to the /// queue. Each AnimData becomes one AnimNode. /// private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping) { 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); if (node != null) _queue.AddLast(node); } } /// /// 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() { if (_firstCyclic == null) return; var node = _firstCyclic; while (node != null) { var next = node.Next; // 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) _framePosition = _currNode.Value.GetEndFramePosition(); else _framePosition = 0.0; } _queue.Remove(node); node = next; } _firstCyclic = null; } /// /// 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; 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 a finite non-looping sequence; stay on last node. if (_currNode != null) _framePosition = _currNode.Value.GetStartFramePosition(); } /// /// Build the per-part blended transform from the current animation frame. /// 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() { int partCount = _setup.Parts.Count; if (_currNode == null) return BuildIdentityFrame(partCount); var curr = _currNode.Value; int numPartFrames = curr.Anim.PartFrames.Count; // 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 >= 0.0) { nextIdx = frameIdx + 1; if (nextIdx > rangeHi || nextIdx >= numPartFrames) nextIdx = rangeLo; // wrap forward } else { nextIdx = frameIdx - 1; if (nextIdx < rangeLo) nextIdx = rangeHi; // wrap backward } // 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; var result = new PartTransform[partCount]; for (int i = 0; i < partCount; i++) { if (i < f0Parts.Count) { var p0 = f0Parts[i]; var p1 = i < f1Parts.Count ? f1Parts[i] : p0; result[i] = new PartTransform( Vector3.Lerp(p0.Origin, p1.Origin, t), SlerpRetailClient(p0.Orientation, p1.Orientation, t)); } else { result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); } } return result; } private static IReadOnlyList BuildIdentityFrame(int partCount) { var result = new PartTransform[partCount]; for (int i = 0; i < partCount; i++) result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); return result; } /// /// Quaternion slerp matching the retail client's FUN_005360d0 /// (chunk_00530000.c:4799-4846): /// /// Compute dot product of q1 and q2. /// If dot < 0, negate q2 (choose the shorter arc). /// If 1 - dot <= epsilon, fall back to (1-t)*q1 + t*q2 (linear). /// Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega). /// Validate result lies in [0,1]²; if not, fall back to linear. /// /// The only difference from the standard formula is step 5: the retail /// client validates that both blend weights are in [0,1] before using the /// sin-based result; this handles degenerate inputs gracefully. /// public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t) { float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z; // Step 2: choose the shorter arc. Quaternion q2s; if (dot < 0f) { dot = -dot; q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W); } else { q2s = q2; } const float SlerpEpsilon = 1e-4f; float w1, w2; if (1f - dot <= SlerpEpsilon) { // Near-parallel: linear fallback (matches retail client's path). w1 = 1f - t; w2 = t; } else { float omega = MathF.Acos(dot); float sinOmega = MathF.Sin(omega); float invSin = 1f / sinOmega; float candidate1 = MathF.Sin((1f - t) * omega) * invSin; float candidate2 = MathF.Sin(t * omega) * invSin; // Step 5: validate (retail client check: both weights in [0,1]). if (candidate1 >= 0f && candidate1 <= 1f && candidate2 >= 0f && candidate2 <= 1f) { w1 = candidate1; w2 = candidate2; } else { w1 = 1f - t; w2 = t; } } return new Quaternion( w1 * q1.X + w2 * q2s.X, w1 * q1.Y + w2 * q2s.Y, w1 * q1.Z + w2 * q2s.Z, w1 * q1.W + w2 * q2s.W); } }