feat(R3-W2): pending_motions lifecycle + the MotionDone consumer — the R2 seam lands (closes J1, J17)

MotionInterpreter implements IMotionDoneSink: pending_motions
(LinkedList<MotionNode>, AD-34 wording) + add_to_queue 0x00527b80 +
motions_pending 0x00527fe0 + MotionDone 0x00527ec0 verbatim (action-class
head → UnstickFromObject no-op seam (→R5 StickyManager) + BOTH states'
action-FIFO pops; head popped UNCONDITIONALLY — A7: never match-by-id,
params unread in this build) + HandleExitWorld 0x00527f30 (the raw
decompile's null-physics-obj branch would infinite-loop if translated
literally — adjudicated as retail dead code, documented on the method) +
is_standing_still 0x00527fa0 + MotionAllowsJump 0x005279e0 with the
A1-pinned literal blocklist (28-case boundary table test: Fallen blocks,
Falling passes — ACE's transposition NOT copied).

Queue producers wired into the funnel per the decomp anchors
(re-verified from the raw text): DispatchInterpretedMotion's success
path computes jump_error_code via retail's double-check shape
(motion_allows_jump(motion), then forward_command when non-action;
the DisableJumpDuringLink params leg is TODO(W5) — no MovementParameters
threaded yet) + add_to_queue; apply_interpreted_movement's turn-stop
re-queues the Ready node (@305766-305785 — the plan's producer #2 and #3
are the SAME site; StopInterpretedMotion's internal add_to_queue @305657
is W5 scope). ContextId=0 with TODO(W5): retail's own compiled code
reads an UNINITIALIZED local for it at this call site.

GameWindow: MotionDoneTarget now binds the entity's REAL consumer —
player via PlayerMovementController.Motion (new internal property),
remotes via RemoteMotion.Motion — resolved AT FIRE TIME (a remote's
RemoteMotion can be created after its first anim tick; an eager capture
would drop completions forever). Despawn runs BOTH layers' exit-world
drains (manager then interp) per §4. AD-36 narrowed (consumed for
creature-class; doors/statics recorder-only until R5).

51 new conformance cases incl. the end-to-end chain test:
MotionTableManager.AnimationDone → sink → interp pending head pops in
step. 183-case observer-trace + funnel suites green unchanged (queue
side effects additive). Full suite: 3,582 passed.

Implemented by a dedicated agent (MotionInterpreter side) against the
W0-pinned spec; producer placements + MotionAllowsJump branch algebra
independently reviewed against the raw decomp; GameWindow wiring by the
orchestrator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-02 22:24:56 +02:00
parent 8664959152
commit 371679915e
5 changed files with 696 additions and 13 deletions

View file

@ -4183,12 +4183,15 @@ public sealed class GameWindow : IDisposable
_worldState.RemoveEntityByServerGuid(serverGuid);
_worldGameState.RemoveById(existingEntity.Id);
// R2-Q5: retail's exit-world drain — every pending queue entry fires
// MotionDone(success:false) before the object goes away
// (MotionTableManager::HandleExitWorld 0x0051bda0; R3 adds the
// CMotionInterp-side drain alongside per r3-port-plan §4).
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
// independently (r3-port-plan §4): the manager's (each pending
// animation fires MotionDone(success:false) → the bound interp pops
// in step) THEN the interp's own (flushes any remainder —
// CMotionInterp::HandleExitWorld 0x00527f30).
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
aeGone.Sequencer?.Manager.HandleExitWorld();
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
rmGone.Motion.HandleExitWorld();
_animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
@ -9876,17 +9879,37 @@ public sealed class GameWindow : IDisposable
}
seqFrames = ae.Sequencer.Advance(dt);
// R2-Q4: bind the MotionDone diagnostic recorder once per
// sequencer (the IMotionDoneSink seam's interim consumer —
// R3 binds MotionInterpreter.MotionDone here instead).
// R3-W2: bind the MotionDone seam once per sequencer to the
// entity's REAL consumer — the MotionInterpreter's
// pending_motions pop (retail CPhysicsObj::MotionDone
// 0x0050fdb0 → MovementManager → CMotionInterp::MotionDone
// 0x00527ec0; r3-port-plan.md §4). Remotes bind their
// RemoteMotion interp; the local player binds the
// controller's; interp-less entities (doors, statics) keep
// the diagnostic recorder only.
if (ae.Sequencer.MotionDoneTarget is null)
{
uint mdGuid = serverGuid;
// Resolve the interp AT FIRE TIME, not bind time: a
// remote's RemoteMotion is created on its first UM/UP,
// which can arrive AFTER the first anim tick — an
// eagerly-captured null would silently drop completions
// forever. MotionDone fires per completed motion (rare),
// so the dictionary lookup is negligible.
ae.Sequencer.MotionDoneTarget = (m, ok) =>
{
AcDream.Core.Physics.MotionInterpreter? interp = null;
if (mdGuid != 0 && mdGuid == _playerServerGuid)
interp = _playerController?.Motion;
else if (mdGuid != 0
&& _remoteDeadReckon.TryGetValue(mdGuid, out var rmFire))
interp = rmFire.Motion;
interp?.MotionDone(m, ok);
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
System.Console.WriteLine(
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok}");
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok} "
+ $"pending={(interp?.MotionsPending() ?? false)}");
};
}