feat(anim): AnimationSequencer with transition links + retail slerp

Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.

## What this adds

**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):

- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
  AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
  wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.

- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
  `FUN_005360d0` (chunk_00530000.c:4799-4846):
    1. dot-product sign-flip to take the shorter arc
    2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
    3. sin-based slerp for all other cases
    4. validate weights lie in [0,1] before using sin result (retail
       client validation step that guards degenerate inputs)

- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
  mirrors ACE's `MotionTable.get_link` positive-speed path.
  DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
  `MotionCommandData` whose `.MotionData[toMotion]` is the transition
  `MotionData`. Link frames are prepended before the cyclic tail, so
  idle->walk plays the short transition clip then loops the walk cycle.

- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
  sequencer is testable offline without opening dat files.

- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
  returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).

**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.

317 tests green, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 00:22:42 +02:00
parent 14569558fb
commit f48f2745c4
2 changed files with 1003 additions and 0 deletions

View file

@ -0,0 +1,563 @@
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 — per-entity animation playback with transition links.
//
// Decompiled references:
// FUN_005360d0 (chunk_00530000.c:4799) — quaternion slerp with dot-product
// sign-flip and lerp fallback for near-parallel quaternions.
// Sequence.update_internal (ACE Sequence.cs) — frame advance: frameNum +=
// framerate*dt, test against high/low, fire hooks at each crossed
// integer frame boundary, advance to next anim when done.
// MotionTable.get_link (ACE MotionTable.cs:395) — transition lookup:
// Links[(style<<16)|(fromSubstate&0xFFFFFF)].TryGetValue(toMotion).
//
// 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>
// AnimData.AnimId : QualifiedDataId<Animation>
// Animation.PartFrames : List<AnimationFrame>
// AnimationFrame.Frames : List<Frame>
// 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).
/// </summary>
internal sealed class AnimNode
{
public Animation Anim;
public float Framerate; // signed; negative means reverse
public int LowFrame;
public int HighFrame;
public bool IsLooping; // true only for the tail cyclic node
public AnimNode(Animation anim, float framerate, int lowFrame, int highFrame, bool isLooping)
{
Anim = anim;
Framerate = framerate;
LowFrame = lowFrame;
HighFrame = highFrame;
IsLooping = isLooping;
}
public float StartingFrame => Framerate >= 0f ? LowFrame : HighFrame + 1 - 1e-5f;
public float EndingFrame => Framerate >= 0f ? HighFrame + 1 - 1e-5f : LowFrame;
}
/// <summary>
/// Full animation playback engine for one entity.
///
/// <para>
/// Usage pattern:
/// <code>
/// var seq = new AnimationSequencer(setup, motionTable, dats);
/// seq.SetCycle(style, motion, speedMod);
/// // each frame:
/// var transforms = seq.Advance(dt);
/// // rebuild MeshRefs from transforms
/// </code>
/// </para>
///
/// <para>
/// When <see cref="SetCycle"/> is called with a new motion, the sequencer
/// looks up a transition link in the MotionTable and prepends those frames
/// to the queue so the entity blends smoothly instead of snapping. The
/// cyclic tail of the queue loops forever.
/// </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; }
// ── 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;
private float _frameNum;
private const float Epsilon = 1e-5f;
// ── 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 (same as the old snap behaviour).
/// </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>
public void SetCycle(uint style, uint motion, float speedMod = 1f)
{
// Fast-path: already playing this exact motion at the same speed.
if (CurrentStyle == style && CurrentMotion == motion
&& _firstCyclic != null && _queue.Count > 0)
return;
// Resolve transition link (currentSubstate → newMotion).
MotionData? linkData = CurrentMotion != 0
? GetLink(style, CurrentMotion, motion)
: null;
// Resolve target cycle.
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (motion & 0xFFFFFFu));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
// Clear the old cyclic tail; keep any non-cyclic head that hasn't
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail();
// Enqueue link frames.
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, speedMod, isLooping: false);
// Enqueue new cycle.
if (cycleData is { Anims.Count: > 0 })
{
EnqueueMotionData(cycleData, speedMod, isLooping: true);
}
else if (_queue.Count == 0)
{
// No cycle and no link — nothing to play; reset fully.
_currNode = null;
_firstCyclic = null;
_frameNum = 0f;
CurrentStyle = style;
CurrentMotion = motion;
return;
}
// Mark the first cyclic node (the tail after all link frames).
// If there were no link frames, the first enqueued node is cyclic.
// Re-scan from the end to find the first IsLooping node.
_firstCyclic = null;
for (var n = _queue.First; n != null; n = n.Next)
{
if (n.Value.IsLooping)
{
_firstCyclic = n;
break;
}
}
// If we have no current anim, start at the beginning of the queue.
if (_currNode == null)
{
_currNode = _queue.First;
_frameNum = _currNode?.Value.StartingFrame ?? 0f;
}
CurrentStyle = style;
CurrentMotion = motion;
}
/// <summary>
/// Advance the animation by <paramref name="dt"/> seconds and return the
/// per-part transforms for the current blended keyframe.
///
/// <para>
/// The slerp algorithm mirrors the decompiled retail client's
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799):
/// compute dot product; if negative, negate q2 and dot; if the angle is
/// very small, fall back to linear (1-t, t) instead of sin-based slerp.
/// </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);
var curr = _currNode.Value;
float framerate = curr.Framerate;
float frametime = framerate * dt;
bool animDone = false;
float timeRemainder = 0f;
_frameNum += frametime;
if (frametime > 0f)
{
if (_frameNum > curr.HighFrame + 1 - Epsilon)
{
timeRemainder = Math.Abs(framerate) > Epsilon
? (_frameNum - (curr.HighFrame + 1 - Epsilon)) / framerate
: 0f;
_frameNum = curr.HighFrame;
animDone = true;
}
}
else if (frametime < 0f)
{
if (_frameNum < curr.LowFrame)
{
timeRemainder = Math.Abs(framerate) > Epsilon
? (_frameNum - curr.LowFrame) / framerate
: 0f;
_frameNum = curr.LowFrame;
animDone = true;
}
}
if (animDone)
AdvanceToNextAnimation();
// Build the blended frame.
return BuildBlendedFrame();
}
/// <summary>
/// Reset the sequencer to an unplaying state without clearing the
/// motion table reference.
/// </summary>
public void Reset()
{
_queue.Clear();
_currNode = null;
_firstCyclic = null;
_frameNum = 0f;
CurrentStyle = 0;
CurrentMotion = 0;
}
// ── Private helpers ──────────────────────────────────────────────────────
/// <summary>
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
///
/// Port of ACE's MotionTable.get_link (positive-speed path):
/// 1. Try Links[(style&lt;&lt;16)|(fromMotion&amp;0xFFFFFF)][toMotion]
/// 2. Fallback: try Links[style&lt;&lt;16][toMotion]
///
/// 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 fromMotion, uint toMotion)
{
int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1))
{
if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1))
return result1;
}
// Fallback: style-level catch-all.
int outerKey2 = (int)(style << 16);
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2))
{
if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2))
return result2;
}
return null;
}
/// <summary>
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
/// Mirrors ACE AnimSequenceNode.set_animation_id.
/// </summary>
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
{
uint animId = (uint)ad.AnimId;
if (animId == 0) return null;
var anim = _loader.LoadAnimation(animId);
if (anim is null || anim.PartFrames.Count == 0) return null;
int numFrames = anim.PartFrames.Count;
int low = ad.LowFrame;
int high = ad.HighFrame;
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
if (high < 0) high = numFrames - 1;
if (low >= numFrames) low = numFrames - 1;
if (high >= numFrames) high = numFrames - 1;
if (low < 0) low = 0;
if (low > high) high = low;
float fr = ad.Framerate * speedMod;
return new AnimNode(anim, fr, low, high, isLooping);
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// queue. Each AnimData becomes one AnimNode.
/// </summary>
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
{
for (int i = 0; i < motionData.Anims.Count; i++)
{
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling);
if (node != null)
_queue.AddLast(node);
}
}
/// <summary>
/// Remove all cyclic (looping) nodes from the tail of the queue, starting
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain.
/// </summary>
private void ClearCyclicTail()
{
if (_firstCyclic == null) return;
var node = _firstCyclic;
while (node != null)
{
var next = node.Next;
// If CurrAnim is being removed, jump it to the previous non-cyclic node.
if (_currNode == node)
{
_currNode = node.Previous;
if (_currNode != null)
_frameNum = _currNode.Value.EndingFrame;
else
_frameNum = 0f;
}
_queue.Remove(node);
node = next;
}
_firstCyclic = null;
}
/// <summary>
/// Move <see cref="_currNode"/> to the next node in the queue, or loop
/// back to <see cref="_firstCyclic"/> if at the end. Mirrors ACE's
/// <c>advance_to_next_animation</c>.
/// </summary>
private void AdvanceToNextAnimation()
{
if (_currNode == null) return;
if (_currNode.Next != null)
_currNode = _currNode.Next;
else if (_firstCyclic != null)
_currNode = _firstCyclic;
// else: end of non-looping sequence — stay on last frame.
if (_currNode != null)
_frameNum = _currNode.Value.StartingFrame;
}
/// <summary>
/// Build the per-part blended transform from the current animation frame.
/// Blends between floor(frameNum) and floor(frameNum)+1 using the
/// fractional part of frameNum.
/// </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;
int frameIdx = (int)Math.Floor(_frameNum);
frameIdx = Math.Clamp(frameIdx, curr.LowFrame, Math.Min(curr.HighFrame, numPartFrames - 1));
int nextIdx = frameIdx + 1;
if (nextIdx > curr.HighFrame || nextIdx >= numPartFrames)
nextIdx = curr.LowFrame;
float t = _frameNum - (float)Math.Floor(_frameNum);
if (t < 0f) t = 0f;
if (t > 1f) t = 1f;
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>
/// 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);
}
}