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 // 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 // key = (style << 16) | (fromSubstate & 0xFFFFFF) // 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 // ───────────────────────────────────────────────────────────────────────────── /// /// 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. /// 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 { 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 bool HasPosFrames; // mirror of Anim.Flags & AnimationFlags.PosFrames // 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_005267E0 — multiply_framerate ───────────────────────────────── // Scales this node's framerate by a factor. Used by // AnimationSequencer.MultiplyCyclicFramerate to retarget an already-queued // cyclic animation at a new playback speed without restarting. // // Retail's implementation additionally swapped StartFrame↔EndFrame for a // negative factor (so the forward-playback advance loop could traverse // either direction), but acdream's AnimNode keeps StartFrame ≤ EndFrame // as an invariant and encodes direction purely via Framerate's sign — the // Advance loop then checks against StartFrame as the lower bound for // negative delta. So here we only scale. // // Mirrors ACE AnimSequenceNode.multiply_framerate / Sequence.cs L277-L287 // modulo the swap difference. Valid because the callers we care about // (ForwardSpeed updates from UpdateMotion) only ever pass positive factors. public void MultiplyFramerate(double factor) { Framerate *= factor; } // ── 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. /// /// /// 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. /// /// /// /// /// /// Usage pattern: /// /// var seq = new AnimationSequencer(setup, motionTable, dats); /// seq.SetCycle(style, motion, speedMod); /// // each frame: /// var transforms = seq.Advance(dt); /// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage /// var root = seq.ConsumeRootMotionDelta(); // add to AFrame if desired /// /// /// 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; } /// /// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 /// and is updated by when the same motion is /// re-issued with a different speed (which triggers /// instead of a cycle restart). /// public float CurrentSpeedMod { get; private set; } = 1f; /// /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field. /// Updated each time a MotionData is appended or combined — reflects the /// MOST RECENT MotionData's velocity × speedMod, matching /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130, /// MotionTable.add_motion L358-L370). /// /// /// Crucially this is **not** per-node: while a link animation plays, the /// surfaced velocity is still the cycle's velocity (the cycle was added /// last, so SetVelocity's latest call wins). Remote entity dead-reckoning /// reads this to integrate position without gapping during stance /// transitions. /// /// public Vector3 CurrentVelocity { get; private set; } /// /// Sequence-wide omega, matching 's semantics. /// public Vector3 CurrentOmega { get; private set; } // Diagnostics public int QueueCount => _queue.Count; public bool HasCurrentNode => _currNode != null; /// /// Diagnostic snapshot of _currNode's identity + frame state, for /// the per-tick CURRNODE log line in GameWindow.TickAnimations. /// Lets the caller see whether the actual node being read by Advance / /// BuildBlendedFrame is what SetCycle was supposed to leave it on. /// AnimRefHash uses object-identity hashing on the Animation reference /// so different Walk vs Run anim resources can be distinguished even /// without exposing the full Animation type. /// public (int AnimRefHash, bool IsLooping, double Framerate, int StartFrame, int EndFrame, double FramePosition, int QueueCount) CurrentNodeDiag { get { if (_currNode is null) return (0, false, 0.0, 0, 0, 0.0, _queue.Count); var n = _currNode.Value; int hash = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(n.Anim); return (hash, n.IsLooping, n.Framerate, n.StartFrame, n.EndFrame, _framePosition, _queue.Count); } } /// /// Diagnostic: the AnimRefHash for the FIRST cyclic node in the queue /// (i.e., what SetCycle is trying to land us on for a locomotion cycle). /// Compare against 's AnimRefHash to see /// whether _currNode is actually pointing at the new cycle or /// something stale. /// public int FirstCyclicAnimRefHash => _firstCyclic is null ? 0 : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(_firstCyclic.Value.Anim); // ── 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; // 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; // ── Diagnostics (Commit A 2026-05-03) ─────────────────────────────────── // Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines // emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads // the env var inline rather than caching so a launch can be re-toggled // without restarting. 0.5s per sequencer instance keeps logs readable // while still capturing meaningful state changes. private double _lastSetCycleDiagTime; // ── 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). /// K-fix18 (2026-04-26): when true, do /// NOT enqueue the transition-link frames between the previous and /// new cycle. Used when the caller wants the new cycle to engage /// instantly — e.g. swapping to Falling on a jump start, where the /// RunForward→Falling link is a short "stop running" pose that /// makes the jump look delayed (legs stand still for ~100 ms while /// the link drains, then fold into Falling). Defaults to false to /// preserve normal smooth transitions for everything else. /// /// Check whether the underlying MotionTable contains a cycle for the /// given (style, motion) pair. Useful for callers that want to fall /// back to a known-good motion (e.g. WalkForward → /// Ready) instead of triggering 's /// unconditional ClearCyclicTail path on a missing cycle — /// which leaves the body without any animation tail and snaps every /// part to the setup-default offset (visible as "torso on the /// ground" since most creatures' setup-default has limbs at the /// torso origin). /// public bool HasCycle(uint style, uint motion) { // adjust_motion remapping (mirrors the head of SetCycle): // TurnLeft, SideStepLeft, WalkBackward map to their right/forward // mirror cycles. uint adjustedMotion = motion; switch (motion & 0xFFFFu) { case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break; case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break; case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break; } int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); return _mtable.Cycles.ContainsKey(cycleKey); } public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false) { // ── 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. // // Retail (ACE MotionTable.cs:132-139): when motion == current and // sign(speedMod) matches, DON'T restart the cycle — just rescale the // in-flight cyclic-tail's framerate via multiply_cyclic_animation_framerate. // This keeps the run/walk loop smooth when a new UpdateMotion arrives // with a different ForwardSpeed (e.g. when the server broadcasts a // player's updated RunRate mid-step). // // **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd // backward walk as `WalkForward + speed=-N`, motion stays 0x45000005 // but speedMod sign flips. We MUST do a full cycle restart in that case // so the new (negative) framerate takes effect; otherwise the cycle // keeps playing forward with the old positive framerate and the // observer sees the player walking forward despite the negative speed. if (CurrentStyle == style && CurrentMotion == motion && _firstCyclic != null && _queue.Count > 0 && MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)) { if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f && MathF.Abs(CurrentSpeedMod) > 1e-6f) { MultiplyCyclicFramerate(speedMod / CurrentSpeedMod); CurrentSpeedMod = speedMod; } // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path // is firing instead of the full rebuild. Throttled to 0.5s per // instance (re-throttled after A.1 unthrottled experiment). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; if (nowSec - _lastSetCycleDiagTime > 0.5) { System.Console.WriteLine( $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + $"oldSpeedMod={CurrentSpeedMod:F3} " + $"qCount={_queue.Count} " + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); _lastSetCycleDiagTime = nowSec; } } return; } // Resolve transition link (currentSubstate → adjustedMotion). Pass // both speeds — GetLink switches lookup branches based on sign. // CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer, // so a Ready → WalkBackward transition correctly enters GetLink's // negative-speed (reversed-key) branch. // K-fix18: when the caller asked to skip the transition link // (instant-engage cases like Falling on jump start), force // linkData to null so only the cycle gets enqueued. MotionData? linkData = (skipTransitionLink || CurrentMotion == 0) ? null : GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed); // 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(); // K-fix18: when the caller asked for instant-engage, ALSO drain // any in-flight non-cyclic transition frames from the previous // cycle. Without this, the old RunForward → ??? link would // continue draining for ~100 ms before the new Falling cycle // starts, defeating the "skip the link" intent. if (skipTransitionLink) { _queue.Clear(); _currNode = null; _firstCyclic = null; _framePosition = 0.0; } // Clear sequence-wide physics before the rebuild. Retail's // GetObjectSequence calls sequence.clear_physics() before each // add_motion chain (MotionTable.cs L100-L101, L152-L153). ClearPhysics(); // Snapshot the queue tail BEFORE appending new motion data so we // can locate the first newly-added node afterward and force // _currNode onto it. Without this, _currNode can stay pointing // into stale non-cyclic head frames left over from the previous // cycle (typically a Walk_link or Ready_link's tail), and the // visible animation continues playing those stale frames before // the queue advances naturally to the new cycle. For remote // entities receiving many bundled UMs over time, this stale-head // build-up was the root cause of "transitions between cycles // don't visibly switch the leg pose" even though SetCycle's // CurrentMotion/CurrentSpeedMod were updated correctly. Local // player avoided the bug because PlayerMovementController fires // SetCycle in a tight per-input loop that keeps the queue clean. var preEnqueueTail = _queue.Last; // 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; } } // Force _currNode onto the FIRST NEWLY-ENQUEUED node so the // visible animation switches to the new cycle/link immediately // instead of finishing whatever stale head frames were sitting // at the front of the queue. preEnqueueTail.Next is the first // newly-added node; if preEnqueueTail was null (queue was empty // before enqueue), the first new node is _queue.First. var firstNew = preEnqueueTail is null ? _queue.First : preEnqueueTail.Next; // #39 Fix B (2026-05-06): for direct cyclic-locomotion → // cyclic-locomotion transitions (Walk↔Run on Shift toggle, // W↔S direct flip, A↔D, Forward↔Strafe), land _currNode on // the new CYCLE (_firstCyclic), NOT on the link (firstNew), // and remove the just-enqueued link from the queue. // // Why: the transition link's drain time (~100–300 ms at // Framerate 30 × link runSpeed) gets restarted before it can // end if the user toggles Shift faster than that. _currNode // sits on a fresh link every UM and Advance never reaches // the cycle. User observes "blips forward in walking // animation" — what they're seeing is the link's // interpolation pose, never the new cycle. // // Conditional on BOTH old AND new being locomotion cycles to // avoid regressing the cases where the link IS the right // animation: // - Idle (Ready) → any cycle: link is the wind-up pose // - Falling → Ready: landing animation // - Ready → Sitting/Crouching: pose-change links // - Combat substates (attack/parry/ready transitions) // Commit c06b6c5 (reverted in a2ae2ae) demonstrated that // unconditionally skipping the link breaks all of these. // // Retail reference: cdb live trace 2026-05-03 of a Walk→Run // direct transition logged // add_to_queue(45000005, looping=1) walk // add_to_queue(44000007, looping=1) run // with truncate_animation_list never firing — i.e. retail // appends the new cycle directly without a separate link // enqueue or visible link pose for cyclic→cyclic. Our // structural mismatch was always enqueueing link+cycle and // forcing _currNode onto the link; this fix matches retail's // observed semantics for the locomotion subset. bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu); bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu); if (prevIsLocomotion && newIsLocomotion && _firstCyclic is not null) { // Drop the just-enqueued link node (firstNew) from the // queue if it's distinct from the cycle — nothing should // ever play it, and leaving stale non-cyclic nodes ahead // of _currNode contributes to the unbounded queue growth // observed in [SCFULL] (qCount climbing past 49 over // ~30 transitions). if (firstNew is not null && firstNew != _firstCyclic) { _queue.Remove(firstNew); } _currNode = _firstCyclic; _framePosition = _firstCyclic.Value.GetStartFramePosition(); } else if (firstNew is not null) { _currNode = firstNew; _framePosition = _currNode.Value.GetStartFramePosition(); } else if (_currNode == null) { // Defensive fallback: nothing newly added AND no current node. _currNode = _queue.First; _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; // D4 (Commit A 2026-05-03): SCNULLFALLBACK — proves whether the // null-data fallback is being hit. If this fires during a // Walk→Run transition for the watched remote, H4 (MotionTable // GetLink/GetCycle returns null for the remote's setup) is the // bug. linkData/cycleData null almost certainly means a // MotionTable lookup gap for that style+motion combo. if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[SCNULLFALLBACK] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + $"linkNull={(linkData is null)} cycleNull={(cycleData is null)} " + $"qCount={_queue.Count}"); } } // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on // the full-rebuild SetCycle path. Throttled to 0.5s per instance. // Logs prev CurrentMotion so the line shows the transition directly // (e.g. "Run → Ready" = cycle just got reset). if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; if (nowSec - _lastSetCycleDiagTime > 0.5) { System.Console.WriteLine( $"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + $"speedMod={speedMod:F3} " + $"qCount={_queue.Count} " + $"firstNewNull={(firstNew is null)} " + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " + $"firstCyclicNull={(_firstCyclic is null)}"); _lastSetCycleDiagTime = nowSec; } } CurrentStyle = style; CurrentMotion = motion; CurrentSpeedMod = speedMod; // ── Synthesize CurrentVelocity for locomotion cycles ────────────── // The Humanoid motion table ships every locomotion MotionData with // Flags=0x00 (no HasVelocity), so EnqueueMotionData leaves // CurrentVelocity at Vector3.Zero. That matches the literal retail // dat, but retail's body physics uses CMotionInterp::get_state_velocity // (FUN_00528960) which returns RunAnimSpeed × ForwardSpeed for // RunForward, independent of the dat's HasVelocity flag. The dat // velocity is a separate additive source (kick-off velocity, flying // creatures, etc) not the primary locomotion drive. // // For our sequencer's to be usable by // consumers (local-player get_state_velocity via Option B, remote // dead-reckoning in GameWindow) it must carry the retail-constant // locomotion value when the dat is silent. Synthesize it here, // post-EnqueueMotionData, only when the cycle is a locomotion cycle // AND the dat didn't populate it. // // Constants match etc — // decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local // (+Y = forward, +X = right); consumers rotate into world space via // the owning entity's orientation. // For known locomotion cycles, ALWAYS overwrite CurrentVelocity with // the synthesized value — even if the transition link set // CurrentVelocity from its own HasVelocity flag. The link's velocity // is for the brief transition (e.g. small stride into run-pose); the // cycle's intended steady-state velocity is what consumers (remote // body translation in GameWindow.TickAnimations env-var path) need. // Without this, walking-to-running transitions left CurrentVelocity // at the link's slow pace, and the user reported "it just blips // forward walking" until another motion command (turn, etc) forced // a re-synth. The gate that previously read // `if (CurrentVelocity.LengthSquared() < 1e-9f)` allowed dat-baked // velocity to win over synthesis — which is correct for non- // locomotion (e.g. flying creatures with HasVelocity) but wrong for // Humanoid run/walk/strafe where the dat is silent and the link // velocity is the only thing setting it. { float yvel = 0f; float xvel = 0f; uint low = motion & 0xFFu; bool isLocomotion = false; switch (low) { case 0x05: // WalkForward yvel = WalkAnimSpeed * adjustedSpeed; isLocomotion = true; break; case 0x06: // WalkBackward — adjust_motion remapped to WalkForward // with speedMod *= -0.65f. yvel = WalkAnimSpeed * adjustedSpeed; isLocomotion = true; break; case 0x07: // RunForward yvel = RunAnimSpeed * adjustedSpeed; isLocomotion = true; break; case 0x0F: // SideStepRight xvel = SidestepAnimSpeed * adjustedSpeed; isLocomotion = true; break; case 0x10: // SideStepLeft — remapped to SideStepRight with // negated speed; same handling as backward walk. xvel = SidestepAnimSpeed * adjustedSpeed; isLocomotion = true; break; } if (isLocomotion) CurrentVelocity = new Vector3(xvel, yvel, 0f); } // ── Synthesize CurrentOmega for turn cycles ─────────────────────── // Same story as velocity synthesis above: Humanoid turn MotionData // often ships without HasOmega. Retail clients turn the body via // the baked omega, but if the dat is silent we fall back to the // retail turn-rate constant. Decompile references: // FUN_00529210 apply_current_movement (writes Omega) // chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1) // The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left // turns (holtburger confirms the same via motion_resolution.rs). if (CurrentOmega.LengthSquared() < 1e-9f) { float zomega = 0f; uint low = motion & 0xFFu; switch (low) { case 0x0D: // TurnRight — clockwise from above = -Z in right-handed. zomega = -(MathF.PI / 2f) * adjustedSpeed; break; case 0x0E: // TurnLeft — counter-clockwise = +Z. // adjust_motion above ALREADY remapped 0x0E → 0x0D // with adjustedSpeed = -speedMod, so the same // formula as 0x0D applied to the negated speed // produces the correct +Z (CCW) result. Using a // different sign here would double-negate and // animate a left turn as a right turn — that was // the bug observed before this fix (commit follows). zomega = -(MathF.PI / 2f) * adjustedSpeed; break; } if (zomega != 0f) CurrentOmega = new Vector3(0f, 0f, zomega); } } // Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed // etc. Kept here to keep AnimationSequencer self-contained for the // synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8. private const float WalkAnimSpeed = 3.12f; private const float RunAnimSpeed = 4.0f; private const float SidestepAnimSpeed = 1.25f; /// /// Scale every cyclic node's framerate by , mirroring /// ACE's Sequence.multiply_cyclic_animation_framerate /// (references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287, /// retail decompile FUN_00525CE0). Walks _firstCyclic through /// the tail of the queue and calls /// on each. The non-cyclic head (link frames) is untouched — those drain /// at their original framerate, which matches retail: the sequencer /// "catches up" the transition before applying the new run speed. /// /// /// Called from when the same (style, motion) pair /// is re-issued with a different speedMod — for instance, when a remote /// player's ForwardSpeed changes mid-run. Does NOT restart the animation, /// so footsteps keep planting where they are. /// /// /// Framerate multiplier (newSpeed / oldSpeed). public void MultiplyCyclicFramerate(float factor) { if (_firstCyclic == null) return; if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor)) return; for (var node = _firstCyclic; node != null; node = node.Next) { node.Value.MultiplyFramerate((double)factor); } // Sequence-wide velocity/omega scale too. Retail's flow is // subtract_motion(oldSpeed) + combine_motion(newSpeed) in // MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which // algebraically equals scaling by newSpeed/oldSpeed — exactly // what the factor represents here. CurrentVelocity *= factor; CurrentOmega *= factor; } /// /// Advance the animation by seconds and return the /// per-part transforms for the current blended keyframe. /// /// /// 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. /// /// /// /// 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. /// /// 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 / 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 && safety-- > 0) { 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 // lastFrame = floor(_framePosition) BEFORE advance (ACE pattern). int lastFrame = (int)Math.Floor(_framePosition); double newPos = _framePosition + delta; bool wrapped = false; double overflow = 0.0; if (delta > 0.0) { // ── FORWARD PLAYBACK ────────────────────────────────────── double maxBoundary = (double)(curr.EndFrame + 1); if (newPos >= maxBoundary - FrameEpsilon) { // Time spilled past the boundary. overflow = (newPos - maxBoundary) / rate; if (overflow < 0.0) overflow = 0.0; _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 ───────────────────────────────────── double minBoundary = (double)curr.StartFrame; if (newPos <= minBoundary) { overflow = (newPos - minBoundary) / rate; if (overflow < 0.0) overflow = 0.0; _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 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 } 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 /// queue immediately before the looping cyclic tail; they drain once /// and then the cycle resumes naturally. /// /// /// Retail semantics: actions and modifiers live in /// (a separate dict from /// ) keyed by /// (style << 16) | (motion & 0xFFFFFF). A motion like /// Jump = 0x2500003b is a Modifier (class byte 0x25) not a /// SubState — feeding it to silently fails the /// cycle lookup. Routing through PlayAction instead resolves /// from the Modifiers table and interleaves the action frames with /// the ongoing cyclic motion. /// /// /// /// If no entry is found in the Modifiers table for the requested /// motion, this is a no-op. /// /// /// Raw MotionCommand (e.g. 0x2500003b for Jump). /// Speed multiplier for the action's framerate. public void PlayAction(uint motionCommand, float speedMod = 1f) { // Resolve motion data. The lookup depends on the command's mask class: // // - Action (mask 0x10): stored in the Links dict as the transition // FROM currentSubstate TO the action motion. Matches ACE // MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action). // - Modifier (mask 0x20): stored in the Modifiers dict, keyed by // (style<<16) | (motion&0xFFFFFF) (or unstyled key). Matches ACE // @ line 234-242 (CommandMask.Modifier). // // Jump (0x2500003B) has BOTH bits set (0x20|0x04|0x01) but ACE treats // it via the Modifier path. FallDown (0x10000050) / Jumpup (0x1000004B) // are pure Actions (mask 0x10) and live in Links. // // We try Links first (via GetLink, which reproduces ACE's get_link // fallback chain). If that fails and the motion is a Modifier, fall // through to the Modifiers dict. const uint ActionMask = 0x10000000u; const uint ModifierMask = 0x20000000u; MotionData? data = null; if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0) { // Action: look up the transition link from current substate → action. // Action overlays always play forward (positive speeds) — the // action speed mod is the caller-supplied modifier, not part of // the substate cycle's direction. data = GetLink(CurrentStyle, CurrentMotion, /*substateSpeed:*/ 1f, motionCommand, /*speed:*/ 1f); } if (data is null && (motionCommand & ModifierMask) != 0) { uint styleKey = CurrentStyle << 16; int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu)); int keyPlain = (int)(motionCommand & 0xFFFFFFu); if (!_mtable.Modifiers.TryGetValue(keyStyled, out data)) _mtable.Modifiers.TryGetValue(keyPlain, out data); } if (data is null || data.Anims.Count == 0) return; // 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, vel, omg); if (node != null) newNodes.Add(node); } if (newNodes.Count == 0) return; // Insert before the cyclic tail (so the action plays, then cycle resumes). // If there's no cyclic tail yet, append at the end. LinkedListNode? firstInserted = null; if (_firstCyclic != null) { foreach (var n in newNodes) { var inserted = _queue.AddBefore(_firstCyclic, n); firstInserted ??= inserted; } } else { foreach (var n in newNodes) { var inserted = _queue.AddLast(n); firstInserted ??= inserted; } } // If we're currently on the cyclic tail (or past where we inserted), // jump the cursor back to the first newly-inserted action node so the // action plays immediately instead of after the next cycle wrap. bool cursorOnCyclic = _currNode != null && _currNode.Value.IsLooping; if (cursorOnCyclic || _currNode == null) { _currNode = firstInserted; if (_currNode != null) _framePosition = _currNode.Value.GetStartFramePosition(); } } /// /// 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; _pendingHooks.Clear(); _rootMotionPos = Vector3.Zero; _rootMotionRot = Quaternion.Identity; CurrentStyle = 0; CurrentMotion = 0; CurrentSpeedMod = 1f; CurrentVelocity = Vector3.Zero; CurrentOmega = Vector3.Zero; } // ── 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 /// (current state, played at ) to /// (new state, played at ). /// /// /// Port of ACE's MotionTable.get_link (MotionTable.cs:395-426). The lookup /// path differs by sign of the speeds — the retail/ACE mechanism is two /// distinct branches: /// /// Both speeds positive (forward → forward, normal case): /// Look up Links[(style<<16) | substate][motion] — the link FROM /// substate TO motion. Played forward. /// Either speed negative (any direction reversal — /// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key /// Links[(style<<16) | motion][substate] — the link FROM motion TO /// substate. Played in reverse, this anim visually transitions /// substate → motion's pose, then the cycle continues from where it /// left off. Without this branch, Ready→WalkBackward would queue the /// "start walking forward" link played in reverse, which strands the /// cursor at the wrong cycle frame and causes the user-visible /// "left leg twitches forward two times" glitch on the X key. /// /// /// /// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData> /// where MotionCommandData.MotionData is Dictionary<int, MotionData>. /// private MotionData? GetLink(uint style, uint substate, float substateSpeed, uint motion, float speed) { if (speed < 0f || substateSpeed < 0f) { // Reversed-direction path: link FROM motion TO substate. int reversedKey = (int)((style << 16) | (motion & 0xFFFFFFu)); if (_mtable.Links.TryGetValue(reversedKey, out var revLink) && revLink.MotionData.TryGetValue((int)substate, out var revResult)) { return revResult; } // Style-defaults fallback per ACE MotionTable.cs:405-409. if (_mtable.StyleDefaults.TryGetValue( (DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion)) { int subKey = (int)((style << 16) | (substate & 0xFFFFFFu)); if (_mtable.Links.TryGetValue(subKey, out var subLink) && subLink.MotionData.TryGetValue((int)defaultMotion, out var subResult)) { return subResult; } } return null; } // Forward-direction path: link FROM substate TO motion (the original // implementation pre-K-fix6). int outerKey1 = (int)((style << 16) | (substate & 0xFFFFFFu)); if (_mtable.Links.TryGetValue(outerKey1, out var cmd1) && cmd1.MotionData.TryGetValue((int)motion, out var result1)) { return result1; } // Fallback: style-level catch-all (ACE line 419-422). int outerKey2 = (int)(style << 16); if (_mtable.Links.TryGetValue(outerKey2, out var cmd2) && cmd2.MotionData.TryGetValue((int)motion, 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"). /// private AnimNode? LoadAnimNode( AnimData ad, float speedMod, bool isLooping, Vector3 velocity, Vector3 omega) { 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; // Do NOT swap StartFrame↔EndFrame for negative speed. // The Advance loop handles negative delta by checking against // StartFrame as the lower boundary. GetStartFramePosition uses // EndFrame (the HIGH value) to start the cursor near the top // for reverse playback, so the cursor traverses all frames // from high→low instead of being stuck in [0,1). if (low > high) high = low; bool hasPosFrames = anim.Flags.HasFlag(AnimationFlags.PosFrames) && anim.PosFrames.Count >= numFrames; return new AnimNode( anim, fr, startFrame: low, endFrame: high, isLooping, hasPosFrames, velocity, omega); } /// /// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics, /// ACE Sequence.cs L256-L260). Called before a style-transition rebuild /// in SetCycle so we don't inherit velocity from the previous cycle. /// private void ClearPhysics() { CurrentVelocity = Vector3.Zero; CurrentOmega = Vector3.Zero; } /// /// Append all AnimData entries from to the /// 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; // Sequence-wide velocity/omega update, matching ACE's // MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity // REPLACES the previous sequence velocity. When SetCycle enqueues // link then cycle, the final CurrentVelocity is the cycle's — which // is what dead-reckoning needs to read from the first frame of the // link transition (the cycle velocity is already "queued up" even // while a zero-velocity link plays visually). // // Only replace if HasVelocity (else we'd zero out a running cycle // when a transient HasVelocity=0 modifier enqueues). Matches // retail's conditional behavior: MotionData without HasVelocity // doesn't touch the sequence velocity. if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)) CurrentVelocity = vel; if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega)) CurrentOmega = omg; 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, vel, omg); 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(); } /// /// 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 /// 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; } /// /// True if the given motion-low-byte names a locomotion cycle — /// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07), /// SideStepRight (0x0F), or SideStepLeft (0x10). /// Used by to recognise cyclic→cyclic /// direct transitions and bypass the transition link in that case /// (retail's observed add_to_queue semantics). /// private static bool IsLocomotionCycleLowByte(uint lowByte) { return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u || lowByte == 0x0Fu || lowByte == 0x10u; } /// /// 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); } }