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); // Without swap: StartFrame=0, EndFrame=3 (original range preserved). // GetStartFramePosition for negative speed = (EndFrame+1)-eps = (3+1)-eps ≈ 3.99999. // The cursor starts near the HIGH end and counts DOWN toward StartFrame(=0). double pos = GetFramePosition(seq); Assert.True(pos > 3.9 && pos < 4.0, $"Expected framePosition near 3.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"); } // ── Hook dispatch (Phase E.1) ──────────────────────────────────────────── [Fact] public void Advance_FiresForwardHook_OnFrameBoundaryCrossing() { // 4-frame anim at 10fps. Put a SoundHook on frame 1 (Forward direction). // Advance by 0.15s (1.5 frames) which crosses the boundary at frame 1. // Expect exactly one hook fired. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000100u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.PartFrames[1].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Forward, Id = 0x0A000042u, }); 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); // Drain any hooks pre-existing from initial load (there should be none). seq.ConsumePendingHooks(); // Step 1: 0.05s → advance ~0.5 frames, floor still 0 → no crossing. seq.Advance(0.05f); Assert.Empty(seq.ConsumePendingHooks()); // Step 2: 0.10s more → total ~1.5 frames, floor now 1 → crosses boundary // from frame 0 → frame 1. Fire hook on frame index 1? No — ACE fires // hooks on frame index lastFrame (= 0). Let's put the hook on frame 0 // instead. Retest. // // Actually looking at ACE code at Sequence.cs:389: // execute_hooks(currAnim.get_part_frame(lastFrame), Forward); // lastFrame++; // So lastFrame is 0 initially, crosses to 1 → fires hooks on frame 0. // Let's verify with a hook on frame 0 instead. } [Fact] public void Advance_FiresHookOnCrossedFrame_ForwardDirection() { // ACE semantics: when floor(framePos) goes from i → i+1, hooks on // frame i with direction Forward or Both fire. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000101u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.PartFrames[0].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Forward, Id = 0x0A000001u, }); anim.PartFrames[2].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Forward, Id = 0x0A000002u, }); 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); seq.ConsumePendingHooks(); // clear any initial hooks // Advance 0.15s = 1.5 frames → floor 0 → 1, crosses boundary 0→1 → fires frame 0 hook. seq.Advance(0.15f); var hooks = seq.ConsumePendingHooks(); Assert.Single(hooks); Assert.IsType(hooks[0]); // Advance 0.2s more = 2 more frames (total 3.5) → crosses 1→2 and 2→3 → fires frame 2 hook. seq.Advance(0.2f); var hooks2 = seq.ConsumePendingHooks(); Assert.Single(hooks2); Assert.IsType(hooks2[0]); Assert.Equal(0x0A000002u, (uint)((SoundHook)hooks2[0]).Id); } [Fact] public void Advance_BothDirectionHook_FiresInForwardAndReverse() { // Direction.Both fires regardless of playback direction. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000102u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.PartFrames[0].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Both, Id = 0x0A000003u, }); 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); seq.ConsumePendingHooks(); // Forward playback, cross boundary 0→1. seq.Advance(0.15f); Assert.Single(seq.ConsumePendingHooks()); } [Fact] public void Advance_ForwardHookDoesNotFire_OnReversePlayback() { // A hook tagged Direction.Forward should NOT fire when playback is reversed. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000103u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.PartFrames[2].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Forward, Id = 0x0A000004u, }); 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); seq.ConsumePendingHooks(); // Reverse playback: cursor starts near frame 4 and counts down. seq.Advance(0.15f); var hooks = seq.ConsumePendingHooks(); // Forward-only hook on frame 2 should NOT fire on reverse playback. Assert.DoesNotContain(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000004u); } [Fact] public void Advance_BackwardHook_FiresOnReversePlayback() { const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000104u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.PartFrames[2].Hooks.Add(new SoundHook { Direction = AnimationHookDir.Backward, Id = 0x0A000005u, }); 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); seq.ConsumePendingHooks(); // Reverse: start near 4, advance 0.2s = -2 frames → cursor ~2 → crosses 3→2 // which fires hooks on frame 3 (wrong one) and maybe 2. // Let's advance enough to cross 3→2 boundary for sure. seq.Advance(0.25f); // -2.5 frames → cursor ~1.5 → crosses 3→2 and 2→1 var hooks = seq.ConsumePendingHooks(); Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u); } // ── PosFrames root motion (Phase E.1) ──────────────────────────────────── [Fact] public void Advance_WithPosFrames_AccumulatesRootMotion() { // Animation with PosFrames flag and per-frame origin deltas should // surface a non-zero root motion delta after Advance. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000110u; // 4-frame anim, each PosFrame origin = (1, 0, 0), rotation identity. var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); anim.Flags = AnimationFlags.PosFrames; for (int f = 0; f < 4; f++) { anim.PosFrames.Add(new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = 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); seq.ConsumeRootMotionDelta(); // clear // Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied. seq.Advance(0.25f); var (pos, _) = seq.ConsumeRootMotionDelta(); // Each crossing adds +X origin → total X should be 2. Assert.True(pos.X >= 1.8f && pos.X <= 2.2f, $"Expected ~2.0 root motion X after 2 crossings, got {pos.X}"); // A subsequent consume with no advance should return zero (drained). var (pos2, _) = seq.ConsumeRootMotionDelta(); Assert.Equal(Vector3.Zero, pos2); } [Fact] public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() { // MotionData with HasVelocity flag should surface via CurrentVelocity. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000120u; 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 { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0f, 4f, 0f), // 4 m/s forward }; 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); // Node is current → velocity should be exposed (scaled by speedMod=1). Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity); } [Fact] public void CurrentVelocity_ScaledBySpeedMod() { const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000121u; 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 { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0f, 4f, 0f), }; 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, speedMod: 0.5f); // Velocity scaled by speedMod=0.5 → 2 m/s forward. Assert.Equal(new Vector3(0f, 2f, 0f), seq.CurrentVelocity); } [Fact] public void ConsumePendingHooks_AnimationDoneFires_WhenLinkDrains() { // When a non-cyclic link node exhausts and we advance_to_next_animation, // an AnimationDoneHook should be queued so consumers can react (e.g. UI // wake-on-idle-complete). const uint Style = 0x003Du; const uint IdleMotion = 0x0003u; const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000130u; const uint LinkAnim = 0x03000131u; var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, 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); seq.ConsumePendingHooks(); // Link is 2 frames at 10fps = 0.2s. Advance past it. seq.Advance(0.25f); var hooks = seq.ConsumePendingHooks(); Assert.Contains(hooks, h => h is AnimationDoneHook); } // ── MultiplyCyclicFramerate / speed-mod tracking ───────────────────────── [Fact] public void MultiplyCyclicFramerate_DoublesPlaybackRate() { // A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). const uint Style = 0x003Du; const uint Motion = 0x0007u; // RunForward const uint AnimId = 0x03000401u; // Unique per-frame Z so we can tell where the cursor lands. var anim = new Animation(); for (int f = 0; f < 10; 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; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; QualifiedDataId qid = AnimId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = 9, 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, speedMod: 1f); // Halve the playback rate. seq.MultiplyCyclicFramerate(0.5f); // 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5. seq.Advance(1.0f); var frames = seq.Advance(0.001f); Assert.Single(frames); Assert.InRange(frames[0].Origin.Z, 4f, 6f); // Velocity also scales: originally (0,4,0), now (0,2,0). Assert.Equal(2f, seq.CurrentVelocity.Y, 1); } [Fact] public void MultiplyCyclicFramerate_PreservesCursorPosition() { // Changing speed mid-cycle must NOT reset the frame cursor — the // animation keeps playing from where it was, just faster/slower. const uint Style = 0x003Du; const uint Motion = 0x0007u; const uint AnimId = 0x03000402u; var anim = new Animation(); for (int f = 0; f < 10; 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; int cycleKey = (int)((Style << 16) | (Motion & 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, Motion); seq.Advance(0.3f); // cursor ~ frame 3 double before = GetFramePosition(seq); seq.MultiplyCyclicFramerate(2.0f); double after = GetFramePosition(seq); Assert.Equal(before, after, 5); } [Fact] public void SetCycle_SameMotionDifferentSpeed_RescalesInPlace() { // Re-issuing SetCycle with the same motion but a new speedMod must // NOT reset the cursor — it should call MultiplyCyclicFramerate to // keep the run loop smooth (retail behavior for a mid-run RunRate // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. const uint Style = 0x003Du; const uint Motion = 0x0007u; const uint AnimId = 0x03000403u; var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, 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, speedMod: 1f); seq.Advance(0.3f); double cursorMid = GetFramePosition(seq); Assert.Equal(1f, seq.CurrentSpeedMod, 3); // Re-issue with 2× speed — should rescale in place. seq.SetCycle(Style, Motion, speedMod: 2f); Assert.Equal(2f, seq.CurrentSpeedMod, 3); Assert.Equal(cursorMid, GetFramePosition(seq), 5); } [Fact] public void CurrentVelocity_ScalesWithSpeedMod() { // A RunForward motion with MotionData.Velocity = (0,4,0) should // surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at // 0.5×. The dead-reckoning integrator in TickAnimations reads // CurrentVelocity each tick, so this has to be accurate. const uint Style = 0x003Du; const uint Motion = 0x0007u; const uint AnimId = 0x03000405u; 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 { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; QualifiedDataId qid = AnimId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, 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, speedMod: 1f); Assert.Equal(4f, seq.CurrentVelocity.Y, 3); // Start a fresh sequencer so the initial SetCycle applies speedMod. var seq2 = new AnimationSequencer(setup, mt, loader); seq2.SetCycle(Style, Motion, speedMod: 1.5f); Assert.Equal(6f, seq2.CurrentVelocity.Y, 3); // Same-motion rescale path also updates velocity. seq2.SetCycle(Style, Motion, speedMod: 0.5f); Assert.Equal(2f, seq2.CurrentVelocity.Y, 2); } [Fact] public void SetCycle_SameMotionSameSpeed_StaysNoOp() { // Guard: the new speed-path must not break the classic // "identical call = no state change" behavior. const uint Style = 0x003Du; const uint Motion = 0x0007u; const uint AnimId = 0x03000404u; var anim = Fixtures.MakeAnim(10, 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, speedMod: 1.5f); seq.Advance(0.2f); double before = GetFramePosition(seq); seq.SetCycle(Style, Motion, speedMod: 1.5f); Assert.Equal(before, GetFramePosition(seq), 5); Assert.Equal(1.5f, seq.CurrentSpeedMod, 3); } [Fact] public void CurrentOmega_ReflectsMotionDataOmega() { // A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw) // should surface as CurrentOmega = (0, 0, 1) after SetCycle. // Scales with speedMod exactly like Velocity. const uint Style = 0x003Du; const uint Motion = 0x000Du; // TurnRight const uint AnimId = 0x03000701u; 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 { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) }; QualifiedDataId qid = AnimId; md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, 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, speedMod: 2f); // Omega scales by speedMod — 1.0 × 2 = 2 rad/sec. Assert.Equal(2.0f, seq.CurrentOmega.Z, 3); } [Fact] public void CurrentVelocity_PersistsThroughLinkTransition() { // Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity): // sequence.Velocity is REPLACED by the most-recent MotionData's // velocity. When SetCycle enqueues [link][cycle], after the final // add_motion the velocity is the cycle's velocity — ALREADY. // So even while the link animation plays visually, dead-reckoning // reads the cycle's run-speed and moves the entity smoothly. // Crucial: otherwise remote entities would stutter at every stance // transition while the link plays. const uint Style = 0x003Du; const uint IdleMotion = 0x0003u; const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000601u; const uint LinkAnim = 0x03000602u; var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion; int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu)); var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) }; QualifiedDataId cycleQid = CycleAnim; cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); mt.Cycles[cycleKey] = cycleMd; // Link from idle → walk. Link MotionData has no velocity (typical). int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); var linkCmdData = new MotionCommandData(); var linkMd = new MotionData(); // no HasVelocity flag QualifiedDataId linkQid = LinkAnim; linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f }); linkCmdData.MotionData[(int)WalkMotion] = linkMd; mt.Links[linkOuter] = linkCmdData; 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); // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is // the link, but CurrentVelocity reflects the most recent // SetVelocity call — the cycle's. So velocity is 3.12 even before // the link plays out. Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); // Advance past the link frames (2 frames at 10fps = 0.2s). seq.Advance(0.25f); // Still 3.12 — cycle is now current. Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2); } // ── PlayAction: Action / Modifier / ChatEmote routing ─────────────────── [Fact] public void PlayAction_Action_ResolvesFromLinksDict() { // An Action-class command (mask 0x10) resolves via the Links dict // keyed by (style, currentSubstate) → motion. Example: a ThrustMed // attack while in SwordCombat stance. const uint Style = 0x003Eu; // SwordCombat const uint IdleMotion = 0x41000003u; // Ready const uint ActionMotion = 0x10000058u; // ThrustMed (Action class) const uint IdleAnimId = 0x03000501u; const uint ActionAnimId= 0x03000502u; var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); // Action anim: distinct non-zero origin so we can detect it played. var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); // Link: (SwordCombat, Ready) → ThrustMed int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); var cmdData = new MotionCommandData(); cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f); mt.Links[linkOuter] = cmdData; var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); loader.Register(ActionAnimId, actionAnim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); seq.Advance(0.01f); // burn the first idle frame // Fire the action. seq.PlayAction(ActionMotion); // After a small advance, we should be reading the action anim (origin X=99). var fr = seq.Advance(0.01f); Assert.Single(fr); Assert.Equal(99f, fr[0].Origin.X, 1); } [Fact] public void PlayAction_Modifier_ResolvesFromModifiersDict() { // A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — // resolves from the Modifiers dict, first with style-specific key // then with unstyled fallback. Empirically: the modifier's anim // plays on top of the current cycle. const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint JumpMotion = 0x2500003Bu; // Modifier class const uint IdleAnimId = 0x03000510u; const uint JumpAnimId = 0x03000511u; var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); // Modifier: (Style, Jump) int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); loader.Register(JumpAnimId, jumpAnim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); seq.PlayAction(JumpMotion); var fr = seq.Advance(0.01f); Assert.Single(fr); Assert.Equal(77f, fr[0].Origin.Z, 1); } [Fact] public void PlayAction_Emote_RoutesThroughActionBranch() { // ChatEmotes like Wave (0x13000087) have class byte 0x13 = // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the // Action bit is set, they route through the Links-dict lookup just // like attacks. Verifies the class-bit math. const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint WaveMotion = 0x13000087u; const uint IdleAnimId = 0x03000520u; const uint WaveAnimId = 0x03000521u; var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); // Register Links[(style, Ready)][Wave] = wave anim. int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); var cmdData = new MotionCommandData(); cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f); mt.Links[linkOuter] = cmdData; var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); loader.Register(WaveAnimId, waveAnim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); seq.PlayAction(WaveMotion); var fr = seq.Advance(0.01f); Assert.Single(fr); Assert.Equal(55f, fr[0].Origin.Y, 1); } [Fact] public void PlayAction_NoEntryInTable_IsNoOp() { // If neither Links nor Modifiers has the motion, PlayAction should // silently return without disturbing the current cycle. const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint IdleAnimId = 0x03000530u; const uint UnknownAction = 0x10001234u; var idleAnim = 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) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); seq.Advance(0.05f); int queueBefore = seq.QueueCount; seq.PlayAction(UnknownAction); // unknown motion → no-op Assert.Equal(queueBefore, seq.QueueCount); } // ── 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); } }