fix #174: RemoveLinkAnimations seam is HandleEnterWorld (strip + drain)

Retail CPhysicsObj::RemoveLinkAnimations (0x0050fe20) is a tailcall to
CPartArray::HandleEnterWorld (0x00517d70) -> MotionTableManager::
HandleEnterWorld (0x0051bdd0): remove_all_link_animations PLUS a full
pending_animations drain (while (head) AnimationDone(0)), each pop
relaying MotionDone so CMotionInterp pops its pending_motions node in
lockstep. acdream bound the seam to the bare sequence strip, so every
jump's LeaveGround removed the animations that queued manager nodes
were counting down on — orphaning them (NumAnims>0, anims gone) and
permanently damming BOTH queues. MotionsPending() then never drained
(probe round: last player pending=False at the first MovementJump
press; old jump motions still completing at rest minutes later) and
BeginTurnToHeading/BeginMoveForward's verbatim motions_pending gates
starved every armed moveto: ACE's mt-6 walk-to-door armed but the body
never walked (wire-proven, seqs 98-101); the close-range use turn
never completed so the deferred action was silently eaten. Doors only
worked on a fresh session (shallow queue).

Rebind both production sites (remote EnsureRemoteMotionBindings +
the player's EnterPlayerModeNow block) to Manager.HandleEnterWorld();
the sequencer wrapper was a pure passthrough so the manager call is a
strict superset. All six interp seam sites (LeaveGround, HitGround,
Dead, and the detached-object guards) are the same retail chain.
Harness mirrors updated; pins: Issue174LinkStripDrainTests (the seam
drains both queues; fresh motions queue and complete after). Suites:
Core 2535 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 16:33:41 +02:00
parent 62b6fa52e8
commit b54555da62
6 changed files with 146 additions and 7 deletions

View file

@ -46,9 +46,24 @@ Copy this block when adding a new issue:
--- ---
## #174 — Door Use dies after the first attempt: close-range deferred uses silently swallowed (player MoveToManager never completes) ## #174 — Door Use dies after the first jump: the RemoveLinkAnimations seam stripped animations without retail's queue drain
**Status:** OPEN — root under investigation (wire + log evidence in hand; needs one `ACDREAM_PROBE_AUTOWALK=1` round to pick between the two candidate mechanisms) **Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m).
**FIX:** the `MotionInterpreter.RemoveLinkAnimations` seam is retail
`CPhysicsObj::RemoveLinkAnimations` 0x0050fe20 — a tailcall to
`CPartArray::HandleEnterWorld` 0x00517d70 → `MotionTableManager::
HandleEnterWorld` 0x0051bdd0: strip the sequence's link animations AND drain
`pending_animations` completely (each pop relays MotionDone → the interp pops
its `pending_motions` node in lockstep). acdream bound the seam to the BARE
sequence strip (`RemoveAllLinkAnimations`), so every jump's LeaveGround
removed the animations that queued manager nodes were counting down on —
orphaning them and permanently damming BOTH queues; `MotionsPending()` then
starved every armed moveto (the far-range walk-to-door and the close-range
use turn — both door faces below). Rebound at both production sites
(GameWindow remote bindings + the player's EnterPlayerModeNow block) to
`Manager.HandleEnterWorld()`; harness mirrors updated; pins
`Issue174LinkStripDrainTests` (seam drains both queues; new motions queue +
complete after). The `UseDone` (0x01C7) display gap stays open below.
**Severity:** HIGH (can't open doors reliably → blocks the #137 door acceptance + normal play) **Severity:** HIGH (can't open doors reliably → blocks the #137 door acceptance + normal play)
**Filed:** 2026-07-05 **Filed:** 2026-07-05
**Component:** interaction — B.4b Use pipeline / AP-23 speculative moveto deferral (R5-V5 facade) **Component:** interaction — B.4b Use pipeline / AP-23 speculative moveto deferral (R5-V5 facade)

View file

@ -4288,7 +4288,20 @@ public sealed class GameWindow : IDisposable
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
}; };
rm.Motion.DefaultSink = rm.Sink; rm.Motion.DefaultSink = rm.Sink;
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations; // #174 (2026-07-05): the RemoveLinkAnimations seam is retail
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
// CPartArray::HandleEnterWorld 0x00517d70 →
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
// sequence's link animations AND drain pending_animations
// completely (each pop fires MotionDone → CMotionInterp pops its
// pending_motions node in lockstep). The previous binding was the
// bare sequence strip — every LeaveGround (jump) stripped the
// animations that queued manager nodes were counting down on,
// orphaning them (NumAnims>0, anims gone) and permanently damming
// BOTH queues: MotionsPending() never drained again, so every
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
// starved — the "door only works on a fresh session" bug.
rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld();
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
@ -13697,7 +13710,14 @@ public sealed class GameWindow : IDisposable
// R3-W4: bind the player interp's retail seams to the player // R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here // sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement). // (the K-fix18 replacement).
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations; // #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
// queue drain), not the bare sequence strip — see the remote
// bind site's comment (retail 0x0050fe20 → 0x00517d70 →
// 0x0051bdd0). The bare strip orphaned every pending manager
// node on each jump's LeaveGround; the local player's
// pending_motions then never drained and every armed moveto
// starved (#174: doors unusable after the first jump/run).
_playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
_playerController.Motion.InitializeMotionTables = _playerController.Motion.InitializeMotionTables =
() => playerSeq.Manager.InitializeState(); () => playerSeq.Manager.InitializeState();
_playerController.Motion.CheckForCompletedMotions = _playerController.Motion.CheckForCompletedMotions =

View file

@ -122,7 +122,9 @@ public class PlayerMoveToCutoverTests
// the A9 pending_motions node (a null sink orphans it and the // the A9 pending_motions node (a null sink orphans it and the
// MoveToManager wait-for-anims gate never opens — the live stall). // MoveToManager wait-for-anims gate never opens — the live stall).
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations; // #174: production wiring — HandleEnterWorld (strip + drain), not
// the bare sequence strip (which orphaned pending manager nodes).
controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState(); controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);

View file

@ -103,7 +103,9 @@ public class W6EdgeDrivenMovementTests
// dispatch, else its A9 pending_motions node is orphaned). // dispatch, else its A9 pending_motions node is orphaned).
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
var s = seq; var s = seq;
controller.Motion.RemoveLinkAnimations = s.RemoveAllLinkAnimations; // #174: production wiring — HandleEnterWorld (strip + drain), not
// the bare sequence strip (which orphaned pending manager nodes).
controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld();
controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState(); controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState();
controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions; controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);

View file

@ -0,0 +1,97 @@
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;
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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");
}
}

View file

@ -174,7 +174,10 @@ internal sealed class RemoteChaseHarness
TurnStopped = () => ObservedOmega = Vector3.Zero, TurnStopped = () => ObservedOmega = Vector3.Zero,
}; };
Interp.DefaultSink = Sink; Interp.DefaultSink = Sink;
Interp.RemoveLinkAnimations = Seq.RemoveAllLinkAnimations; // #174: production binds the seam to Manager.HandleEnterWorld
// (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) —
// the bare sequence strip orphaned pending manager nodes.
Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld();
Interp.InitializeMotionTables = () => Seq.Manager.InitializeState(); Interp.InitializeMotionTables = () => Seq.Manager.InitializeState();
Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions;