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>
1554 lines
68 KiB
C#
1554 lines
68 KiB
C#
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 5–7).
|
||
/// 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 (~100–300 ms at
|
||
// Framerate 30 × link runSpeed) gets restarted before it can
|
||
// end if the user toggles Shift faster than that. _currNode
|
||
// sits on a fresh link every UM and Advance never reaches
|
||
// the cycle. User observes "blips forward in walking
|
||
// animation" — what they're seeing is the link's
|
||
// interpolation pose, never the new cycle.
|
||
//
|
||
// Conditional on BOTH old AND new being locomotion cycles to
|
||
// avoid regressing the cases where the link IS the right
|
||
// animation:
|
||
// - Idle (Ready) → any cycle: link is the wind-up pose
|
||
// - Falling → Ready: landing animation
|
||
// - Ready → Sitting/Crouching: pose-change links
|
||
// - Combat substates (attack/parry/ready transitions)
|
||
// Commit c06b6c5 (reverted in a2ae2ae) demonstrated that
|
||
// unconditionally skipping the link breaks all of these.
|
||
//
|
||
// Retail reference: cdb live trace 2026-05-03 of a Walk→Run
|
||
// direct transition logged
|
||
// add_to_queue(45000005, looping=1) walk
|
||
// add_to_queue(44000007, looping=1) run
|
||
// with truncate_animation_list never firing — i.e. retail
|
||
// appends the new cycle directly without a separate link
|
||
// enqueue or visible link pose for cyclic→cyclic. Our
|
||
// structural mismatch was always enqueueing link+cycle and
|
||
// forcing _currNode onto the link; this fix matches retail's
|
||
// observed semantics for the locomotion subset.
|
||
bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu);
|
||
bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu);
|
||
if (prevIsLocomotion && newIsLocomotion && _firstCyclic is not null)
|
||
{
|
||
// Drop the just-enqueued link node (firstNew) from the
|
||
// queue if it's distinct from the cycle — nothing should
|
||
// ever play it, and leaving stale non-cyclic nodes ahead
|
||
// of _currNode contributes to the unbounded queue growth
|
||
// observed in [SCFULL] (qCount climbing past 49 over
|
||
// ~30 transitions).
|
||
if (firstNew is not null && firstNew != _firstCyclic)
|
||
{
|
||
_queue.Remove(firstNew);
|
||
}
|
||
_currNode = _firstCyclic;
|
||
_framePosition = _firstCyclic.Value.GetStartFramePosition();
|
||
}
|
||
else if (firstNew is not null)
|
||
{
|
||
_currNode = firstNew;
|
||
_framePosition = _currNode.Value.GetStartFramePosition();
|
||
}
|
||
else if (_currNode == null)
|
||
{
|
||
// Defensive fallback: nothing newly added AND no current node.
|
||
_currNode = _queue.First;
|
||
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
|
||
|
||
// D4 (Commit A 2026-05-03): SCNULLFALLBACK — proves whether the
|
||
// null-data fallback is being hit. If this fires during a
|
||
// Walk→Run transition for the watched remote, H4 (MotionTable
|
||
// GetLink/GetCycle returns null for the remote's setup) is the
|
||
// bug. linkData/cycleData null almost certainly means a
|
||
// MotionTable lookup gap for that style+motion combo.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[SCNULLFALLBACK] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} "
|
||
+ $"linkNull={(linkData is null)} cycleNull={(cycleData is null)} "
|
||
+ $"qCount={_queue.Count}");
|
||
}
|
||
}
|
||
|
||
// D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on
|
||
// the full-rebuild SetCycle path. Throttled to 0.5s per instance.
|
||
// Logs prev CurrentMotion so the line shows the transition directly
|
||
// (e.g. "Run → Ready" = cycle just got reset).
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
if (nowSec - _lastSetCycleDiagTime > 0.5)
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} "
|
||
+ $"speedMod={speedMod:F3} "
|
||
+ $"qCount={_queue.Count} "
|
||
+ $"firstNewNull={(firstNew is null)} "
|
||
+ $"currNodeIsCyclic={(_currNode == _firstCyclic)} "
|
||
+ $"firstCyclicNull={(_firstCyclic is null)}");
|
||
_lastSetCycleDiagTime = nowSec;
|
||
}
|
||
}
|
||
|
||
CurrentStyle = style;
|
||
CurrentMotion = motion;
|
||
CurrentSpeedMod = speedMod;
|
||
|
||
// ── Synthesize CurrentVelocity for locomotion cycles ──────────────
|
||
// The Humanoid motion table ships every locomotion MotionData with
|
||
// Flags=0x00 (no HasVelocity), so EnqueueMotionData leaves
|
||
// CurrentVelocity at Vector3.Zero. That matches the literal retail
|
||
// dat, but retail's body physics uses CMotionInterp::get_state_velocity
|
||
// (FUN_00528960) which returns RunAnimSpeed × ForwardSpeed for
|
||
// RunForward, independent of the dat's HasVelocity flag. The dat
|
||
// velocity is a separate additive source (kick-off velocity, flying
|
||
// creatures, etc) not the primary locomotion drive.
|
||
//
|
||
// For our sequencer's <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 << 16) | (motion & 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<<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<<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<int, MotionCommandData>
|
||
/// where MotionCommandData.MotionData is Dictionary<int, MotionData>.
|
||
/// </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 < 0, negate q2 (choose the shorter arc).</item>
|
||
/// <item>If 1 - dot <= 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);
|
||
}
|
||
}
|