using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; namespace AcDream.Core.Tests.Physics.Motion; /// /// R1-P4 — the frame-advance core: update_internal (0x005255d0), /// update (0x00525b80), advance_to_next_animation /// (0x005252b0), execute_hooks (0x00524830) — gaps /// G3/G4/G5/G6/G8/G9/G19. /// /// Skeleton per the ACE-verified structure (P0-pins.md): overshoot → /// clamp to the RAW high/low frame + leftover carry + animDone → /// per-crossed-frame pose/physics/hook loop → AnimDone gate /// (list HEAD != first_cyclic) → advance (pose-out both directions, /// forward wraps to first_cyclic, reverse wraps to the LIST TAIL) → /// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary /// epsilon. /// public class CSequenceUpdateTests { private sealed class MapLoader : IAnimationLoader { private readonly Dictionary _map = new(); public void Add(uint id, Animation anim) => _map[id] = anim; public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null; } private sealed class RecordingHookQueue : IAnimHookQueue { public readonly List Events = new(); public void AddAnimHook(AnimationHook hook) => Events.Add($"hook:{((TaggedHook)hook).Tag}:{hook.Direction}"); public void AddAnimDoneHook() => Events.Add("ANIMDONE"); } /// Hook subclass carrying a test tag (frame index). private sealed class TaggedHook : AnimationHook { public string Tag = ""; public override AnimationHookType HookType => (AnimationHookType)0; } /// numFrames frames; one Forward-direction tagged hook per frame. private static Animation MakeAnim(int numFrames, bool posFrames = false, string hookPrefix = "f") { var anim = new Animation(); for (int f = 0; f < numFrames; f++) { var pf = new AnimationFrame(1u); pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity }); pf.Hooks.Add(new TaggedHook { Tag = $"{hookPrefix}{f}", Direction = AnimationHookDir.Both }); anim.PartFrames.Add(pf); if (posFrames) anim.PosFrames.Add(new Frame { Origin = new Vector3(1, 0, 0), Orientation = Quaternion.Identity }); } return anim; } private static AnimData Ad(uint animId, float framerate = 30f, int low = 0, int high = -1) { QualifiedDataId qid = animId; return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate }; } private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f) { var loader = new MapLoader(); loader.Add(1u, MakeAnim(frames)); var seq = new CSequence(loader); var hooks = new RecordingHookQueue(); seq.HookObj = hooks; seq.AppendAnimation(Ad(1u, framerate)); return (seq, hooks); } // ── forward advance basics (G3) ───────────────────────────────────── [Fact] public void Forward_SingleTick_AdvancesOneFrame_FiresExitedFrameHook() { var (seq, hooks) = Cyclic(5); seq.Update(1.0 / 30.0, null); Assert.Equal(1.0, seq.FrameNumber, 9); Assert.Equal(new[] { "hook:f0:Both" }, hooks.Events); } [Fact] public void Forward_ExactBoundaryLanding_NoAdvance() { // Boundary test is STRICT (>): landing exactly at high_frame+0.0 // integer positions must NOT advance (the G3 bug class: acdream's // old epsilon-shifted boundary reclassified these). var (seq, hooks) = Cyclic(5); seq.FrameNumber = 3.5; seq.Update(0.5 / 30.0, null); // lands exactly at 4.0 == high_frame Assert.Equal(4.0, seq.FrameNumber, 9); Assert.Equal(new[] { "hook:f3:Both" }, hooks.Events); // crossed 3→4 Assert.DoesNotContain("ANIMDONE", hooks.Events); } [Fact] public void Forward_CyclicWrap_NoAnimDone_RestartsAtStartingFrame() { // Single cyclic node: overshoot wraps to itself via first_cyclic; // head == first_cyclic → NO AnimDoneHook (G5's structural gate). var (seq, hooks) = Cyclic(3); seq.FrameNumber = 2.0; seq.Update(1.0 / 30.0, null); // 2→3 > high(2) → clamp, advance, wrap Assert.Equal(0.0, seq.FrameNumber, 9); Assert.DoesNotContain("ANIMDONE", hooks.Events); Assert.Same(seq.FirstCyclic, seq.CurrAnim); } // ── multi-node fast-forward + the P0-pinned carry (G3/G5) ─────────── [Fact] public void Forward_LinkToCycle_FiresAnimDone_AndCarriesLeftover() { // link (2 frames) + cycle (4 frames): a 0.1s tick (3 frames at // 30fps) exhausts the link (2 frames) and carries 1 frame of time // into the cycle. Retail order: link's crossed-frame hooks → // AnimDoneHook (head != first_cyclic) → cycle's crossed hooks. var loader = new MapLoader(); loader.Add(1u, MakeAnim(2, hookPrefix: "link")); loader.Add(2u, MakeAnim(4, hookPrefix: "cyc")); var seq = new CSequence(loader); var hooks = new RecordingHookQueue(); seq.HookObj = hooks; seq.AppendAnimation(Ad(1u)); // link — first_cyclic slides… seq.AppendAnimation(Ad(2u)); // …to the cycle seq.Update(0.1, null); // 3 frames of time // link: FrameNumber 0→3, clamp to high(1), leftover = 1 frame; // crossing fires link f0 only (floor(1)>0), then AnimDone, then // advance → cycle at starting(0); carry 1/30 → cycle 0→1 fires cyc f0. Assert.Equal(new[] { "hook:link0:Both", "ANIMDONE", "hook:cyc0:Both" }, hooks.Events); Assert.Equal(1.0, seq.FrameNumber, 6); // the carry (P0 pin) — zeroing would leave 0.0 Assert.Same(seq.FirstCyclic, seq.CurrAnim); // now inside the cycle } // ── reverse playback (G3/G9) ──────────────────────────────────────── [Fact] public void Reverse_DescendingHooks_BackwardDirection() { // Natively-reverse node (framerate −30): seeded at starting = // high+1 = 5. Each tick fires the hook of the frame being LEFT // (the pre-tick floor index), descending; the very first tick's // index (5 = high+1) is OOB → null part frame → no hook, exactly // the retail structure. var (seq, hooks) = Cyclic(5, framerate: -30f); Assert.Equal(5.0, seq.FrameNumber, 9); // get_starting_frame = high+1 seq.Update(1.0 / 30.0, null); // 5→4 (leaves OOB index 5 — nothing) seq.Update(1.0 / 30.0, null); // 4→3 (leaves frame 4) seq.Update(1.0 / 30.0, null); // 3→2 (leaves frame 3) Assert.Equal(2.0, seq.FrameNumber, 9); Assert.Equal(new[] { "hook:f4:Both", "hook:f3:Both" }, hooks.Events); } [Fact] public void Reverse_AdvanceWrapsToListTail() { // Two nodes A(reverse),B(forward): exhausting A backward wraps // curr_anim to the LIST TAIL (B) — retail's asymmetric wrap (G9). var loader = new MapLoader(); loader.Add(1u, MakeAnim(3, hookPrefix: "a")); loader.Add(2u, MakeAnim(4, hookPrefix: "b")); var seq = new CSequence(loader); var hooks = new RecordingHookQueue(); seq.HookObj = hooks; seq.AppendAnimation(Ad(1u, framerate: -30f)); // A — curr seeds here seq.AppendAnimation(Ad(2u)); // B = first_cyclic = tail seq.FrameNumber = 0.5; // mid-window of A seq.Update(0.02, null); // frametime = −0.6 → crosses low(0) // After the reverse advance, curr must have left A via the TAIL // wrap (B), whatever the carry then does within B. Assert.NotNull(seq.CurrAnim); Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3) } // ── stationary / degenerate (G8 + else-branch) ────────────────────── [Fact] public void ZeroFramerate_PhysicsOnlyAndReturn() { var (seq, hooks) = Cyclic(3, framerate: 0f); seq.SetVelocity(new Vector3(2, 0, 0)); var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; seq.Update(0.5, frame); // frametime == 0 → apply_physics(frame, elapsed, elapsed) then return. Assert.Equal(1.0f, frame.Origin.X, 5); Assert.Empty(hooks.Events); Assert.Equal(0.0, seq.FrameNumber, 9); } [Fact] public void EmptyList_Update_AppliesAccumulatedPhysics() { // update (0x00525b80): empty anim_list + frame != null → // apply_physics(frame, elapsed, elapsed) — free-fall/knockback // with no animation (G8). var seq = new CSequence(new MapLoader()); seq.SetVelocity(new Vector3(0, 3, 0)); var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; seq.Update(2.0, frame); Assert.Equal(6.0f, frame.Origin.Y, 5); } // ── root motion into the caller Frame (G7 wiring half) ────────────── [Fact] public void Forward_PosFrames_CombinedIntoCallerFrame() { // Each crossed frame combines the node's pos_frame into the caller // Frame (retail root motion): 2 crossed frames → +2 X. var loader = new MapLoader(); loader.Add(1u, MakeAnim(5, posFrames: true)); var seq = new CSequence(loader); seq.AppendAnimation(Ad(1u)); var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; seq.Update(2.0 / 30.0, frame); // crosses frames 0 and 1 Assert.Equal(2.0f, frame.Origin.X, 5); Assert.Equal(2.0, seq.FrameNumber, 9); } // ── update entry contract (G19) ───────────────────────────────────── [Fact] public void Update_RunsApricot_TrimmingConsumedLinkNodes() { // After the link→cycle advance, apricot (called by update) frees // the consumed link node. var loader = new MapLoader(); loader.Add(1u, MakeAnim(2, hookPrefix: "link")); loader.Add(2u, MakeAnim(4, hookPrefix: "cyc")); var seq = new CSequence(loader); seq.AppendAnimation(Ad(1u)); seq.AppendAnimation(Ad(2u)); Assert.Equal(2, seq.Count); seq.Update(0.1, null); // exhausts the link, advances into the cycle Assert.Equal(1, seq.Count); // link trimmed by apricot Assert.Same(seq.FirstCyclic, seq.CurrAnim); } }