acdream/src/AcDream.Core/Physics/AnimationSequencer.cs
Erik bb026b7991 diag(motion): #39 — per-tick [CURRNODE] for sequencer node identity
Visual-verify of fix #2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:

- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
  to walking

So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
  (e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode

Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).

The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.

No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:17:56 +02:00

1554 lines
68 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Minimal interface for resolving Animation objects by id.
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
/// </summary>
public interface IAnimationLoader
{
/// <summary>Load an Animation by its dat id, or return null.</summary>
Animation? LoadAnimation(uint id);
}
/// <summary>
/// Production implementation of <see cref="IAnimationLoader"/> backed by
/// a <see cref="DatCollection"/>.
/// </summary>
public sealed class DatCollectionLoader : IAnimationLoader
{
private readonly DatCollection _dats;
public DatCollectionLoader(DatCollection dats) => _dats = dats;
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(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<int, MotionCommandData>
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
// MotionCommandData.MotionData : Dictionary<int, MotionData>
// key = target motion (int cast of MotionCommand)
// MotionData.Anims : List<AnimData>
// MotionData.Velocity / MotionData.Omega : Vector3 (world-space physics)
// MotionData.Flags : MotionDataFlags (HasVelocity=0x01, HasOmega=0x02)
// AnimData.AnimId : QualifiedDataId<Animation>
// Animation.PartFrames : List<AnimationFrame>
// Animation.PosFrames : List<Frame> (root motion, present if Flags & PosFrames)
// Animation.Flags : AnimationFlags (PosFrames = 0x01)
// AnimationFrame.Frames : List<Frame>
// AnimationFrame.Hooks : List<AnimationHook>
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Per-part world-local transform produced by <see cref="AnimationSequencer.Advance"/>.
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
/// </summary>
public readonly struct PartTransform
{
public readonly Vector3 Origin;
public readonly Quaternion Orientation;
public PartTransform(Vector3 origin, Quaternion orientation)
{
Origin = origin;
Orientation = orientation;
}
}
/// <summary>
/// One entry in the animation queue (link transition or looping cycle).
///
/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18.
/// Carries the parent <see cref="DatReaderWriter.Types.MotionData"/>'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).
/// </summary>
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;
}
/// <summary>
/// Full animation playback engine for one entity.
///
/// <para>
/// This is a faithful port of the retail AC client's Sequence object
/// (docs/research/acclient_animation_pseudocode.md, sections 57).
/// Key invariants:
/// <list type="bullet">
/// <item><description>
/// <c>_framePosition</c> is a <c>double</c> matching the retail client's
/// 64-bit field at Sequence+0x30.
/// </description></item>
/// <item><description>
/// Negative framerate means reverse playback.
/// </description></item>
/// <item><description>
/// When a node's frames are exhausted, <c>advance_to_next_animation</c>
/// wraps to <c>_firstCyclic</c> (the looping tail of the queue).
/// </description></item>
/// <item><description>
/// Every integer frame boundary crossed in a tick fires the hooks at
/// that frame whose <see cref="AnimationHookDir"/> matches the playback
/// direction (or <c>Both</c>). Mirrors ACE Sequence.execute_hooks.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Usage pattern:
/// <code>
/// 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
/// </code>
/// </para>
/// </summary>
public sealed class AnimationSequencer
{
// ── Public state ─────────────────────────────────────────────────────────
/// <summary>Current style (stance) command.</summary>
public uint CurrentStyle { get; private set; }
/// <summary>Current cyclic motion command.</summary>
public uint CurrentMotion { get; private set; }
/// <summary>
/// Speed multiplier currently applied to the cyclic tail. Starts at 1.0
/// and is updated by <see cref="SetCycle"/> when the same motion is
/// re-issued with a different speed (which triggers
/// <see cref="MultiplyCyclicFramerate"/> instead of a cycle restart).
/// </summary>
public float CurrentSpeedMod { get; private set; } = 1f;
/// <summary>
/// Sequence-wide velocity mirror of ACE's <c>Sequence.Velocity</c> field.
/// Updated each time a MotionData is appended or combined — reflects the
/// MOST RECENT MotionData's velocity × speedMod, matching
/// <c>Sequence.SetVelocity</c> semantics (ACE Sequence.cs L127-L130,
/// <c>MotionTable.add_motion</c> L358-L370).
///
/// <para>
/// 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.
/// </para>
/// </summary>
public Vector3 CurrentVelocity { get; private set; }
/// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// </summary>
public Vector3 CurrentOmega { get; private set; }
// Diagnostics
public int QueueCount => _queue.Count;
public bool HasCurrentNode => _currNode != null;
/// <summary>
/// Diagnostic snapshot of <c>_currNode</c>'s identity + frame state, for
/// the per-tick CURRNODE log line in <c>GameWindow.TickAnimations</c>.
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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 <see cref="CurrentNodeDiag"/>'s AnimRefHash to see
/// whether <c>_currNode</c> is actually pointing at the new cycle or
/// something stale.
/// </summary>
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<AnimNode> _queue = new();
private LinkedListNode<AnimNode>? _currNode;
private LinkedListNode<AnimNode>? _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<AnimationHook> _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 ──────────────────────────────────────────────────────────
/// <summary>
/// Create a sequencer for one entity.
/// </summary>
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
/// <param name="loader">
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production,
/// or inject a test double in unit tests.
/// </param>
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 ───────────────────────────────────────────────────────────
/// <summary>
/// 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.
///
/// <para>
/// Implements <c>adjust_motion</c> (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.
/// </para>
/// </summary>
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
/// <param name="skipTransitionLink">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.</param>
/// <summary>
/// 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. <c>WalkForward</c> →
/// <c>Ready</c>) instead of triggering <see cref="SetCycle"/>'s
/// unconditional <c>ClearCyclicTail</c> 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).
/// </summary>
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 (~100300 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 <see cref="CurrentVelocity"/> 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 <see cref="MotionInterpreter.RunAnimSpeed"/> 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;
/// <summary>
/// Scale every cyclic node's framerate by <paramref name="factor"/>, mirroring
/// ACE's <c>Sequence.multiply_cyclic_animation_framerate</c>
/// (<c>references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs</c> L277-L287,
/// retail decompile <c>FUN_00525CE0</c>). Walks <c>_firstCyclic</c> through
/// the tail of the queue and calls <see cref="AnimNode.MultiplyFramerate"/>
/// 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.
///
/// <para>
/// Called from <see cref="SetCycle"/> 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.
/// </para>
/// </summary>
/// <param name="factor">Framerate multiplier (newSpeed / oldSpeed).</param>
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;
}
/// <summary>
/// Advance the animation by <paramref name="dt"/> seconds and return the
/// per-part transforms for the current blended keyframe.
///
/// <para>
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0 / ACE
/// Sequence.cs:351-443): walks every integer frame boundary crossed in
/// this tick, calls <c>execute_hooks</c> for each with the playback
/// direction, and accumulates <see cref="Animation.PosFrames"/> root
/// motion into the pending delta. Hooks fire only once per crossing
/// regardless of framerate scaling.
/// </para>
///
/// <para>
/// Crossing semantics (forward): as <c>floor(framePos)</c> increments
/// from <c>i</c> to <c>i+1</c>, hooks attached to frame <c>i</c> with
/// direction <c>Forward</c> or <c>Both</c> fire. Reverse: as
/// <c>floor(framePos)</c> decrements from <c>i</c> to <c>i-1</c>,
/// hooks with direction <c>Backward</c> or <c>Both</c> fire on frame
/// <c>i</c>.
/// </para>
/// </summary>
/// <param name="dt">Elapsed time in seconds since the last call.</param>
/// <returns>
/// One <see cref="PartTransform"/> per part in the Setup, in part order.
/// If no animation is loaded, all parts get identity transforms.
/// </returns>
public IReadOnlyList<PartTransform> 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();
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<AnimationHook> ConsumePendingHooks()
{
if (_pendingHooks.Count == 0)
return Array.Empty<AnimationHook>();
var result = _pendingHooks.ToArray();
_pendingHooks.Clear();
return result;
}
/// <summary>
/// Retrieve and clear the root-motion displacement accumulated from
/// <see cref="Animation.PosFrames"/> during the last <see cref="Advance"/>
/// 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.
/// </summary>
public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()
{
var result = (_rootMotionPos, _rootMotionRot);
_rootMotionPos = Vector3.Zero;
_rootMotionRot = Quaternion.Identity;
return result;
}
/// <summary>
/// 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.
///
/// <para>
/// Retail semantics: actions and modifiers live in
/// <see cref="MotionTable.Modifiers"/> (a separate dict from
/// <see cref="MotionTable.Cycles"/>) keyed by
/// <c>(style &lt;&lt; 16) | (motion &amp; 0xFFFFFF)</c>. A motion like
/// <c>Jump = 0x2500003b</c> is a Modifier (class byte 0x25) not a
/// SubState — feeding it to <see cref="SetCycle"/> silently fails the
/// cycle lookup. Routing through <c>PlayAction</c> instead resolves
/// from the Modifiers table and interleaves the action frames with
/// the ongoing cyclic motion.
/// </para>
///
/// <para>
/// If no entry is found in the Modifiers table for the requested
/// motion, this is a no-op.
/// </para>
/// </summary>
/// <param name="motionCommand">Raw MotionCommand (e.g. 0x2500003b for Jump).</param>
/// <param name="speedMod">Speed multiplier for the action's framerate.</param>
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<AnimNode>(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<AnimNode>? 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();
}
}
/// <summary>
/// Reset the sequencer to an unplaying state without clearing the
/// motion table reference.
/// </summary>
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 };
/// <summary>
/// Look up the transition MotionData for going from <paramref name="substate"/>
/// (current state, played at <paramref name="substateSpeed"/>) to
/// <paramref name="motion"/> (new state, played at <paramref name="speed"/>).
///
/// <para>
/// 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:
/// <list type="bullet">
/// <item><b>Both speeds positive</b> (forward → forward, normal case):
/// Look up Links[(style&lt;&lt;16) | substate][motion] — the link FROM
/// substate TO motion. Played forward.</item>
/// <item><b>Either speed negative</b> (any direction reversal —
/// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key
/// Links[(style&lt;&lt;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.</item>
/// </list>
/// </para>
///
/// DatReaderWriter encodes Links as Dictionary&lt;int, MotionCommandData&gt;
/// where MotionCommandData.MotionData is Dictionary&lt;int, MotionData&gt;.
/// </summary>
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;
}
/// <summary>
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private void ClearPhysics()
{
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> 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.
/// </summary>
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);
}
}
/// <summary>
/// Remove all cyclic (looping) nodes from the tail of the queue starting
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain so they
/// can drain naturally.
/// </summary>
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;
}
/// <summary>
/// Move <see cref="_currNode"/> to the next node in the queue, or wrap
/// back to <see cref="_firstCyclic"/> when the queue is exhausted.
///
/// Implements <c>FUN_00525EB0</c> (Sequence::advance_to_next_animation).
/// The retail client walks a doubly-linked list; we mirror that with
/// LinkedList.Next plus the _firstCyclic wrap sentinel.
/// </summary>
private void AdvanceToNextAnimation()
{
if (_currNode == null) return;
LinkedListNode<AnimNode>? 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();
}
/// <summary>
/// Dispatch any hooks on the given part frame whose direction matches
/// the playback direction (or <c>Both</c>). Mirrors ACE's
/// <c>Sequence.execute_hooks</c> (Sequence.cs:262).
/// </summary>
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);
}
}
}
/// <summary>
/// Apply the <see cref="Animation.PosFrames"/> (root motion) delta for
/// <paramref name="frameIndex"/> to the accumulated pending delta.
/// Mirrors ACE's <c>AFrame.Combine</c> (forward) / <c>frame.Subtract</c>
/// (backward) calls in <c>update_internal</c>.
/// </summary>
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);
}
}
/// <summary>
/// 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 (<see cref="SlerpRetailClient"/>) for
/// quaternion interpolation and linear lerp for position.
/// </summary>
private IReadOnlyList<PartTransform> 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<PartTransform> 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;
}
/// <summary>
/// True if the given motion-low-byte names a locomotion cycle —
/// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07),
/// SideStepRight (0x0F), or SideStepLeft (0x10).
/// Used by <see cref="SetCycle"/> to recognise cyclic→cyclic
/// direct transitions and bypass the transition link in that case
/// (retail's observed add_to_queue semantics).
/// </summary>
private static bool IsLocomotionCycleLowByte(uint lowByte)
{
return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u
|| lowByte == 0x0Fu || lowByte == 0x10u;
}
/// <summary>
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>
/// (chunk_00530000.c:4799-4846):
/// <list type="number">
/// <item>Compute dot product of q1 and q2.</item>
/// <item>If dot &lt; 0, negate q2 (choose the shorter arc).</item>
/// <item>If 1 - dot &lt;= epsilon, fall back to (1-t)*q1 + t*q2 (linear).</item>
/// <item>Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).</item>
/// <item>Validate result lies in [0,1]²; if not, fall back to linear.</item>
/// </list>
/// 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.
/// </summary>
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);
}
}