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); + } +}