diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs
new file mode 100644
index 0000000..b8866d5
--- /dev/null
+++ b/src/AcDream.Core/Physics/AnimationSequencer.cs
@@ -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;
+
+///
+/// Minimal interface for resolving Animation objects by id.
+/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
+///
+public interface IAnimationLoader
+{
+ /// Load an Animation by its dat id, or return null.
+ Animation? LoadAnimation(uint id);
+}
+
+///
+/// Production implementation of backed by
+/// a .
+///
+public sealed class DatCollectionLoader : IAnimationLoader
+{
+ private readonly DatCollection _dats;
+ public DatCollectionLoader(DatCollection dats) => _dats = dats;
+ public Animation? LoadAnimation(uint id) => _dats.Get(id);
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// AnimationSequencer — 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
+// key = (style << 16) | (fromSubstate & 0xFFFFFF)
+// MotionCommandData.MotionData : Dictionary
+// key = target motion (int cast of MotionCommand)
+// MotionData.Anims : List
+// AnimData.AnimId : QualifiedDataId
+// Animation.PartFrames : List
+// AnimationFrame.Frames : List
+// Frame.Origin : Vector3, Frame.Orientation : Quaternion
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// Per-part world-local transform produced by .
+/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
+///
+public readonly struct PartTransform
+{
+ public readonly Vector3 Origin;
+ public readonly Quaternion Orientation;
+
+ public PartTransform(Vector3 origin, Quaternion orientation)
+ {
+ Origin = origin;
+ Orientation = orientation;
+ }
+}
+
+///
+/// One entry in the animation queue (link transition or looping cycle).
+///
+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;
+}
+
+///
+/// Full animation playback engine for one entity.
+///
+///
+/// Usage pattern:
+///
+/// var seq = new AnimationSequencer(setup, motionTable, dats);
+/// seq.SetCycle(style, motion, speedMod);
+/// // each frame:
+/// var transforms = seq.Advance(dt);
+/// // rebuild MeshRefs from transforms
+///
+///
+///
+///
+/// When 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.
+///
+///
+public sealed class AnimationSequencer
+{
+ // ── Public state ─────────────────────────────────────────────────────────
+
+ /// Current style (stance) command.
+ public uint CurrentStyle { get; private set; }
+
+ /// Current cyclic motion command.
+ public uint CurrentMotion { get; private set; }
+
+ // ── Private state ────────────────────────────────────────────────────────
+
+ private readonly Setup _setup;
+ private readonly MotionTable _mtable;
+ private readonly IAnimationLoader _loader;
+
+ // Animation queue: non-looping link frames followed by the looping cycle.
+ private readonly LinkedList _queue = new();
+ private LinkedListNode? _currNode;
+ private LinkedListNode? _firstCyclic;
+ private float _frameNum;
+
+ private const float Epsilon = 1e-5f;
+
+ // ── Constructor ──────────────────────────────────────────────────────────
+
+ ///
+ /// Create a sequencer for one entity.
+ ///
+ /// Entity's Setup dat (for part count / default scale).
+ /// Loaded MotionTable dat for this entity.
+ ///
+ /// Animation loader. Use for production,
+ /// or inject a test double in unit tests.
+ ///
+ public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
+ {
+ ArgumentNullException.ThrowIfNull(setup);
+ ArgumentNullException.ThrowIfNull(motionTable);
+ ArgumentNullException.ThrowIfNull(loader);
+
+ _setup = setup;
+ _mtable = motionTable;
+ _loader = loader;
+ }
+
+ // ── Public API ───────────────────────────────────────────────────────────
+
+ ///
+ /// Switch to a new cyclic motion, prepending any transition link frames
+ /// so the switch is smooth. If the motion table has no link for the
+ /// (currentStyle, currentMotion) → newMotion transition, the cycle
+ /// switches immediately (same as the old snap behaviour).
+ ///
+ /// MotionCommand style / stance (e.g. NonCombat 0x003D0000).
+ /// Target motion command (e.g. WalkForward 0x45000005).
+ /// Speed multiplier applied to framerates (1.0 = normal).
+ 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;
+ }
+
+ ///
+ /// Advance the animation by seconds and return the
+ /// per-part transforms for the current blended keyframe.
+ ///
+ ///
+ /// The slerp algorithm mirrors the decompiled retail client's
+ /// FUN_005360d0 (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.
+ ///
+ ///
+ /// Elapsed time in seconds since the last call.
+ ///
+ /// One per part in the Setup, in part order.
+ /// If no animation is loaded, all parts get identity transforms.
+ ///
+ public IReadOnlyList Advance(float dt)
+ {
+ int partCount = _setup.Parts.Count;
+
+ if (_currNode == null || dt <= 0f)
+ return BuildIdentityFrame(partCount);
+
+ 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();
+ }
+
+ ///
+ /// Reset the sequencer to an unplaying state without clearing the
+ /// motion table reference.
+ ///
+ public void Reset()
+ {
+ _queue.Clear();
+ _currNode = null;
+ _firstCyclic = null;
+ _frameNum = 0f;
+ CurrentStyle = 0;
+ CurrentMotion = 0;
+ }
+
+ // ── Private helpers ──────────────────────────────────────────────────────
+
+ ///
+ /// Look up the transition MotionData for going from
+ /// to within .
+ ///
+ /// Port of ACE's MotionTable.get_link (positive-speed path):
+ /// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion]
+ /// 2. Fallback: try Links[style<<16][toMotion]
+ ///
+ /// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData>
+ /// where MotionCommandData.MotionData is Dictionary<int, MotionData>.
+ ///
+ 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;
+ }
+
+ ///
+ /// Load an Animation from the dat by its
+ /// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
+ /// Mirrors ACE AnimSequenceNode.set_animation_id.
+ ///
+ 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);
+ }
+
+ ///
+ /// Append all AnimData entries from to the
+ /// queue. Each AnimData becomes one AnimNode.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Remove all cyclic (looping) nodes from the tail of the queue, starting
+ /// from . Non-cyclic link frames remain.
+ ///
+ 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;
+ }
+
+ ///
+ /// Move to the next node in the queue, or loop
+ /// back to if at the end. Mirrors ACE's
+ /// advance_to_next_animation.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private IReadOnlyList 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 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;
+ }
+
+ ///
+ /// Quaternion slerp matching the retail client's FUN_005360d0
+ /// (chunk_00530000.c:4799-4846):
+ ///
+ /// - Compute dot product of q1 and q2.
+ /// - If dot < 0, negate q2 (choose the shorter arc).
+ /// - If 1 - dot <= epsilon, fall back to (1-t)*q1 + t*q2 (linear).
+ /// - Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).
+ /// - Validate result lies in [0,1]²; if not, fall back to linear.
+ ///
+ /// The only difference from the standard formula is step 5: the retail
+ /// client validates that both blend weights are in [0,1] before using the
+ /// sin-based result; this handles degenerate inputs gracefully.
+ ///
+ public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t)
+ {
+ float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z;
+
+ // Step 2: choose the shorter arc.
+ Quaternion q2s;
+ if (dot < 0f)
+ {
+ dot = -dot;
+ q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W);
+ }
+ else
+ {
+ q2s = q2;
+ }
+
+ const float SlerpEpsilon = 1e-4f;
+ float w1, w2;
+
+ if (1f - dot <= SlerpEpsilon)
+ {
+ // Near-parallel: linear fallback (matches retail client's path).
+ w1 = 1f - t;
+ w2 = t;
+ }
+ else
+ {
+ float omega = MathF.Acos(dot);
+ float sinOmega = MathF.Sin(omega);
+ float invSin = 1f / sinOmega;
+
+ float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
+ float candidate2 = MathF.Sin(t * omega) * invSin;
+
+ // Step 5: validate (retail client check: both weights in [0,1]).
+ if (candidate1 >= 0f && candidate1 <= 1f
+ && candidate2 >= 0f && candidate2 <= 1f)
+ {
+ w1 = candidate1;
+ w2 = candidate2;
+ }
+ else
+ {
+ w1 = 1f - t;
+ w2 = t;
+ }
+ }
+
+ return new Quaternion(
+ w1 * q1.X + w2 * q2s.X,
+ w1 * q1.Y + w2 * q2s.Y,
+ w1 * q1.Z + w2 * q2s.Z,
+ w1 * q1.W + w2 * q2s.W);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
new file mode 100644
index 0000000..0c568f4
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
@@ -0,0 +1,440 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using Xunit;
+
+// Alias the DatReaderWriter enum so it doesn't clash with
+// AcDream.Core.Physics.MotionCommand (which is a static class of uint constants).
+using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
+
+namespace AcDream.Core.Tests.Physics;
+
+// ─────────────────────────────────────────────────────────────────────────��───
+// AnimationSequencerTests
+//
+// All tests run fully offline -- no DatCollection, no disk access.
+// We build in-memory Setup / MotionTable / Animation fixtures that drive
+// exactly the code paths we are testing.
+//
+// Covered:
+// 1. SlerpRetailClient matches System.Numerics slerp for standard cases.
+// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc).
+// 3. SlerpRetailClient falls back to linear for near-parallel quaternions.
+// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop).
+// 5. Advance at dt=0 returns identity frame (no motion table loaded).
+// 6. SetCycle transitions: link frames are prepended before the target cycle.
+// 7. GetLink returns null when MotionTable has no link for the transition.
+// 8. SetCycle with same motion twice is a no-op (fast path).
+// 9. Reset clears all state.
+// ─────────────────────────────────────────────────────────────────────────────
+
+///
+/// In-memory IAnimationLoader test double. No filesystem access.
+///
+file sealed class FakeLoader : IAnimationLoader
+{
+ private readonly Dictionary _anims = new();
+
+ public void Register(uint id, Animation anim) => _anims[id] = anim;
+
+ public Animation? LoadAnimation(uint id) =>
+ _anims.TryGetValue(id, out var a) ? a : null;
+}
+
+///
+/// Helper to build minimal in-memory dat fixtures.
+///
+file static class Fixtures
+{
+ ///
+ /// Build an Animation with identical frames,
+ /// each part having the supplied origin/orientation.
+ ///
+ public static Animation MakeAnim(int numFrames, int numParts,
+ Vector3 origin, Quaternion orientation)
+ {
+ var anim = new Animation();
+ for (int f = 0; f < numFrames; f++)
+ {
+ // AnimationFrame requires NumParts in its constructor.
+ var pf = new AnimationFrame((uint)numParts);
+ for (int p = 0; p < numParts; p++)
+ pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
+ anim.PartFrames.Add(pf);
+ }
+ return anim;
+ }
+
+ ///
+ /// Build a two-frame animation: frame 0 has one origin/rotation, frame 1 another.
+ /// Used to exercise slerp blending.
+ ///
+ public static Animation MakeTwoFrameAnim(
+ int numParts,
+ Vector3 fromOrigin, Quaternion fromRot,
+ Vector3 toOrigin, Quaternion toRot)
+ {
+ var anim = new Animation();
+
+ var pf0 = new AnimationFrame((uint)numParts);
+ var pf1 = new AnimationFrame((uint)numParts);
+ for (int p = 0; p < numParts; p++)
+ {
+ pf0.Frames.Add(new Frame { Origin = fromOrigin, Orientation = fromRot });
+ pf1.Frames.Add(new Frame { Origin = toOrigin, Orientation = toRot });
+ }
+ anim.PartFrames.Add(pf0);
+ anim.PartFrames.Add(pf1);
+ return anim;
+ }
+
+ ///
+ /// Build a minimal Setup with parts,
+ /// each with a DefaultScale of (1,1,1).
+ ///
+ public static Setup MakeSetup(int numParts)
+ {
+ var setup = new Setup();
+ for (int i = 0; i < numParts; i++)
+ {
+ setup.Parts.Add(0x01000000u + (uint)i); // synthetic GfxObj ids
+ setup.DefaultScale.Add(Vector3.One);
+ }
+ return setup;
+ }
+
+ ///
+ /// Build a MotionTable with one cycle (style+motion) pointing to the
+ /// given animation id, and optionally a link from (style, fromMotion)
+ /// to (toMotion) pointing to .
+ ///
+ public static MotionTable MakeMtable(
+ uint style, uint motion, uint cycleAnimId,
+ uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0)
+ {
+ var mt = new MotionTable();
+ mt.DefaultStyle = (DRWMotionCommand)style;
+ mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
+
+ int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu));
+ mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate: 30f);
+
+ if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0)
+ {
+ int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
+ var cmd = new MotionCommandData();
+ cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate: 30f);
+ mt.Links[linkOuter] = cmd;
+ }
+
+ return mt;
+ }
+
+ private static MotionData MakeMotionData(uint animId, float framerate)
+ {
+ var md = new MotionData();
+ // QualifiedDataId has an implicit conversion from uint.
+ QualifiedDataId qid = animId;
+ md.Anims.Add(new AnimData
+ {
+ AnimId = qid,
+ LowFrame = 0,
+ HighFrame = -1, // sentinel -> resolve to numFrames-1
+ Framerate = framerate,
+ });
+ return md;
+ }
+}
+
+public sealed class AnimationSequencerTests
+{
+ // ── SlerpRetailClient ────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(0f)]
+ [InlineData(0.25f)]
+ [InlineData(0.5f)]
+ [InlineData(0.75f)]
+ [InlineData(1f)]
+ public void SlerpRetailClient_MatchesNumerics_ForOrthogonalQuats(float t)
+ {
+ // Two quaternions 90 degrees apart (rotation around Z axis: 0 and 90 deg).
+ var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0f);
+ var q2 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
+
+ var got = AnimationSequencer.SlerpRetailClient(q1, q2, t);
+ var expected = Quaternion.Slerp(q1, q2, t);
+
+ Assert.Equal(expected.X, got.X, 4);
+ Assert.Equal(expected.Y, got.Y, 4);
+ Assert.Equal(expected.Z, got.Z, 4);
+ Assert.Equal(expected.W, got.W, 4);
+ }
+
+ [Fact]
+ public void SlerpRetailClient_HandlesNegativeDot_TakesShortArc()
+ {
+ // q2 is the antipodal of q1 (dot -> -1).
+ var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.1f);
+ var q2 = new Quaternion(-q1.X, -q1.Y, -q1.Z, -q1.W); // antipode
+
+ // At t=0 the result should be non-NaN (the sign-flip gives a valid quat).
+ var got = AnimationSequencer.SlerpRetailClient(q1, q2, 0f);
+ Assert.False(float.IsNaN(got.X));
+ Assert.False(float.IsNaN(got.W));
+ }
+
+ [Fact]
+ public void SlerpRetailClient_NearParallel_LinearFallback()
+ {
+ // Two identical quaternions -> dot = 1 -> linear fallback path.
+ var q = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.3f);
+ var got = AnimationSequencer.SlerpRetailClient(q, q, 0.5f);
+
+ Assert.Equal(q.X, got.X, 4);
+ Assert.Equal(q.Y, got.Y, 4);
+ Assert.Equal(q.Z, got.Z, 4);
+ Assert.Equal(q.W, got.W, 4);
+ }
+
+ // ── SetCycle / frame advance ────────────────────────────────────────��────
+
+ [Fact]
+ public void Advance_NoCycleSet_ReturnsIdentityTransforms()
+ {
+ var setup = Fixtures.MakeSetup(3);
+ var mt = new MotionTable();
+ var loader = new FakeLoader();
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ var transforms = seq.Advance(0.033f);
+
+ Assert.Equal(3, transforms.Count);
+ foreach (var tr in transforms)
+ {
+ Assert.Equal(Vector3.Zero, tr.Origin);
+ Assert.Equal(Quaternion.Identity, tr.Orientation);
+ }
+ }
+
+ [Fact]
+ public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
+ {
+ const uint Style = 0x003Du; // NonCombat
+ const uint Motion = 0x0003u; // Ready
+ const uint AnimId = 0x03000001u;
+
+ var origin = new Vector3(1f, 0f, 0f);
+ var rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
+ var anim = Fixtures.MakeTwoFrameAnim(2, origin, rot, origin * 2, rot);
+
+ var setup = Fixtures.MakeSetup(2);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion);
+
+ // Very small dt -> should be near the first frame's rotation.
+ var transforms = seq.Advance(0.001f);
+
+ Assert.Equal(2, transforms.Count);
+ // Orientation should be close to rot (first frame), not identity.
+ Assert.True(Math.Abs(transforms[0].Orientation.Z - rot.Z) < 0.1f,
+ $"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}");
+ }
+
+ [Fact]
+ public void Advance_FrameWrapsAtHighFrame()
+ {
+ const uint Style = 0x003Du;
+ const uint Motion = 0x0003u;
+ const uint AnimId = 0x03000002u;
+
+ // 4-frame animation; framerate=10fps, one full loop = 0.4s.
+ var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
+ var setup = Fixtures.MakeSetup(1);
+ var mt = new MotionTable();
+ mt.DefaultStyle = (DRWMotionCommand)Style;
+ mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
+ int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
+ mt.Cycles[cycleKey] = new MotionData();
+ QualifiedDataId qid = AnimId;
+ mt.Cycles[cycleKey].Anims.Add(new AnimData
+ {
+ AnimId = qid,
+ LowFrame = 0,
+ HighFrame = 3,
+ Framerate = 10f,
+ });
+
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion);
+
+ // Advance one full loop + a bit: 0.5s at 10fps = 5 frames.
+ // After wrapping this should still return a valid transform.
+ seq.Advance(0.5f);
+ var transforms = seq.Advance(0.01f);
+
+ Assert.Single(transforms);
+ // No exception = pass; the wrap produced a valid (non-crash) frame.
+ }
+
+ [Fact]
+ public void SetCycle_WithTransitionLink_PrependLinkFrames()
+ {
+ // Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1).
+ const uint Style = 0x003Du;
+ const uint IdleMotion = 0x0003u;
+ const uint WalkMotion = 0x0005u;
+ const uint CycleAnim = 0x03000010u;
+ const uint LinkAnim = 0x03000011u;
+
+ var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity);
+ var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity);
+
+ var setup = Fixtures.MakeSetup(1);
+ // MotionTable: link Idle->Walk = 2-frame transition anim.
+ var mt = Fixtures.MakeMtable(
+ style: Style,
+ motion: WalkMotion,
+ cycleAnimId: CycleAnim,
+ fromMotion: IdleMotion,
+ toMotion: WalkMotion,
+ linkAnimId: LinkAnim);
+
+ var loader = new FakeLoader();
+ loader.Register(CycleAnim, cycleAnim);
+ loader.Register(LinkAnim, linkAnim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+
+ // Prime the sequencer as if it was already playing IdleMotion.
+ SetCurrentMotion(seq, Style, IdleMotion);
+
+ seq.SetCycle(Style, WalkMotion);
+
+ // At t~0 we should be reading the link anim (Y=1), not the cycle (X=1).
+ var transforms = seq.Advance(0.001f);
+ Assert.Single(transforms);
+ Assert.True(transforms[0].Origin.Y > transforms[0].Origin.X,
+ $"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
+ }
+
+ [Fact]
+ public void SetCycle_NoLinkInTable_DirectCycleSwitch()
+ {
+ const uint Style = 0x003Du;
+ const uint Motion = 0x0003u;
+ const uint AnimId = 0x03000020u;
+
+ var anim = Fixtures.MakeAnim(3, 1, new Vector3(5, 0, 0), Quaternion.Identity);
+ var setup = Fixtures.MakeSetup(1);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion); // no link registered -> direct cycle
+
+ var transforms = seq.Advance(0.001f);
+ Assert.Single(transforms);
+ // Should get cycle origin X~5 since there's no link.
+ Assert.True(transforms[0].Origin.X > 4f,
+ $"Expected cycle origin X~5 but got {transforms[0].Origin.X}");
+ }
+
+ [Fact]
+ public void SetCycle_SameMotionTwice_NoStateChange()
+ {
+ const uint Style = 0x003Du;
+ const uint Motion = 0x0003u;
+ const uint AnimId = 0x03000030u;
+
+ var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
+ var setup = Fixtures.MakeSetup(1);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion);
+
+ // Advance a bit to move the frame counter.
+ seq.Advance(0.1f);
+
+ float frameBefore = GetFrameNum(seq);
+
+ // Call SetCycle again with identical args -- fast-path, no reset.
+ seq.SetCycle(Style, Motion);
+
+ float frameAfter = GetFrameNum(seq);
+
+ Assert.Equal(frameBefore, frameAfter);
+ }
+
+ [Fact]
+ public void Reset_ClearsAllState()
+ {
+ const uint Style = 0x003Du;
+ const uint Motion = 0x0003u;
+ const uint AnimId = 0x03000040u;
+
+ var anim = Fixtures.MakeAnim(4, 1, Vector3.One, Quaternion.Identity);
+ var setup = Fixtures.MakeSetup(1);
+ var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
+ var loader = new FakeLoader();
+ loader.Register(AnimId, anim);
+
+ var seq = new AnimationSequencer(setup, mt, loader);
+ seq.SetCycle(Style, Motion);
+ seq.Advance(0.2f);
+
+ seq.Reset();
+
+ Assert.Equal(0u, seq.CurrentStyle);
+ Assert.Equal(0u, seq.CurrentMotion);
+
+ // After reset, Advance should return identity transforms.
+ var transforms = seq.Advance(0.033f);
+ foreach (var tr in transforms)
+ {
+ Assert.Equal(Vector3.Zero, tr.Origin);
+ Assert.Equal(Quaternion.Identity, tr.Orientation);
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /// Expose _frameNum via reflection (test-only).
+ private static float GetFrameNum(AnimationSequencer seq)
+ {
+ var field = typeof(AnimationSequencer)
+ .GetField("_frameNum",
+ System.Reflection.BindingFlags.NonPublic |
+ System.Reflection.BindingFlags.Instance);
+ return field is null ? -1f : (float)field.GetValue(seq)!;
+ }
+
+ ///
+ /// Directly set CurrentStyle and CurrentMotion via reflection so the
+ /// transition-link test can simulate "we were already playing IdleMotion".
+ /// Both are auto-properties with private setters.
+ ///
+ private static void SetCurrentMotion(AnimationSequencer seq, uint style, uint motion)
+ {
+ var t = typeof(AnimationSequencer);
+ t.GetProperty(nameof(AnimationSequencer.CurrentStyle))!
+ .SetValue(seq, style);
+ t.GetProperty(nameof(AnimationSequencer.CurrentMotion))!
+ .SetValue(seq, motion);
+ }
+}