using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using Xunit; using Xunit.Abstractions; namespace AcDream.Core.Tests.Physics.Motion; // ───────────────────────────────────────────────────────────────────────────── // #174 pin (2026-07-05): the RemoveLinkAnimations seam must be retail // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to // CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager:: // HandleEnterWorld 0x0051bdd0: CSequence::remove_all_link_animations PLUS a // full pending_animations drain (`while (head) AnimationDone(0)`), each pop // relaying MotionDone → CMotionInterp pops its pending_motions node in // lockstep. // // The pre-fix binding was the bare sequence strip: every LeaveGround (jump) // removed the link animations that queued MotionTableManager nodes were // counting down on, orphaning them (NumAnims > 0, animations gone). Both // queues then dammed permanently — MotionsPending() never drained at rest — // and BeginTurnToHeading/BeginMoveForward (retail 0x00529b90 motions_pending // gate) starved every armed moveto: ACE's walk-to-door mt-6 armed but the // body never walked; the close-range Use turn never completed so the // deferred action was silently eaten. Live evidence: launch-174-autowalk.log // (last player pending=False at the first MovementJump press; old jump // motions still completing at rest minutes later). // ───────────────────────────────────────────────────────────────────────────── public class Issue174LinkStripDrainTests { private readonly ITestOutputHelper _out; public Issue174LinkStripDrainTests(ITestOutputHelper output) => _out = output; /// /// The jam repro: queue motions (link + cycle nodes land in BOTH the /// interp's pending_motions and the manager's pending_animations), then /// fire the LeaveGround-side seam. With the retail HandleEnterWorld /// binding both queues drain to empty; the pre-fix bare-strip binding /// left both non-empty forever. /// [Fact] public void RemoveLinkAnimationsSeam_DrainsBothQueues() { var h = new RemoteChaseHarness(_out); // Drive a motion burst — walk, run, stop — the shape a player's // pre-jump input produces. Each successful dispatch pairs an interp // node with a manager node. var p = new MovementParameters(); h.Interp.DoMotion(MotionCommand.WalkForward, p); h.Interp.set_hold_run(true, interrupt: false); h.Interp.StopMotion(MotionCommand.WalkForward, p); Assert.True(h.Interp.MotionsPending(), "precondition: the burst must leave pending interp nodes"); Assert.NotEmpty(h.Seq.Manager.PendingAnimations); // The LeaveGround seam (retail CMotionInterp::LeaveGround 0x00528b00 // fires CPhysicsObj::RemoveLinkAnimations). h.Interp.RemoveLinkAnimations!.Invoke(); Assert.False(h.Interp.MotionsPending(), "HandleEnterWorld's drain must pop every pending interp node " + "(retail: each AnimationDone(0) relays MotionDone)"); Assert.Empty(h.Seq.Manager.PendingAnimations); } /// /// The post-jump livability pin: after the seam fires mid-activity, a /// NEW moveto-style dispatch must be able to queue and complete — the /// #174 symptom was that BeginTurnToHeading's motions_pending gate never /// re-opened after a jump, permanently starving armed movetos. /// [Fact] public void AfterSeamDrain_NewMotionsQueueAndComplete() { var h = new RemoteChaseHarness(_out); var p = new MovementParameters(); // Pre-jump activity, then the jump's LeaveGround strip+drain. h.Interp.DoMotion(MotionCommand.WalkForward, p); h.Interp.RemoveLinkAnimations!.Invoke(); Assert.False(h.Interp.MotionsPending()); // A fresh dispatch (the armed moveto's turn) queues... h.Interp.DoMotion(MotionCommand.TurnRight, p); Assert.True(h.Interp.MotionsPending()); // ...and the normal completion path (the manager's queue feeding // MotionDone) drains it — the gate re-opens. while (h.Seq.Manager.PendingAnimations.GetEnumerator() is var e && e.MoveNext()) h.Seq.Manager.AnimationDone(success: true); h.Seq.Manager.CheckForCompletedMotions(); Assert.False(h.Interp.MotionsPending(), "the normal AnimationDone → MotionDone chain must drain the new node"); } }