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_MissingCycle_LeavesSequenceAndStateUntouched() { // R2-Q5: HasCycle + the caller-side fallback chains are DELETED. // The retail mechanism replacing the L.1c "torso on the ground" // guard: GetObjectSequence (0x00522860) checks the cycle BEFORE any // list surgery — a missing cycle leaves the sequence AND MotionState // untouched, so whatever was playing (here the initialize_state // default) keeps playing. const uint Style = 0x8000003Cu; // HandCombat (full command) const uint ReadyMotion = 0x41000003u; const uint AnimId = 0x03000001u; var setup = Fixtures.MakeSetup(2); var mt = Fixtures.MakeMtable(Style, ReadyMotion, AnimId); var loader = new FakeLoader(); loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); var seq = new AnimationSequencer(setup, mt, loader); seq.InitializeState(); Assert.Equal(ReadyMotion, seq.CurrentMotion); int nodesBefore = seq.QueueCount; // RunForward (0x44000007) is NOT in the table — the dispatch fails // and nothing changes (no cyclic-tail wipe, no state overwrite). seq.SetCycle(Style, 0x44000007u); Assert.Equal(ReadyMotion, seq.CurrentMotion); Assert.Equal(nodesBefore, seq.QueueCount); Assert.True(seq.HasCurrentNode); } [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() { // R2-Q4: GetObjectSequence gates Branch 2 (cyclic substates) on the // 0x40000000 class bit and Branch 1 (style change) on the top bit — // bare low-word ids like the pre-cutover 0x0003/0x0005 never satisfy // those gates and the dispatch silently fails. Tag Style/IdleMotion/ // WalkMotion with their class bits (masking to the low 24 bits for // the cycle/link key hash is unaffected — CMotionTable keys on // `id & 0xFFFFFF`). const uint Style = 0x8000003Du; const uint IdleMotion = 0x40000003u; const uint WalkMotion = 0x40000005u; const uint IdleAnim = 0x03000012u; const uint CycleAnim = 0x03000010u; const uint LinkAnim = 0x03000011u; var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); 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); // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) — // route it at IdleMotion (the state this test "was already playing" // before priming) and give IdleMotion its own cycle so the real // SetCycle(Style, IdleMotion) priming call below actually dispatches. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f); var loader = new FakeLoader(); loader.Register(IdleAnim, idleAnim); 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 — a // real SetCycle call now that CurrentStyle/CurrentMotion are // read-only mirrors of MotionState (R2-Q4; reflection SetValue no // longer works, "Property set method not found"). seq.SetCycle(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 Advance_LinkTailDoesNotBlendIntoLinkFrame0() { // Issue #61 regression: the fractional tail of a non-looping LINK // (the ~30 ms between the last integer frame and the wrap boundary) // must hold the link's END pose instead of blending into the link's // FRAME 0 pose. Old behaviour: BuildBlendedFrame wrapped nextIdx to // rangeLo unconditionally, producing a one-frame flash through the // link's starting pose at the link→cycle boundary. Symptoms: door // swing-open flap (frame 0 = closed); player run-stop twitch // (frame 0 = mid-stride). // R2-Q4: class-bit-tagged ids (GetObjectSequence gates Branch 1/2 on // the 0x80000000/0x40000000 bits) — masking to the low 24 bits for // the cycle/link key hash is unaffected. const uint Style = 0x8000003Du; const uint IdleMotion = 0x40000003u; const uint WalkMotion = 0x40000005u; const uint IdleAnim = 0x03000082u; const uint CycleAnim = 0x03000080u; const uint LinkAnim = 0x03000081u; // Link anim: 3 frames, distinct Y so we can tell which frame is being // sampled. Frame 0 Y=10 (link's starting pose — e.g. closed door), // frame 2 Y=0 (link's end pose — e.g. open door). var linkAnim = new Animation(); for (int f = 0; f < 3; f++) { var pf = new AnimationFrame(1); float y = 10f - 5f * f; // 10, 5, 0 pf.Frames.Add(new Frame { Origin = new Vector3(0, y, 0), Orientation = Quaternion.Identity }); linkAnim.PartFrames.Add(pf); } // Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose). var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); var idleAnim = Fixtures.MakeAnim(1, 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: 30f); // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) // — route it at IdleMotion (the state we prime through below) with // its own cycle so the priming SetCycle call actually dispatches. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f); var loader = new FakeLoader(); loader.Register(IdleAnim, idleAnim); 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 — a // real SetCycle call (reflection SetValue no longer works against // the now-read-only CurrentStyle/CurrentMotion mirrors). seq.SetCycle(Style, IdleMotion); seq.SetCycle(Style, WalkMotion); // Advance to _framePosition ≈ 2.5 — past the last integer frame (2) // but before maxBoundary - epsilon (≈ 3). At 30 fps, 2.5/30 = 0.0833s. seq.Advance(0.0833f); double pos = GetFramePosition(seq); Assert.InRange(pos, 2.4, 2.7); var transforms = seq.Advance(0.0001f); // tiny extra dt to trigger blend read // Pre-fix: nextIdx would wrap to rangeLo (0), so transforms[0].Origin.Y // would land near 0.5 × 0 + 0.5 × 10 = 5 (mid-blend with link frame 0). // Post-fix: nextIdx = frameIdx (2), so transforms[0].Origin.Y = 0 (held). Assert.True(transforms[0].Origin.Y < 1f, $"Link tail should hold last-frame pose Y=0; got Y={transforms[0].Origin.Y} " + "(would be ~5 if nextIdx still wrapped to link frame 0)"); } [Fact] public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink() { // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. // S6_WalkBackToReady_StopSettleFallback): the pre-cutover adapter had // an invented "stop-anim low-byte fallback" that re-keyed a // WalkForward->Ready link when a WalkBackward->Ready lookup missed. // Retail has no such fallback — CMotionTable.GetLink's verbatim // reversed-key branch (Q0-pins A1) does the real job: adjust_motion // remaps WalkBackward to WalkForward with a NEGATIVE SubstateMod, so // stopping from it drives GetLink's "either speed negative -> swapped // keys" path, which resolves the link stored FROM the style default // (Ready) TO WalkForward and plays it IN REVERSE (the Ready->Walk // windup run backward as a settle). The fixture below stores that // link under Links[(style,Ready)][WalkForward] — the opposite // direction from the old WalkForward->Ready fallback entry — because // that's the key GetLink's reversed branch actually probes. const uint Style = 0x8000003Du; const uint WalkForwardCmd = 0x40000005u; const uint WalkBackCmd = 0x40000006u; const uint ReadyCmd = 0x40000003u; const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0) const uint LinkAnim = 0x03000091u; // Ready->Walk windup (Y=7), played reversed as the settle var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); // Table: Ready cycle + Ready->WalkForward windup link (probed // REVERSED by GetLink's swapped-key branch when settling out of a // negative-SubstateMod substate). No forward WalkBackward cycle is // needed — adjust_motion remaps WalkBackward to WalkForward with a // negated + BackwardsFactor-scaled speed before dispatch ever sees it. var mt = Fixtures.MakeMtable( style: Style, motion: ReadyCmd, cycleAnimId: CycleAnim, fromMotion: ReadyCmd, toMotion: WalkForwardCmd, linkAnimId: LinkAnim, framerate: 30f); // WalkForward also needs a cycle — adjust_motion's WalkBackward remap // dispatches WalkForward's cycle (with the negated/scaled speed) as // part of entering "backward" motion below. int walkKey = (int)((Style << 16) | (WalkForwardCmd & 0xFFFFFFu)); mt.Cycles[walkKey] = Fixtures.MakeMotionData(CycleAnim, framerate: 30f); var loader = new FakeLoader(); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); // Enter WalkBackward for real — SetCycle's adjust_motion head remaps // this to WalkForward with SubstateMod = -0.65 (BackwardsFactor), // which is what makes the SUBSEQUENT stop-to-Ready call route // GetLink's reversed-key branch. seq.SetCycle(Style, WalkBackCmd, 1.0f); seq.SetCycle(Style, ReadyCmd); // Advance a tiny dt — should land on the reversed windup link // (Y=7), not the Ready cycle (Y=0). var transforms = seq.Advance(0.001f); Assert.Single(transforms); Assert.True(transforms[0].Origin.Y > 5f, $"Stop-from-backward should resolve GetLink's reversed-key branch " + $"(expect Y≈7 from the reversed windup link); got Y={transforms[0].Origin.Y} " + "(Y=0 means the link didn't resolve and we snapped to the Ready cycle)."); } [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. // R2-Q4: Style needs the 0x80000000 top bit and TurnRight/TurnLeft the // 0x40000000 cycle-class bit — GetObjectSequence's entry/branch gates // (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity) test the // FULL command word, not just the low 16 bits adjust_motion remaps. const uint Style = 0x8000003Du; // NonCombat const uint TurnRight = 0x4045000Du; // bit pattern for TurnRight in NonCombat const uint TurnLeft = 0x4045000Eu; // 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; // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) // — route the default straight at TurnRight (the only cycle this // fixture defines) so initialize_state's baseline install succeeds // and state.Style/Substate are non-zero before the explicit dispatch. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)TurnRight; // 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); // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. // S5_WalkBackward_RemapNegativeSpeed's "mirrors only" diff): // CurrentMotion is now a GET-ONLY mirror of MotionState.Substate, // and MotionState owns the POST-adjust_motion substate — retail's // interpreted state IS the adjusted one (TurnRight played reversed), // not the raw TurnLeft the caller passed in. Pre-cutover the adapter // kept its own separate CurrentMotion field and never overwrote it // with the adjusted id; that field no longer exists. Assert.Equal(TurnRight, seq.CurrentMotion); Assert.Equal(-1f, seq.CurrentSpeedMod, 3); // R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated // epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's // AnimSequenceNode.GetStartingFrame (0x00525c80) returns a BARE INT // for reverse playback: HighFrame + 1 (gap map G1 — "NO epsilon"). // LowFrame=0, HighFrame=3 (no swap at append time; the swap only // happens inside MultiplyFramerate for an in-place resign, which // this path doesn't take), so the retail-exact start position is // exactly 4.0, not "near but under" 4.0. The cursor starts at the // boundary and counts DOWN toward LowFrame(=0) on the next Advance. double pos = GetFramePosition(seq); Assert.True(pos == 4.0, $"Expected framePosition == 4 (bare-int reverse start = HighFrame+1, " + $"retail AnimSequenceNode.GetStartingFrame 0x00525c80 has NO epsilon — G1); got {pos}"); } [Fact] public void Advance_NegativeSpeed_FramePositionDecreases() { // Verify that a cycle loaded with negative framerate counts downward. // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Du; const uint Motion = 0x40000003u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; // 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. // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). const uint Style = 0x8000003Du; const uint IdleMotion = 0x40000003u; const uint WalkMotion = 0x40000005u; const uint IdleAnim = 0x03000082u; 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 idleAnim = Fixtures.MakeAnim(1, 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); // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) // — route it at IdleMotion with its own cycle so the priming // SetCycle call below actually dispatches. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); var loader = new FakeLoader(); loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); // Prime as if already playing IdleMotion — real SetCycle call // (reflection SetValue no longer works, see WithTransitionLink test). seq.SetCycle(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. // R2-Q4: class-bit-tagged ids + retail-mandatory StyleDefaults — the bare // ids made this test pass VACUOUSLY (dispatch silently failed, no anim // played, so "hook did not fire" held for the wrong reason). const uint Style = 0x8000003Du; const uint Motion = 0x40000003u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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. // 0.25s at -10fps = -2.5 frames → crosses the 3→2 boundary, so the // hooked frame IS reached (same advance as the Backward sibling test // — the direction filter, not distance, is what's under test). seq.Advance(0.25f); 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() { // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Du; const uint Motion = 0x40000003u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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 (R1-P6: the wired Frame path, gap map G7) ─────── [Fact] public void Advance_WithRootMotionFrame_AccumulatesPosFrameDeltas() { // R1-P6 (2026-07-02): root motion flows through retail's actual // contract — CSequence::update(quantum, Frame*) (0x00525b80): // every crossed integer frame combines the node's pos_frame into // the caller-supplied Frame (update_internal 0x005255d0). The old // adapter-side accumulator (ConsumeRootMotionDelta) is DELETED — // this is the seam R6's per-tick order consumes. 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); // Advance 0.25s @10fps → 2.5 frames → 2 crossings (0→1, 1→2), each // combining +1 X of pos_frame origin into the supplied Frame. var rootFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; seq.Advance(0.25f, rootFrame); Assert.True(rootFrame.Origin.X >= 1.8f && rootFrame.Origin.X <= 2.2f, $"Expected ~2.0 root motion X after 2 crossings via the wired Frame, got {rootFrame.Origin.X}"); } [Fact] public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() { // MotionData with HasVelocity flag should surface via CurrentVelocity. // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) — // without it, initialize_state's SetDefaultState fails, state.Style // stays 0, and GetObjectSequence's entry guard rejects every // dispatch. Route the default straight at Motion (the cycle this // test cares about). const uint Style = 0x8000003Du; const uint Motion = 0x40000003u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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() { // R2-Q4: retail-mandatory StyleDefaults, and Motion needs its // 0x40000000 cycle-class bit — the same-motion re-speed fast path // (Branch 2, target==substate) still requires the class-bit gate to // be reached in the first place. const uint Style = 0x8000003Du; const uint Motion = 0x40000003u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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). // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). const uint Style = 0x8000003Du; const uint IdleMotion = 0x40000003u; const uint WalkMotion = 0x40000005u; const uint IdleAnim = 0x03000132u; 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 idleAnim = Fixtures.MakeAnim(1, 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); // R2-Q4: retail-mandatory StyleDefaults — route it at IdleMotion with // its own cycle so the priming SetCycle call below actually dispatches. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); var loader = new FakeLoader(); loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); // Prime as if already playing IdleMotion — real SetCycle call. seq.SetCycle(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). // R2-Q4: Motion needs the 0x40000000 cycle-class bit — GetObjectSequence // Branch 2 (and its same-motion fast re-speed path) never triggers on // a bare low-word id (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Du; const uint Motion = 0x40000007u; // 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; // R2-Q4: the dispatch stack needs the retail-mandatory StyleDefaults // entry (SetDefaultState 0x005230a0 requires StyleDefaults[DefaultStyle]; // GetObjectSequence refuses a zero style/substate). Real dat tables // always carry it. mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; var loader = new FakeLoader(); loader.Register(AnimId, anim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion, speedMod: 1f); // R2-Q4: halve the playback rate via the retail same-motion re-speed // (GetObjectSequence Branch-2 fast path: change_cycle_speed + // subtract_motion(old) + combine_motion(new), decomp §5) — the old // MultiplyCyclicFramerate adapter composite is deleted. seq.SetCycle(Style, Motion, speedMod: 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) // (subtract_motion(1.0) + combine_motion(0.5) = ×0.5 net). 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. // R2-Q4: class-bit-tagged ids — the bare ids made this test pass // VACUOUSLY (dispatch silently failed; "cursor unchanged" held // because nothing moved at all). const uint Style = 0x8000003Du; const uint Motion = 0x40000007u; 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); // R2-Q4: retail-mandatory StyleDefaults entry (see // MultiplyCyclicFramerate_HalvesPlaybackRate). mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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); // R2-Q4: mid-cycle re-speed via the retail Branch-2 fast path — must // not touch the cursor (change_cycle_speed scales framerates only). seq.SetCycle(Style, Motion, speedMod: 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. // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. const uint Style = 0x8000003Du; const uint Motion = 0x40000007u; 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. // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Du; const uint Motion = 0x40000007u; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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. // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. const uint Style = 0x8000003Du; const uint Motion = 0x40000007u; 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. // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Du; const uint Motion = 0x4000000Du; // 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; 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. // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). const uint Style = 0x8000003Du; const uint IdleMotion = 0x40000003u; const uint WalkMotion = 0x40000005u; const uint IdleAnim = 0x03000603u; 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 idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) // — route it at IdleMotion (the state we prime through below). mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); 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(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); // Prime as if already playing IdleMotion — real SetCycle call. seq.SetCycle(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. // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry // (SetDefaultState 0x005230a0 is retail-mandatory — GetObjectSequence // refuses style==0/substate==0, see // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). const uint Style = 0x8000003Eu; // 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; 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_ActionSurvivesImmediateReadyCycleEcho() { // ACE broadcasts creature attacks as Action-class ForwardCommand // values followed by Ready. Retail keeps currState.Substate at Ready // while the action link drains, so the Ready echo must not abort the // in-flight swing. // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry // (see PlayAction_Action_ResolvesFromLinksDict). const uint Style = 0x8000003Du; const uint IdleMotion = 0x41000003u; const uint AttackMotion = 0x10000052u; const uint IdleAnimId = 0x03000503u; const uint AttackAnimId = 0x03000504u; var setup = Fixtures.MakeSetup(1); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style }; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); var cmdData = new MotionCommandData(); cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f); mt.Links[linkOuter] = cmdData; var loader = new FakeLoader(); loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity)); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); seq.PlayAction(AttackMotion); seq.SetCycle(Style, IdleMotion); var fr = seq.Advance(0.01f); Assert.Single(fr); Assert.Equal(12f, fr[0].Origin.X, 1); Assert.Equal(IdleMotion, seq.CurrentMotion); } [Fact] public void PlayAction_Modifier_ResolvesFromModifiersDict() { // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. // S9_TurnModifier): a Modifier-class command (mask 0x20) — like Jump // (0x2500003B) or a turn-while-moving overlay — resolves from the // Modifiers dict, first with style-specific key then with unstyled // fallback (CMotionTable.GetObjectSequence Branch 4). Pre-cutover the // adapter INSERTED the modifier's anim before the cyclic tail — an // acdream invention. Retail Branch 4 is PHYSICS-ONLY combine_motion: // the base cycle's anim list is untouched (no new nodes), and the // modifier contributes velocity/omega on top of the cycle's own, // tracked on the MotionState modifier stack (AP-73 mechanism). // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry // (see PlayAction_Action_ResolvesFromLinksDict). const uint Style = 0x8000003Du; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); // Modifier: (Style, Jump) — carries an omega contribution (a jump // kick's angular nudge) rather than an anim payload, since Branch 4 // never touches the anim list. int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); var jumpMd = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); jumpMd.Flags = MotionDataFlags.HasOmega; jumpMd.Omega = new Vector3(0f, 0f, 2.5f); mt.Modifiers[modKey] = jumpMd; var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); loader.Register(JumpAnimId, jumpAnim); var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); int queueBefore = seq.QueueCount; seq.PlayAction(JumpMotion); // No anim nodes inserted — the queue is unchanged from before the // modifier fired. Assert.Equal(queueBefore, seq.QueueCount); // The modifier's omega is combined onto the sequence's physics. Assert.Equal(2.5f, seq.CurrentOmega.Z, 3); } [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. // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry // (see PlayAction_Action_ResolvesFromLinksDict). const uint Style = 0x8000003Du; 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; mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; 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 the core CSequence's FrameNumber via reflection (test-only). /// R1-P5 rehost (2026-07-02): _framePosition lived directly on /// AnimationSequencer pre-cutover; it now lives on the private _core /// (CSequence) field as the public FrameNumber. Two-hop reflection: /// grab _core, then its FrameNumber field. /// private static double GetFramePosition(AnimationSequencer seq) { var coreField = typeof(AnimationSequencer) .GetField("_core", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var core = coreField?.GetValue(seq); if (core is null) return -1.0; var frameNumberField = core.GetType() .GetField("FrameNumber", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); return frameNumberField is null ? -1.0 : (double)frameNumberField.GetValue(core)!; } /// /// 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); } }