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