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. // 10. Negative-speed playback (TurnLeft → TurnRight with reversed animation). // 11. Boundary crossing: frame wraps correctly in reverse. // 12. advance_to_next_animation: transition link drains then wraps to cycle. // ───────────────────────────────────────────────────────────────────────────── /// /// 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++) { 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, float framerate = 30f) { 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); 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); mt.Links[linkOuter] = cmd; } return mt; } public static MotionData MakeMotionData(uint animId, float framerate) { var md = new MotionData(); 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); double frameBefore = GetFramePosition(seq); // Call SetCycle again with identical args -- fast-path, no reset. seq.SetCycle(Style, Motion); double frameAfter = GetFramePosition(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); } } // ── Negative-speed playback (TurnLeft → TurnRight reversed) ───────────── [Fact] public void SetCycle_TurnLeft_RemapsToTurnRightWithNegativeSpeed() { // TurnLeft (low nibble 0x000E) should remap to TurnRight (0x000D) // with negated speed, so the animation plays in reverse. // We verify this by checking CurrentMotion is still TurnLeft (the // original command), but the sequencer internally uses TurnRight's anim. const uint Style = 0x003Du; // NonCombat const uint TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft const uint AnimId = 0x03000050u; // 4-frame animation; each frame has a distinct Z-origin so we can tell // which direction we're reading. var anim = new Animation(); for (int f = 0; f < 4; f++) { var pf = new AnimationFrame(1); pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity }); anim.PartFrames.Add(pf); } var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; // Register TurnRight cycle (adjusted motion, not TurnLeft). int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); var loader = new FakeLoader(); loader.Register(AnimId, anim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, TurnLeft, speedMod: 1f); // CurrentMotion should record the original TurnLeft command. Assert.Equal(TurnLeft, seq.CurrentMotion); // After FUN_005267E0 (multiply_framerate) swaps low↔high for negative speed: // StartFrame = 3 (was high), EndFrame = 0 (was low) // GetStartFramePosition for negative speed = (EndFrame + 1) - EPSILON = (0+1) - eps ≈ 0.99999. // The cursor starts just below frame 1 and counts DOWN toward EndFrame(=0). double pos = GetFramePosition(seq); Assert.True(pos > 0.9 && pos < 1.0, $"Expected framePosition near 0.99999 (reverse start near EndFrame+1) but got {pos}"); } [Fact] public void Advance_NegativeSpeed_FramePositionDecreases() { // Verify that a cycle loaded with negative framerate counts downward. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000060u; var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; // Register cycle with NEGATIVE framerate to simulate reverse playback. int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 7, Framerate = -10f, // negative → reverse }); mt.Cycles[cycleKey] = md; var loader = new FakeLoader(); loader.Register(AnimId, anim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion); // For negative framerate: startFrame=7, endFrame=0 (swapped by multiply_framerate). // GetStartFramePosition = (endFrame + 1) - EPSILON = 1 - eps (the swapped endFrame is 0). // Wait — after swap: StartFrame=7, EndFrame=0. // GetStartFramePosition for negative fr: (EndFrame + 1) - eps = (0 + 1) - eps ≈ 0.99999. // Then Advance(0.05) at -10fps → delta = -10 * 0.05 = -0.5 → new pos ≈ 0.49999. double posBefore = GetFramePosition(seq); seq.Advance(0.05f); double posAfter = GetFramePosition(seq); Assert.True(posAfter < posBefore, $"Expected framePosition to decrease (reverse) but went {posBefore} → {posAfter}"); } [Fact] public void Advance_NegativeSpeed_WrapsAtStartBoundary() { // A reverse-speed cycle should wrap (via advance_to_next_animation) // when it reaches its StartFrame boundary, then loop back to the // firstCyclic node's end position. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000070u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 3, Framerate = -10f, }); mt.Cycles[cycleKey] = md; var loader = new FakeLoader(); loader.Register(AnimId, anim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion); // Advance well past one full reverse loop (0.5s at 10fps = 5 frames). // Should NOT throw or crash — wrap must produce a valid transform. seq.Advance(0.5f); var transforms = seq.Advance(0.01f); Assert.Single(transforms); // Verify the frame position is back within the valid range after wrapping. double pos = GetFramePosition(seq); Assert.True(pos >= 0.0 && pos < 4.0, $"Frame position {pos} out of range [0, 4) after reverse wrap"); } // ── advance_to_next_animation: link drains then wraps to cycle ─────────── [Fact] public void AdvanceToNextAnimation_LinkDrainsThenCycleLoops() { // Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)] // Advance enough to exhaust the link node, then verify we're in the cycle. const uint Style = 0x003Du; const uint IdleMotion = 0x0003u; const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000080u; const uint LinkAnim = 0x03000081u; // Link anim: 2 frames, Y=5 (distinct marker). var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity); // Cycle anim: 4 frames, X=9 (distinct marker). var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable( style: Style, motion: WalkMotion, cycleAnimId: CycleAnim, fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim, framerate: 10f); var loader = new FakeLoader(); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); // Link node is 2 frames at 10fps → 0.2s to exhaust. // Advance 0.25s so we're definitely past the link and into the cycle. seq.Advance(0.25f); var transforms = seq.Advance(0.001f); // After draining the 2-frame link node, we should be in the cycle anim (X=9). Assert.Single(transforms); Assert.True(transforms[0].Origin.X > 8f, $"Expected cycle anim origin X~9 but got {transforms[0].Origin.X} (link Y was 5)"); } [Fact] public void AdvanceToNextAnimation_CycleLoopsRepeatedly() { // Verify that a cycle keeps looping (multiple wraps don't crash or drift). const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000090u; var anim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f); var loader = new FakeLoader(); loader.Register(AnimId, anim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion); // Advance 5 full loops (4 frames × 10fps = 0.4s per loop → 2.0s total). for (int i = 0; i < 10; i++) seq.Advance(0.2f); var transforms = seq.Advance(0.001f); Assert.Single(transforms); // Frame position must be in a valid range (not NaN, not out of bounds). double pos = GetFramePosition(seq); Assert.True(pos >= 0.0 && pos < 4.0, $"Frame position {pos} out of range [0, 4) after 5 loops"); } // ── Helpers ────────────────────────────────────────────────────────────── /// Expose _framePosition (double) via reflection (test-only). private static double GetFramePosition(AnimationSequencer seq) { var field = typeof(AnimationSequencer) .GetField("_framePosition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return field is null ? -1.0 : (double)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); } }