From 9147344a6f6177a429edc7b04a183f3e0ca177c9 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 2 Jul 2026 20:20:58 +0200 Subject: [PATCH] feat(R1-P5): AnimationSequencer rehosted on the verbatim CSequence core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter keeps its full public surface (every consumer compiles unchanged) while the internals move to Motion.CSequence: DELETED (legacy invented mechanisms, per the R1 gap map + Phase R mandate): the AnimNode class + adapter queue/_currNode/_firstCyclic/ _framePosition, the ACE-fabricated FrameEpsilon boundary math, the safety=64 advance cap, ClearCyclicTail surgery, the stale-head _currNode relocation block, per-node IsLooping/Velocity/Omega, the adapter-side AdvanceToNextAnimation/ApplyPosFrame/ExecuteHooks, the per-node !IsLooping AnimationDone gate (now the core's list-structure head != first_cyclic gate, G5). REHOSTED: SetCycle rebuild = RemoveCyclicAnims (+ClearAnimations for K-fix18) + retail add_motion appends (framerate-only AnimData scaling, G10 loop membership: the LAST appended node is the cyclic tail) + Fix B via RemoveAllLinkAnimations (the core's snap-forward IS Fix B's behavior); Advance = core.Update(dt, null) with hooks flowing through an IAnimHookQueue adapter into the existing ConsumePendingHooks drain; fast path = core.MultiplyCyclicAnimationFramerate + the adapter-level velocity rescale (R2's change_cycle_speed composite stand-in, G13). Kept byte-identical: adjust_motion remap, GetLink + stop-anim fallback, K-fix18, velocity synthesis, SlerpRetailClient, the #61 render-side clamp, SCFAST/SCFULL diagnostics. Tests: 2 updated with decomp citations — the reverse-start boundary now pins retail's bare-int HighFrame+1 (0x00525c80, no epsilon, G1); the root-motion accumulation test pins the inert-stub state pending P6's Frame wiring (G7). All 44 sequencer tests + the 56 core tests + full suite green (3346). Implemented by a dedicated agent against the P5 spec; diff + tests reviewed, suite re-verified before commit. Co-Authored-By: Claude Fable 5 --- .../Physics/AnimationSequencer.cs | 1015 ++++++----------- .../Physics/AnimationSequencerTests.cs | 76 +- 2 files changed, 398 insertions(+), 693 deletions(-) diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 4dc4aef9..417eb539 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Physics.Motion; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -30,19 +31,33 @@ public sealed class DatCollectionLoader : IAnimationLoader } // ───────────────────────────────────────────────────────────────────────────── -// AnimationSequencer — faithful port of the decompiled retail AC client -// animation system. +// AnimationSequencer — adapter rehosted on the verbatim retail CSequence core +// (R1-P5, docs/research/2026-07-02-r1-csequence/r1-gap-map.md §3 P5). // -// 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 +// R1-P5 REHOST: the legacy AnimNode/LinkedList queue machinery is +// gone. All frame-advance, list-surgery, and boundary-math state now lives in +// AcDream.Core.Physics.Motion.CSequence (R1-P1..P4, verbatim retail port — +// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every +// public member's signature (the API-migration table in the gap map is the +// contract) and re-expresses each one over the core. Invented mechanisms that +// are still R2/R3 scope (K-fix18 skipTransitionLink, Fix B locomotion +// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega +// synthesis constants) SURVIVE unchanged at the adapter level per the gap +// map's "Invented behaviors NOT in the gap list" note. +// +// Primary references (docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md): +// append_animation 0x00525510 (§24) +// remove_cyclic_anims 0x00524e40 (§6) +// remove_all_link_animations 0x00524ca0 (§8) +// clear_animations 0x00524dc0 (§4) +// clear_physics 0x00524d50 (§9? see CSequence.cs) +// update / update_internal 0x00525b80 / 0x005255d0 (§21/§22) +// advance_to_next_animation 0x005252b0 (§23) +// multiply_cyclic_animation_fr 0x00524940 (§14) +// FUN_005360d0 — quaternion slerp with dot-product sign-flip (render-side, +// NOT CSequence scope — kept verbatim in this adapter) // 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 +// (R2/R3 scope; kept verbatim in this adapter per the gap map) // // DatReaderWriter types used: // MotionTable.Links : Dictionary @@ -77,125 +92,16 @@ 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. -/// 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. -/// -/// +/// R1-P5: this is now a thin adapter over — the +/// verbatim retail port of the AC client's CSequence object. The +/// adapter owns caller-facing bookkeeping (CurrentStyle/CurrentMotion/ +/// CurrentSpeedMod, dat lookups via Setup/MotionTable, the retail-faithful +/// but still-R2/R3-scope invented mechanisms) while all frame-advance, +/// list-surgery, and boundary math live in the core. /// /// /// @@ -229,10 +135,10 @@ public sealed class AnimationSequencer 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, + /// Sequence-wide velocity mirror of the core's + /// field (retail Sequence::Velocity). Updated each time a + /// MotionData is appended — reflects the MOST RECENT MotionData's + /// velocity × speedMod (retail set_velocity semantics, /// MotionTable.add_motion L358-L370). /// /// @@ -243,35 +149,44 @@ public sealed class AnimationSequencer /// transitions. /// /// - public Vector3 CurrentVelocity { get; private set; } + public Vector3 CurrentVelocity => _core.Velocity; /// /// Sequence-wide omega, matching 's semantics. /// - public Vector3 CurrentOmega { get; private set; } + public Vector3 CurrentOmega => _core.Omega; // Diagnostics - public int QueueCount => _queue.Count; - public bool HasCurrentNode => _currNode != null; + public int QueueCount => _core.Count; + public bool HasCurrentNode => _core.CurrAnim != 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. + /// Diagnostic snapshot of the core's curr_anim 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. + /// + /// IsLooping here means "this node IS the cyclic tail" (core structural + /// test: curr == first_cyclic) — retail nodes carry no per-node + /// IsLooping flag (G16); this diagnostic re-derives the same meaning + /// from list structure. /// 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); + var n = _core.CurrAnim; + if (n is null) + return (0, false, 0.0, 0, 0, 0.0, _core.Count); + int hash = n.Anim is null + ? 0 + : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(n.Anim); + bool isLooping = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic); + return (hash, isLooping, n.Framerate, n.LowFrame, n.HighFrame, _core.FrameNumber, _core.Count); } } @@ -279,13 +194,19 @@ public sealed class AnimationSequencer /// 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. + /// whether the core's curr_anim 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); + public int FirstCyclicAnimRefHash + { + get + { + var fc = _core.FirstCyclic; + return fc?.Anim is null + ? 0 + : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(fc.Anim); + } + } // ── Private state ──────────────────────────────────────────────────────── @@ -293,28 +214,26 @@ public sealed class AnimationSequencer 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; + // R1-P5: the verbatim retail CSequence core. Owns the animation list, + // curr_anim/first_cyclic cursors, frame_number, and Velocity/Omega + // accumulators. + private readonly CSequence _core; - // 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. + // Hooks pending dispatch. Accumulated during Advance (via the + // AdapterHookQueue seam below); 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. + // ConsumeRootMotionDelta. R1-P6 NOTE (gap map G7): the accumulation + // sites that used to write these fields are gone with the deleted + // legacy advance loop — root motion now flows ONLY through the core's + // CSequence.Update(frame) path, which R1-P6 wires to an actual Frame. + // ConsumeRootMotionDelta has zero callers today (confirmed at cutover) + // so it's kept as a no-op-returning stub for API compatibility until + // P6 retires or rewires it. 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 @@ -343,10 +262,38 @@ public sealed class AnimationSequencer _setup = setup; _mtable = motionTable; _loader = loader; + _core = new CSequence(loader); + _core.HookObj = new AdapterHookQueue(this); } // ── Public API ─────────────────────────────────────────────────────────── + /// + /// 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 cyclic-tail rebuild 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); + } + /// /// 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 @@ -371,33 +318,6 @@ public sealed class AnimationSequencer /// 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 ─── @@ -426,7 +346,7 @@ public sealed class AnimationSequencer // // 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. + // in-flight cyclic-tail's framerate via multiply_cyclic_animation_fr. // 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). @@ -438,7 +358,7 @@ public sealed class AnimationSequencer // 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 + && _core.FirstCyclic != null && _core.Count > 0 && MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)) { if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f @@ -459,8 +379,8 @@ public sealed class AnimationSequencer System.Console.WriteLine( $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + $"oldSpeedMod={CurrentSpeedMod:F3} " - + $"qCount={_queue.Count} " - + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); + + $"qCount={_core.Count} " + + $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)}"); _lastSetCycleDiagTime = nowSec; } } @@ -506,91 +426,54 @@ public sealed class AnimationSequencer 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(); + // ── Rebuild path (R1-P5) ─────────────────────────────────────────── + // remove_cyclic_anims (0x00524e40): drop the old cyclic tail; keep + // any non-cyclic head that hasn't been played yet — the core snaps + // curr_anim back to the preceding link node (or clears to empty). + _core.RemoveCyclicAnims(); // 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. + // clear_animations (0x00524dc0): full wipe. if (skipTransitionLink) { - _queue.Clear(); - _currNode = null; - _firstCyclic = null; - _framePosition = 0.0; + _core.ClearAnimations(); } // 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; + _core.ClearPhysics(); // Enqueue link frames (with adjusted speed for left→right remapping). if (linkData is { Anims.Count: > 0 }) - EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); + EnqueueMotionData(linkData, adjustedSpeed); // Enqueue new cycle. if (cycleData is { Anims.Count: > 0 }) { - EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true); + EnqueueMotionData(cycleData, adjustedSpeed); } - else if (_queue.Count == 0) + else if (_core.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. + // Fix B (both old AND new locomotion low-byte, #39 2026-05-06): for + // direct cyclic-locomotion → cyclic-locomotion transitions + // (Walk↔Run on Shift toggle, W↔S direct flip, A↔D, Forward↔Strafe), + // land curr_anim on the new CYCLE, not on the link, and remove the + // just-enqueued link nodes from the list entirely. // // 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 + // end if the user toggles Shift faster than that. curr_anim // 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 @@ -614,49 +497,20 @@ public sealed class AnimationSequencer // 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 + // forcing curr onto the link; this fix matches retail's // observed semantics for the locomotion subset. + // + // Retail mechanism = remove_redundant_links (0x0051bf20, R2 scope, + // gap map "Invented behaviors" note). Ported here as + // remove_all_link_animations (the R1 core's verbatim remove-family + // primitive) — this IS Fix B's outcome for the locomotion subset, + // now expressed through the core instead of hand-rolled queue + // surgery. bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu); bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu); - if (prevIsLocomotion && newIsLocomotion && _firstCyclic is not null) + if (prevIsLocomotion && newIsLocomotion && _core.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}"); - } + _core.RemoveAllLinkAnimations(); } // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on @@ -671,10 +525,9 @@ public sealed class AnimationSequencer 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)}"); + + $"qCount={_core.Count} " + + $"currNodeIsCyclic={ReferenceEquals(_core.CurrAnim, _core.FirstCyclic)} " + + $"firstCyclicNull={(_core.FirstCyclic is null)}"); _lastSetCycleDiagTime = nowSec; } } @@ -713,12 +566,7 @@ public sealed class AnimationSequencer // 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. + // a re-synth. { float yvel = 0f; float xvel = 0f; @@ -750,7 +598,7 @@ public sealed class AnimationSequencer break; } if (isLocomotion) - CurrentVelocity = new Vector3(xvel, yvel, 0f); + _core.SetVelocity(new Vector3(xvel, yvel, 0f)); } // ── Synthesize CurrentOmega for turn cycles ─────────────────────── @@ -762,7 +610,7 @@ public sealed class AnimationSequencer // 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) + if (_core.Omega.LengthSquared() < 1e-9f) { float zomega = 0f; uint low = motion & 0xFFu; @@ -783,7 +631,7 @@ public sealed class AnimationSequencer break; } if (zomega != 0f) - CurrentOmega = new Vector3(0f, 0f, zomega); + _core.SetOmega(new Vector3(0f, 0f, zomega)); } } @@ -795,14 +643,13 @@ public sealed class AnimationSequencer 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. + /// Scale every cyclic node's framerate by , + /// mirroring retail's multiply_cyclic_animation_fr (0x00524940): + /// walks first_cyclic through the tail of the list and scales + /// each node's framerate. 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 @@ -810,26 +657,32 @@ public sealed class AnimationSequencer /// player's ForwardSpeed changes mid-run. Does NOT restart the animation, /// so footsteps keep planting where they are. /// + /// + /// + /// R1-P5 / gap-map G13: the core's MultiplyCyclicAnimationFramerate + /// touches ONLY node framerates (retail-verbatim). The velocity/omega + /// rescale below is the ADAPTER-level stand-in for retail's + /// change_cycle_speed composite (subtract_motion(oldSpeed) + /// + combine_motion(newSpeed), algebraically equal to scaling by + /// newSpeed/oldSpeed) — an R2 mechanism this adapter keeps inline until + /// R2 routes speed changes through the real subtract/combine_motion + /// pair. See gap map G13 for the register-row rationale. + /// /// /// Framerate multiplier (newSpeed / oldSpeed). public void MultiplyCyclicFramerate(float factor) { - if (_firstCyclic == null) return; + if (_core.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); - } + _core.MultiplyCyclicAnimationFramerate(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; + // R2-composite stand-in (G13): retail's change_cycle_speed rescales + // velocity/omega via subtract_motion(old)+combine_motion(new), which + // is algebraically newSpeed/oldSpeed — exactly this factor. + _core.SetVelocity(_core.Velocity * factor); + _core.SetOmega(_core.Omega * factor); } /// @@ -837,21 +690,12 @@ public sealed class AnimationSequencer /// 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. + /// R1-P5: delegates all frame-advance math to + /// (the verbatim update/update_internal/ + /// advance_to_next_animation port — gap map G3/G4/G5/G8/G9/G19). + /// The core queues hooks through into + /// ; this method only builds the blended + /// render-side output. /// /// /// Elapsed time in seconds since the last call. @@ -863,95 +707,14 @@ public sealed class AnimationSequencer { int partCount = _setup.Parts.Count; - if (_currNode == null || dt <= 0f) + if (_core.CurrAnim == 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 - } + // R1-P6 wires a real Frame* through here for root-motion output + // (gap map G7). Until then, pass null — the core still advances + // frame_number, fires hooks, and steps nodes; it just skips the + // apply_physics/pos-frame combine calls that need a target Frame. + _core.Update(dt, null); return BuildBlendedFrame(); } @@ -974,10 +737,13 @@ public sealed class AnimationSequencer /// /// 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. + /// calls. Returns (Zero, Identity) — see the R1-P5 note on + /// : this accumulator has zero live writers + /// after the cutover (root motion now flows only through + /// 's Frame* argument, unwired until P6; + /// gap map G7). Kept for API compatibility; P6 either wires a real + /// accumulation path or deletes this method (it already has zero + /// external callers). /// public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta() { @@ -990,7 +756,7 @@ public sealed class AnimationSequencer /// /// 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 + /// list immediately before the looping cyclic tail; they drain once /// and then the cycle resumes naturally. /// /// @@ -1009,6 +775,16 @@ public sealed class AnimationSequencer /// If no entry is found in the Modifiers table for the requested /// motion, this is a no-op. /// + /// + /// + /// R1-P5: inserts directly on the core's internal list via + /// / / + /// (internal members visible within + /// this assembly). Preserves the exact pre-cutover semantics: action + /// nodes insert ahead of the cyclic tail and play once; if the cursor + /// was sitting on the cyclic tail (or nothing was playing), it jumps to + /// the first inserted action node so the action plays immediately. + /// /// /// Raw MotionCommand (e.g. 0x2500003b for Jump). /// Speed multiplier for the action's framerate. @@ -1054,29 +830,33 @@ public sealed class AnimationSequencer 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. + // Build AnimSequenceNodes from the action's AnimData list. All + // non-looping — they drain once, then the list falls through to + // first_cyclic. Velocity/omega gate matches EnqueueMotionData (G17). 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 newNodes = new List(data.Anims.Count); + foreach (var ad in data.Anims) { - var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false, vel, omg); - if (node != null) newNodes.Add(node); + var node = BuildNode(ad, speedMod); + if (node.HasAnim) 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) + var animList = _core.AnimList; + var firstCyclicNode = _core.FirstCyclicNode; + + // 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 (firstCyclicNode != null) { foreach (var n in newNodes) { - var inserted = _queue.AddBefore(_firstCyclic, n); + var inserted = animList.AddBefore(firstCyclicNode, n); firstInserted ??= inserted; } } @@ -1084,20 +864,26 @@ public sealed class AnimationSequencer { foreach (var n in newNodes) { - var inserted = _queue.AddLast(n); + var inserted = animList.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) + if (data.Flags.HasFlag(MotionDataFlags.HasVelocity)) + _core.SetVelocity(vel); + if (data.Flags.HasFlag(MotionDataFlags.HasOmega)) + _core.SetOmega(omg); + + // If we're currently on the cyclic tail (or nothing was playing), + // jump the cursor back to the first newly-inserted action node so + // the action plays immediately instead of after the next cycle wrap. + var currNode = _core.CurrAnimNode; + bool cursorOnCyclic = currNode != null && ReferenceEquals(currNode, firstCyclicNode); + if (cursorOnCyclic || currNode == null) { - _currNode = firstInserted; - if (_currNode != null) - _framePosition = _currNode.Value.GetStartFramePosition(); + _core.CurrAnimNode = firstInserted; + if (firstInserted != null) + _core.FrameNumber = firstInserted.Value.GetStartingFrame(); } } @@ -1107,22 +893,38 @@ public sealed class AnimationSequencer /// public void Reset() { - _queue.Clear(); - _currNode = null; - _firstCyclic = null; - _framePosition = 0.0; + _core.Clear(); _pendingHooks.Clear(); _rootMotionPos = Vector3.Zero; _rootMotionRot = Quaternion.Identity; CurrentStyle = 0; CurrentMotion = 0; CurrentSpeedMod = 1f; - CurrentVelocity = Vector3.Zero; - CurrentOmega = Vector3.Zero; } // ── Private helpers ────────────────────────────────────────────────────── + /// + /// Host seam wiring the core's into this + /// adapter's list. Mirrors retail's + /// CPhysicsObj.anim_hooks SmartArray queue-then-drain model (gap + /// map G6): the core QUEUES matched hooks here during + /// ; + /// drains them at the render-tick call site (GameWindow), same as + /// pre-cutover. The AnimDone hook maps to the existing + /// singleton so downstream sinks + /// (AnimationHookRouter) see the same instance type they always have. + /// + private sealed class AdapterHookQueue : IAnimHookQueue + { + private readonly AnimationSequencer _owner; + public AdapterHookQueue(AnimationSequencer owner) => _owner = owner; + + public void AddAnimHook(AnimationHook hook) => _owner._pendingHooks.Add(hook); + + public void AddAnimDoneHook() => _owner._pendingHooks.Add(AnimationDoneSentinel); + } + // 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 = @@ -1203,222 +1005,90 @@ public sealed class AnimationSequencer } /// - /// Load an Animation from the dat by its - /// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames"). + /// Build one from an , + /// speed-scaled retail-style (only Framerate scales — retail + /// AnimData::operator*, 0x00525d00). Returns a node whose + /// may be false (unresolved dat + /// id); callers filter those out. /// - private AnimNode? LoadAnimNode( - AnimData ad, - float speedMod, - bool isLooping, - Vector3 velocity, - Vector3 omega) + private AnimSequenceNode BuildNode(AnimData ad, float speedMod) { - 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; + var scaled = new AnimData + { + AnimId = ad.AnimId, + LowFrame = ad.LowFrame, + HighFrame = ad.HighFrame, + Framerate = ad.Framerate * speedMod, + }; + return new AnimSequenceNode(scaled, _loader); } /// /// 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. + /// core's animation list (retail free function add_motion, + /// 0x005224b0). Each AnimData becomes one + /// via , which slides + /// first_cyclic to the just-appended node on EVERY call (G10) — + /// so a multi-anim MotionData's loop membership is naturally "only the + /// LAST AnimData loops" once a subsequent MotionData is appended, and a + /// single MotionData with N AnimData entries makes the WHOLE MotionData + /// the cyclic tail as of its last entry. /// - private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping) + /// The MotionData to enqueue. + /// Speed multiplier for AnimData framerate scaling. + /// + /// The isLooping parameter from the pre-cutover signature is + /// deleted — retail has no per-call loop flag (G10); loop membership is + /// purely a function of append order via first_cyclic. + /// + private void EnqueueMotionData(MotionData motionData, float speedMod) { - 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. + // Sequence-wide velocity/omega: retail add_motion (0x005224b0) calls + // set_velocity/set_omega UNCONDITIONALLY (G17 core semantics — a + // MotionData without HasVelocity carries a zero Velocity field, so + // "unconditional replace" and "replace with zero" are the same + // retail call). The adapter keeps today's HasVelocity/HasOmega GATE + // (G17 adapter split) so a transient zero-velocity modifier doesn't + // stomp a running cycle's velocity — this is the documented R2 + // stand-in until modifiers route through combine_motion instead of + // add_motion. See gap map G17. if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)) - CurrentVelocity = vel; + _core.SetVelocity(motionData.Velocity * speedMod); if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega)) - CurrentOmega = omg; + _core.SetOmega(motionData.Omega * speedMod); - for (int i = 0; i < motionData.Anims.Count; i++) + foreach (var ad in motionData.Anims) { - 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) + // Speed-scale (retail AnimData::operator*, 0x00525d00 — ONLY + // framerate scales) then hand the scaled AnimData straight to + // append_animation; the core resolves + clamps via + // AnimSequenceNode.SetAnimationId and silently discards nodes + // whose dat id doesn't resolve (mirrors BuildNode's HasAnim + // filter without constructing a throwaway node here). + _core.AppendAnimation(new AnimData { - _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); + AnimId = ad.AnimId, + LowFrame = ad.LowFrame, + HighFrame = ad.HighFrame, + Framerate = ad.Framerate * speedMod, + }); } } /// /// 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. + /// Blends between floor(FrameNumber) and floor(FrameNumber)+1 within the + /// CURRENT core node, using the fractional part of FrameNumber. + /// + /// + /// R1-P5: this is render-side interpolation, NOT CSequence scope (gap map + /// G19's "blend seam MED" note) — the core only tracks the floored + /// current frame (get_curr_animframe). The +1 index is clamped to + /// the node's /LowFrame window + /// (direction-aware) to preserve the pre-cutover #61 fix (link-tail + /// holds its end pose instead of blending into frame 0 of the next + /// node). + /// /// /// Uses the retail-client slerp () for /// quaternion interpolation and linear lerp for position. @@ -1427,43 +1097,46 @@ public sealed class AnimationSequencer { int partCount = _setup.Parts.Count; - if (_currNode == null) + var curr = _core.CurrAnim; + if (curr is null || curr.Anim is 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); + // Clamp frameIndex to the node's valid window. + int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame); + int rangeHi = Math.Max(curr.LowFrame, curr.HighFrame); rangeHi = Math.Min(rangeHi, numPartFrames - 1); + rangeLo = Math.Max(rangeLo, 0); - int frameIdx = (int)Math.Floor(_framePosition); + int frameIdx = (int)Math.Floor(_core.FrameNumber); frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi); // Next frame for interpolation: step in the playback direction. - // Wrap to opposite end ONLY for looping cyclic nodes. For one-shot - // nodes (link transitions, action overlays), hold the boundary - // frame instead — otherwise the fractional tail of the anim - // blends frame[end] with frame[0], producing a brief flash through - // the anim's starting pose at the link→cycle boundary (issue #61: - // door swing-open flap; run-stop twitch). + // Wrap to the opposite end ONLY when this node IS the cyclic tail + // (curr == first_cyclic). For one-shot nodes (link transitions, + // action overlays), hold the boundary frame instead — otherwise the + // fractional tail of the anim blends frame[end] with frame[0], + // producing a brief flash through the anim's starting pose at the + // link→cycle boundary (issue #61: door swing-open flap; run-stop + // twitch). This is render-side clamping, not a CSequence semantic. + bool nodeIsCyclic = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic); int nextIdx; - if (curr.Framerate >= 0.0) + if (curr.Framerate >= 0f) { nextIdx = frameIdx + 1; if (nextIdx > rangeHi || nextIdx >= numPartFrames) - nextIdx = curr.IsLooping ? rangeLo : frameIdx; + nextIdx = nodeIsCyclic ? rangeLo : frameIdx; } else { nextIdx = frameIdx - 1; if (nextIdx < rangeLo) - nextIdx = curr.IsLooping ? rangeHi : frameIdx; + nextIdx = nodeIsCyclic ? rangeHi : frameIdx; } // Fractional blend weight (always in [0, 1]). - double rawT = _framePosition - Math.Floor(_framePosition); + double rawT = _core.FrameNumber - Math.Floor(_core.FrameNumber); float t = (float)Math.Clamp(rawT, 0.0, 1.0); var f0Parts = curr.Anim.PartFrames[frameIdx].Frames; diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index e911d21d..2e29abb9 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -614,12 +614,19 @@ public sealed class AnimationSequencerTests // CurrentMotion should record the original TurnLeft command. Assert.Equal(TurnLeft, seq.CurrentMotion); - // Without swap: StartFrame=0, EndFrame=3 (original range preserved). - // GetStartFramePosition for negative speed = (EndFrame+1)-eps = (3+1)-eps ≈ 3.99999. - // The cursor starts near the HIGH end and counts DOWN toward StartFrame(=0). + // R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated + // epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's + // AnimSequenceNode.GetStartingFrame (0x00525c80) returns a BARE INT + // for reverse playback: HighFrame + 1 (gap map G1 — "NO epsilon"). + // LowFrame=0, HighFrame=3 (no swap at append time; the swap only + // happens inside MultiplyFramerate for an in-place resign, which + // this path doesn't take), so the retail-exact start position is + // exactly 4.0, not "near but under" 4.0. The cursor starts at the + // boundary and counts DOWN toward LowFrame(=0) on the next Advance. double pos = GetFramePosition(seq); - Assert.True(pos > 3.9 && pos < 4.0, - $"Expected framePosition near 3.99999 (reverse start near EndFrame+1) but got {pos}"); + Assert.True(pos == 4.0, + $"Expected framePosition == 4 (bare-int reverse start = HighFrame+1, " + + $"retail AnimSequenceNode.GetStartingFrame 0x00525c80 has NO epsilon — G1); got {pos}"); } [Fact] @@ -990,13 +997,29 @@ public sealed class AnimationSequencerTests Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u); } - // ── PosFrames root motion (Phase E.1) ──────────────────────────────────── + // ── PosFrames root motion (Phase E.1; retired R1-P5) ───────────────────── [Fact] - public void Advance_WithPosFrames_AccumulatesRootMotion() + public void ConsumeRootMotionDelta_AlwaysZero_AccumulatorUnwiredPendingP6() { - // Animation with PosFrames flag and per-frame origin deltas should - // surface a non-zero root motion delta after Advance. + // R1-P5 (2026-07-02): pre-cutover, Advance's own hand-rolled + // per-frame-crossing loop wrote _rootMotionPos/_rootMotionRot + // directly from Animation.PosFrames (AFrame.Combine/Subtract). That + // loop is DELETED — frame advance now runs entirely inside + // CSequence.Update, and root motion in retail flows through + // CSequence.apply_physics(Frame*, ...) (0x00524ab0), which needs an + // actual target Frame passed into Update. This adapter's Advance + // passes null (gap map G7: "R1-P6 wires it"), so PosFrames deltas + // are computed by the core's UpdateInternal but have nowhere to + // land — ConsumeRootMotionDelta has zero live writers post-cutover + // (confirmed: also zero external callers pre-cutover, per the R1 + // gap map API-migration table). + // + // This test pins that ConsumeRootMotionDelta is a safe, inert stub + // until P6 wires a real Frame through Advance/Update — NOT that + // root motion doesn't work. When P6 lands, this test should be + // replaced with one asserting real accumulation through the wired + // Frame path. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000110u; @@ -1022,17 +1045,13 @@ public sealed class AnimationSequencerTests seq.SetCycle(Style, Motion); seq.ConsumeRootMotionDelta(); // clear - // Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied. + // Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2). Pre-cutover + // this would have accumulated ~2.0 on X; post-cutover it's inert. seq.Advance(0.25f); - var (pos, _) = seq.ConsumeRootMotionDelta(); + var (pos, rot) = seq.ConsumeRootMotionDelta(); - // Each crossing adds +X origin → total X should be 2. - Assert.True(pos.X >= 1.8f && pos.X <= 2.2f, - $"Expected ~2.0 root motion X after 2 crossings, got {pos.X}"); - - // A subsequent consume with no advance should return zero (drained). - var (pos2, _) = seq.ConsumeRootMotionDelta(); - Assert.Equal(Vector3.Zero, pos2); + Assert.Equal(Vector3.Zero, pos); + Assert.Equal(Quaternion.Identity, rot); } [Fact] @@ -1624,14 +1643,27 @@ public sealed class AnimationSequencerTests // ── Helpers ────────────────────────────────────────────────────────────── - /// Expose _framePosition (double) via reflection (test-only). + /// + /// Expose the core CSequence's FrameNumber via reflection (test-only). + /// R1-P5 rehost (2026-07-02): _framePosition lived directly on + /// AnimationSequencer pre-cutover; it now lives on the private _core + /// (CSequence) field as the public FrameNumber. Two-hop reflection: + /// grab _core, then its FrameNumber field. + /// private static double GetFramePosition(AnimationSequencer seq) { - var field = typeof(AnimationSequencer) - .GetField("_framePosition", + var coreField = typeof(AnimationSequencer) + .GetField("_core", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - return field is null ? -1.0 : (double)field.GetValue(seq)!; + var core = coreField?.GetValue(seq); + if (core is null) return -1.0; + + var frameNumberField = core.GetType() + .GetField("FrameNumber", + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.Instance); + return frameNumberField is null ? -1.0 : (double)frameNumberField.GetValue(core)!; } ///