acdream/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md
Erik 8eff397801 docs(R3-W-1): CMotionInterp-completion research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R3 research (3 docs, 3,061 lines):
- r3-motioninterp-decomp.md: verbatim pseudo-C + anchors for the full R3
  scope — pending_motions lifecycle (add_to_queue 0x00527b80, MotionDone
  0x00527ec0), DoMotion 0x00528d20 + PerformMovement 0x00528e80, the whole
  jump family, HitGround 0x00528ac0 / LeaveGround 0x00528b00 (stale
  0x00529710 doc-comment corrected), enter_default_state, MovementManager
  relay surface, struct anchors, constants inventory. Negative results
  (IsAnimating / HandleUpdateTarget / CMotionInterp::HandleEnterWorld NOT
  in retail) explicitly recorded.
- r3-ace-motioninterp.md: ACE MotionInterp/MovementManager/MotionNode map
  with flagged ACE-isms (jump_is_allowed L747 NPE typo, Falling-vs-Fallen
  boundary discrepancy).
- r3-port-plan.md: 10 pinned ambiguities (headline: motion_allows_jump
  0x48 polarity INVERTED in the BN annotation — ranges are a BLOCKLIST;
  apply_current_movement dispatch gate IsThePlayer vs IsCreature), 19
  itemized gaps J1-J19, keep-list, 7-commit sequence W0-W7 ending in the
  local-player unification, exact IMotionDoneSink wiring spec vs R2 §4.

Precondition paragraph updated at vaulting: Q2 committed 98f58db9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:56:16 +02:00

44 KiB
Raw Blame History

R3 port work-list — CMotionInterp completion + local-player unification

Inputs: r3-motioninterp-decomp.md (verbatim retail extraction, same dir), r3-ace-motioninterp.md (ACE cross-ref, same dir), plan of record docs/plans/2026-07-02-retail-motion-animation-rewrite.md (stage R3), R2 boundary contract docs/research/2026-07-02-r2-motiontable/r2-port-plan.md §4, current code src/AcDream.Core/Physics/MotionInterpreter.cs (1426 lines, post-D6/S2a), src/AcDream.App/Input/PlayerMovementController.cs (1521 lines), src/AcDream.Core/Physics/InterpretedMotionFunnel.cs, GameWindow K-fix18 sites.

Precondition / state at R3 start (updated at vaulting, 2026-07-02): R1 complete (a987cad1 P6). R2 is IN FLIGHT: Q0 (dc54a3e4) + Q1 MotionState (2345da30) + Q2 Motion/CMotionTable.cs (98f58db9, 44 tests) committed; Q3 (MotionTableManager + IMotionDoneSink) in flight, Q4 (adapter cutover + queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started. RemoteMotionSink.cs still exists (217 lines). R3 commits W2+ hard-depend on R2 Q3/Q4/Q5 being shipped (the IMotionDoneSink seam, the sentinel→AnimationDone drain, and funnel-direct PerformMovement dispatch are R2 deliverables R3 consumes). W0/W1 are independent and can start while R2 finishes.


0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit)

# Ambiguity Evidence each way Pin method
A1 motion_allows_jump 0x48 polarity is INVERTED in the BN extraction's "cleaned" note. r3-motioninterp-decomp §3a annotates 0x48 = "jump allowed" and the range set as a whitelist. But the CALLER jump_is_allowed (0x005282b0, §3h) does eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7; — nonzero is returned AS THE ERROR, so 0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: [0x1000006f,0x10000078], [0x10000128,0x10000131], 0x40000008 (Fallen), [0x40000016,0x40000018], [0x4000001e,0x40000039], [0x41000012,0x41000014] (Crouch..Sleeping); everything else (incl. 0x40000015 Falling → 0 = pass) falls through. ACE instead blocks exactly Falling and never Fallen — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp acclient!CMotionInterp::motion_allows_jump log arg2+eax while user jumps during walk/run/mid-fall/crouch. Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges (ACE's enum-order dependence is the flagged fragility).
A2 jump_is_allowed pending-head peek gate. Retail (§3h full body): peek pending_motions.head_ whenever non-empty; if head.jump_error_code != 0 return it. ACE (L756): PendingMotions.Count > 1 && First.JumpErrorCode != 0 — the Count > 1 has no retail counterpart in this decomp. decomp §3h (no count check) vs ACE L756 Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 WeenieObj == null && !WeenieObj.IsCreature() NPE-typo is CONFIRMED wrong vs retail's weenie != 0 && IsCreature()==0 gate — do not copy.
A3 apply_current_movement / SetWeenieObject / SetPhysicsObject dual-dispatch gate: IsThePlayer or IsCreature? The decomp extraction (§4c) states ReportExhaustion gates on IsThePlayer() && movement_is_autonomous() and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on IsCreature. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, IsThePlayer is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down apply_raw_movement and corrupts their interpreted state from an empty raw state. decomp §4c text vs ACE L430/L497 Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes IsThePlayer from IsCreature. If still ambiguous, cdb bp with this dump on a remote-visible session. Default: IsThePlayer-based for apply_current_movement/ReportExhaustion (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical).
A4 MovementParameters bit numbering. DoMotion reads byte-1 bits: &8 → SetHoldKey (=0x800 overall), &0x20 → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) 0x1ee0f`. decomp §0/§2 vs acclient.h:31453 comment
A5 get_jump_v_z no-weenie fallback + near-zero path. BN garbles the extent < 0.0002 path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: < EPSILON → 0.0f, no-weenie → 10.0f, InqJumpVelocity false → 0.0f. Current acdream uses ACE's shape but with epsilon 0.001 (wrong — retail literal is 0.000199999995f). decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless.
A6 get_leave_ground_velocity zero-fallback matrix direction. BN reads m_fl2gv (local→global) applied to the velocity; ACE does GlobalToLocalVec(Velocity). Since the result feeds set_local_velocity (a LOCAL vector) and m_velocityVector is global, global→local is the only coherent reading — BN row/column confusion. Current Quaternion.Inverse(Orientation) transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). decomp §3d vs ACE L654-663 vs current :1011-1029 Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction.
A7 MovementManager::MotionDone uninit-edx relay + dead arg2 in CMotionInterp::MotionDone. Decompiler artifact (adjudicated in the decomp doc §6d). Port as MotionDone(uint motion, bool success) passing the completed node's motion id; the interp body reads NEITHER param in this build — pop the HEAD unconditionally, never match by motion id. Keep the params for R5 signature parity. decomp §1c/§6d Documented adjudication only.
A8 enter_default_state does NOT clear pending_motions. Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the {0, 0x41000003, 0} sentinel to whatever queue exists — no drain. ACE resets PendingMotions = new LinkedList with the author's own // ??. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. decomp §4d/§6g vs ACE L604-616 Documented adjudication; test asserts pre-existing nodes survive an enter_default_state.
A9 StopCompletely jump-snapshot quirk: motion_allows_jump is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). decomp §5a + ACE Textual pin only; golden asserts the queued node's error code reflects the pre-stop command.
A10 Error-code renumber vs current acdream WeenieError. Retail numeric usage: 8=no physics obj, 0x24=not grounded (jump_is_allowed airborne), 0x3f/0x40/0x41=combat-stance crouch/sit/sleep rejects, 0x42=combat-stance chat-emote-bit reject, 0x45=action depth ≥6, 0x47=constrained OR stamina OR bad MovementStruct.type, 0x48=motion/position blocks jump, 0x49=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). decomp §10 vs MotionInterpreter.cs:113-127, :1064 W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename.

W0 cdb capture (one session serves all of R3): bp CMotionInterp::MotionDone / add_to_queue / jump / jump_is_allowed / jump_charge_is_allowed / charge_jump / motion_allows_jump / HitGround / LeaveGround / StopCompletely / set_hold_run / SetHoldKey / enter_default_state with arg+ret logging (pattern: tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk / tap-jump / full-charge jump / running jump / standing long-jump (charge while idle, then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched (blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge (LeaveGround without jump — the momentum fallback) / land / emote-while-running (action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout (HandleExitWorld drain).


1. ITEMIZED GAPS — current vs retail (R3 scope)

Severity: BLOCKER = R3's conformance harness meaningless without it; HIGH = visible behavior wrongness / blocks R4-R6; MED = edge-visible; LOW = textual.

# Retail behavior acdream lacks/diverges on Retail anchor Current-code anchor Severity
J1 No pending_motions queue — no MotionNode, no add_to_queue (append {context_id, motion, jump_error_code}), no MotionDone (head action-class 0x10000000 → unstick + Interpreted+Raw RemoveAction, then unconditional head pop), no motions_pending, no HandleExitWorld drain loop. The R2 IMotionDoneSink signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 MotionInterpreter.cs:1394 ("Idle add_to_queue(Ready) bookkeeping lands with S3") + :1417 ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists BLOCKER
J2 No action FIFO on the states — retail RawMotionState/InterpretedMotionState each carry an actions list (ApplyMotion/RemoveMotion/AddAction/RemoveAction/GetNumActions); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's InterpretedMotionState (MotionInterpreter.cs:185-210) + LegacyRawMotionState (:148-179) are flat 6/7-field structs; the outbound RawMotionState packs an always-empty action list (register TS-24). acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair MotionInterpreter.cs:148-210; Core/Physics/RawMotionState.cs (packer type, no actions) BLOCKER
J3 DoMotion is a 12-line approximation. Retail 0x00528d20: interrupt on sign bit; SetHoldKey on bit-SetHoldKey BEFORE adjust_motion; fresh local MovementParameters re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (current_style != 0x8000003d rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and motion & 0x2000000 → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate DoInterpretedMotion(adjusted, localParams); mirror RawState.ApplyMotion(ORIGINAL id) on the mirror bit. Current: writes RawState.Forward* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. 0x00528d20 @306159; ACE L112-158 MotionInterpreter.cs:448-462 BLOCKER
J4 TWO parallel DoInterpretedMotion implementations, both partial. The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel DispatchInterpretedMotion (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no Dead → RemoveLinkAnimations, no jump_error_code compute (double motion_allows_jump check: incoming motion, then ForwardCommand if non-action) + add_to_queue, no ModifyInterpretedState param, no CurCell==null → RemoveLinkAnimations tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare sink.StopMotion): missing post-stop add_to_queue(ctx, Ready, None) + raw RemoveMotion mirror. They must MERGE into one verbatim pair. DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 MotionInterpreter.cs:473-491, :498-553, :1408-1425, :1358-1396 BLOCKER
J5 Jump gate family absent: no motion_allows_jump (0x005279e0 literal-range blocklist, A1), no jump_charge_is_allowed (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no charge_jump (0x005281c0: arms standing_longjump only when on-ground + fwd==Ready + no sidestep/turn). jump_is_allowed (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and JumpStaminaCost (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). §3a/3b/3e/3h; ACE L729-779 MotionInterpreter.cs:1053-1071; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) BLOCKER
J6 StandingLongJump armed in the WRONG function — the S2a-flagged misattribution: acdream arms it as a side effect of contact_allows_move (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in charge_jump. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. charge_jump 0x005281c0 @305448 MotionInterpreter.cs:1139-1148 HIGH
J7 jump() missing interrupt_current_movement (retail always cancels the in-flight transition first; ACE cancel_moveto). And the CONTROLLER manually calls LeaveGround() + pre-captures get_jump_v_z at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (set_on_walkable(false) → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) MotionInterpreter.cs:943-958; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) HIGH
J8 HitGround/LeaveGround bodies incomplete: both missing the creature gate (weenie==null OR IsCreature) and RemoveLinkAnimations; LeaveGround missing the state-0x400 gate, apply_current_movement(0,0) re-sync, and the autonomous flag on set_local_velocity(v, 1). This is the retail mechanism K-fix18 fakes: leaving ground strips pending link animations, so Falling engages instantly — no skipTransitionLink needed. HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 MotionInterpreter.cs:1166-1198; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 HIGH
J9 StopCompletely missing interrupt, the A9 jump snapshot, add_to_queue(0, Ready, jumpErr), and cell-null RemoveLinkAnimations; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). set_velocity(Zero) stands in for StopCompletely_Internal (acceptable, keep + note). 0x00527e40 @305208 MotionInterpreter.cs:577-602 HIGH
J10 No enter_default_state / initted: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + initted=1 + unconditional LeaveGround; nothing gates apply_current_movement/ReportExhaustion on initted. 0x00528c80 @306124 constructors at MotionInterpreter.cs:386-398 only BLOCKER
J11 apply_current_movement is a direct-velocity approximation, not the retail dual dispatch (initted gate → IsThePlayer/autonomous (A3) → apply_raw_movement(cancelMoveTo, allowJump) else apply_interpreted_movement(...)). The grounded set_local_velocity(get_state_velocity()) write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no (cancelMoveTo, allowJump) param plumbing anywhere — DisableJumpDuringLink = !allowJump feeds the jump_error_code path (ACE L538/L232). 0x00528870 @305838 (A3 pin); ACE L430-438 MotionInterpreter.cs:905-925; funnel ApplyInterpretedMovement :1358 (no params) HIGH
J12 No ReportExhaustion (initted gate + IsThePlayer/autonomous dual dispatch, both (0,0) args); no movement_is_autonomous flag on PhysicsBody; no IsThePlayer on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE NoticeHandler.RecvNotice_PrevSpellSelection breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. 0x005288d0 @305861 nothing MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events)
J13 No SetHoldKey/set_hold_run — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). HoldKey_None=1 / HoldKey_Run=2 encoding already matches acdream's HoldKey enum usage. set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 MotionInterpreter.cs:340 (bare CurrentHoldKey field, written by apply_raw_movement only) HIGH
J14 No CMotionInterp::PerformMovement 5-way dispatch with CheckForCompletedMotions after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 MotionInterpreter.cs:400-430 BLOCKER
J15 Local player motion state machine lives in PlayerMovementController, level-triggered. Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls _motion.jump + manual LeaveGround (:986-1028); outbound wire commands re-derived from input (:1264-1328) instead of read from the raw state; LocalAnimationCommand/LocalAnimationSpeed synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) BLOCKER for the stage's named deliverable
J16 Epsilons + codes: JumpExtentEpsilon = 0.001 used in get_jump_v_z AND get_leave_ground_velocity — retail is 0.000199999995f in both (§10); WeenieError names shuffled vs retail numerics (A10). §3c/§3d/§10 MotionInterpreter.cs:278, :978, :1020; :113-127 MED
J17 is_standing_still absent (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail IsAnimating/IsMovingOrAnimating. Do NOT invent a combined IsAnimating helper — retail has none (decomp §7f negative result); ACE's PhysicsObj.IsAnimating flag is an ACE-ism (retail queries motions_pending). 0x00527fa0 @305309 nothing LOW
J18 adjust_motion creature guard unwired (register TS-34): IWeenieObject.IsCreature() exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. adjust_motion @305343 gate MotionInterpreter.cs:756-758 LOW
J19 VectorUpdate remote-jump path bypasses the interp (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via SetCycle(..., skipTransitionLink:true) (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes rm.Motion.LeaveGround() (+ the funnel's existing Falling dispatch). SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain GameWindow.cs:4802-4833 HIGH

2. KEEP LIST — already matching retail (do not re-port)

Behavior Retail anchor acdream anchor
Inbound funnel dispatch order: MoveToInterpretedState (flat copy + 15-bit stamp gate + local-echo skip) / ApplyInterpretedMovement axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite 0x005289c0 / 0x00528600 (S2a, 7b0cbbda) MotionInterpreter.cs:1312-1396 — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar
contact_allows_move verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) 0x00528240 (S2a rewrite) MotionInterpreter.cs:1096-1151 — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it)
adjust_motion / apply_run_to_command / apply_raw_movement / get_state_velocity incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp D6/D6.2a (0f099bb6) MotionInterpreter.cs:654-888 — untouched except J18's one-line guard
GetMaxSpeed ×4.0 (UN-2 byte-verified vs the BN x87 dropout) 0x00527cb0 MotionInterpreter.cs:1235-1245
GetCycleVelocity Option-B forward-velocity source (MotionData.Velocity over the constant) adaptation, r03 §1.3 MotionInterpreter.cs:382, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body
StopCompletely_Internal stand-in = PhysicsObj.set_velocity(Zero) 0x0050f5a0 is CPhysicsObj-side MotionInterpreter.cs:599 — keep + register note (full port is physics-layer scope)
Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula GetPowerBarLevel 0x0056ADE0 illegible PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives
Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent L.5/L.2c/B.6 anchors in-file PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order
Auto-walk (B.6) block incl. DoMotion(Ready)/DoMotion(WalkForward) drive MovementManager case 6 stand-in PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager
Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs L.2b/D6.2b/L.1b untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation)
MotionSequenceGate (S1), InterpolationManager (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager plan "absorbed" + R2 untouched — R3 sits between MovementManager(-to-be) and the R2 queue
0x41000003 Ready sentinel / 0x10000000 action-class bit / 0x8000003d NonCombat conventions §10 already the file-wide constants

3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first

New code target: extend src/AcDream.Core/Physics/MotionInterpreter.cs in place (it IS the CMotionInterp port; plan rule 4's Motion/ dir holds the R2 table/queue classes it talks to). Tests: tests/AcDream.Core.Tests/Physics/ (existing MotionInterpreter suites + Motion/ for new fixtures). Every commit: build+test green, register rows added/retired in-commit.

W0 — pseudocode + ambiguity pinning (docs only). docs/research/2026-07-0x-motioninterp-pseudocode.md from r3-motioninterp-decomp.md, resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing; correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb session (§0 protocol) — feeds W2/W3/W5/W6 goldens. Fixture source: cdb (live retail). Deps: none (parallel with R2 completion).

W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber. (closes J2, J16-codes/A10) InterpretedMotionState/RawMotionState gain the actions list (AddAction/RemoveAction/GetNumActions/ApplyMotion/RemoveMotion per retail member set; the outbound packer's empty-list TS-24 comment updates to "populated by CMotionInterp"); fold LegacyRawMotionState into the full-field AcDream.Core.Physics.RawMotionState (one raw-state type; delete the legacy struct); MovementParameters verbatim class (ctor defaults: distance_to_object=0.6, fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield 0x1ee0f per A4 pin; named bool properties); MotionNode {ContextId, Motion, JumpErrorCode}; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix 0x24/0x47/0x48 semantics). Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green under the raw-state fold. Fixture source: synthetic. Deps: W0.

W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands). (closes J1, J17; §4 below is this commit's wiring spec) MotionInterpreter gains PendingMotions (LinkedList — register note reuses AD-34 wording), add_to_queue 0x00527b80, motions_pending 0x00527fe0, MotionDone(uint motion, bool success) 0x00527ec0 (head-peek action-class → unstick seam (no-op Action? callback until R5 StickyManager — register row) + InterpretedState.RemoveAction() + RawState.RemoveAction(); unconditional head pop; NEVER match by motion id per A7), HandleExitWorld 0x00527f30 (loop), is_standing_still 0x00527fa0, and motion_allows_jump 0x005279e0 (pure fn, pinned per A1 — needed here because every add_to_queue caller computes jump_error_code). Wire the queue's PRODUCERS into the funnel: DispatchInterpretedMotion success path computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) + add_to_queue(ctx, motion, err); ApplyInterpretedMovement's turn-stop adds add_to_queue(ctx, Ready, None) (@305775); funnel StopMotion path adds the post-stop Ready node (@305657). GameWindow: replace R2-Q4's diagnostic IMotionDoneSink binding with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH manager.HandleExitWorld() (R2) and interp.HandleExitWorld() (retail has both layers' drains). Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case suite green (dispatch unchanged, queue side effects asserted additively); end-to-end chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram). Fixture source: synthetic + W0 cdb goldens (add_to_queue arg conformance). Deps: W1 + R2 Q3/Q4 shipped.

W3 — jump family verbatim. (closes J5, J6, J7-interp-side, J16-epsilons) jump_charge_is_allowed 0x00527a50; charge_jump 0x005281c0 (arms StandingLongJump; DELETE the contact_allows_move side effect :1139-1148 — J6); get_jump_v_z (epsilon → 0.000199999995f; A5 fallback pinned); get_leave_ground_velocity (same epsilon; A6 direction confirmed); jump_is_allowed 0x005282b0 full chain (creature/ state-0x400 entry shape → 0x24; IsFullyConstrained seam on PhysicsBody (stub false + register row); pending-head peek per A2; charge → motion_allows_jump(fwd) → JumpStaminaCost (new IWeenieObject member; PlayerWeenie implements — real gating stays TS-5-deferred, row survives)); jump 0x00528780 (+interrupt_current_movement seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains IsThePlayer() (default false; PlayerWeenie true) for W4's A3 dispatch. Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit; blocked-motion jump attempts (crouch range per pinned A1 table). Fixture source: W0 cdb goldens + synthetic. Deps: W2.

W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED. (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along) HitGround 0x00528ac0 verbatim (creature gate + Gravity + RemoveLinkAnimations seam

  • apply_current_movement(false, true)); LeaveGround 0x00528b00 verbatim (velocity
  • autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations + apply_current_movement); RemoveLinkAnimations seam = Action? on MotionInterpreter, App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence); enter_default_state 0x00528c80 (A8: append sentinel, no clear; Initted field; LeaveGround tail); Initted gates apply_current_movement + ReportExhaustion; apply_current_movement becomes the verbatim dual dispatch per A3 (the direct grounded-velocity write MOVES to the controller-side call site unchanged — register row: velocity-from-state adaptation until R6 root motion); ReportExhaustion 0x005288d0 (A3 gate; movement_is_autonomous flag added to PhysicsBody, set true at the local-player chokepoint, false on DR-applied updates); SetHoldKey 0x00528bb0 + set_hold_run 0x00528b70; SetPhysicsObject/SetWeenieObject re-apply pattern. PlayerMovementController transition wiring: grounded→airborne edge (section 5, :1245-1257) now calls _motion.LeaveGround(); the jump-fire block stops calling LeaveGround manually — jump() sets on_walkable(false) and the SAME frame's transition detection fires LeaveGround (ordering test); JumpAction wire velocity read from the LeaveGround-computed vector (get_leave_ground_velocity at fire time — byte-identical values, source unified). K-fix18 DELETION (both sites + the parameter): GameWindow :4817-4833 remote VectorUpdate → rm.Motion.LeaveGround() replaces the forced SetCycle (J19); GameWindow :10194-10224 skipLink computation + arg deleted; AnimationSequencer.SetCycle's skipTransitionLink parameter + its post-dispatch link-clear (whatever shape R2-Q4 left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row kept → R3") retired — if no row exists, the sweep note records it was comment-only. Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock); ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering; enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling instantly WITHOUT K-fix18. Fixture source: synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces). Deps: W3 (+ R2-Q4 committed for the sequencer seam shape).

W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush. (closes J3, J4, J9, J14) DoMotion 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45; raw mirror ORIGINAL id); StopMotion 0x00528530 (mirror shape, no gates, raw RemoveMotion ORIGINAL id); StopCompletely 0x00527e40 (A9 verbatim incl. quirk + exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); merge legacy DoInterpretedMotion/StopInterpretedMotion overloads with the funnel Dispatch backend into ONE verbatim DoInterpretedMotion(uint, MovementParameters) / StopInterpretedMotion(uint, MovementParameters) (StandingLongJump state-only branch; Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy = PhysicsObj.CellId == 0); DELETE ApplyMotionToInterpretedState (:1253-1282) + the legacy overloads; PerformMovement gains the per-op CheckForCompletedMotions seam call (bound to the entity's MotionTableManager UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch. Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a 183-case suite green under the merged backend (assertions unchanged — dispatch order is funnel-owned); zero-tick flush test (stop-with-no-link completes same call). Fixture source: W0 cdb goldens + suite. Deps: W2+W4 (+ R2-Q5 so the funnel's sink IS PerformMovement dispatch — otherwise the merge has two consumers).

W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices). (closes J15) PlayerMovementController sheds the motion state machine; edge-driven retail input:

  • DIES in the controller: per-frame RawMotionState rebuild + apply_raw_movement call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4); localAnimCmd/LocalAnimationSpeed synthesis (:1283-1296, :1340-1359 change detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides — the sequencer is now driven by the interp's own dispatch, not by UpdatePlayerAnimation); MovementResult.LocalAnimationCommand/LocalAnimationSpeed fields (and GameWindow consumers).
  • MOVES to MotionInterpreter calls (edge-driven): input edges → PerformMovement: W/S press → DoMotion(WalkForward/WalkBackward, params); release → StopMotion(same); strafe/turn keys likewise; Shift edge → set_hold_run(run, interrupt:true) (retail 0x006b33ca shape); jump release → jump(extent) (charge accumulation STAYS controller-side, AP-24). The controller keeps a tiny prev-key-state edge detector — that IS retail's CommandInterpreter altitude.
  • STAYS in the controller (input/camera/physics/wire): Yaw + mouse turn (:938-946); keyboard-turn Yaw integration reading _motion.InterpretedState.TurnCommand/TurnSpeed (:932-937, unchanged); grounded velocity write from get_state_velocity (:966-976 — the R6-deferred adaptation); physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8); auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 DoMotion(Ready) arrival-idle becomes StopCompletely() — retail's teleport idle is a full stop; note in commit); outbound section 6 now READS _motion.RawState (commands + per-axis hold keys) instead of re-deriving from input — wire bytes identical (golden-byte packer tests prove it).
  • GameWindow call sites that change: UpdatePlayerAnimation(result) (:10062-10234) DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5 sink shape); the airborne-Falling override falls out of contact_allows_move + apply_current_movement (retail mechanism, already live in the funnel); the #45 sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are re-verified against) the CMotionTable-driven speedMod path — expected-diff annotations required; :4344 ApplyServerRunRate retargets to my_run_rate + apply_current_movement (same effect, no InterpretedState poke); :7953-7971 JumpAction build reads the W4-unified launch vector; :7975 call deleted with UpdatePlayerAnimation. Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle identities + speeds, expected-diffs documented (Falling engage now via link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION + observer view; ONE user visual pass (walk/run/toggle/strafe/turn/jump instant Falling/landing/emote-mid-run — the stage acceptance). Fixture source: pre-cutover recorded traces + golden-byte + suite. Deps: W5.

W7 — register sweep + roadmap + digest (docs/cleanup only). Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard), plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add): TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate), TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30 (re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4), grounded velocity-from-state write (→R6 root motion), managed LinkedList vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE. Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end). Deps: W6.

Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates using queue → transitions using gates → dispatchers using all → cutover). W4 and W6 both touch GameWindow — single-agent each.


4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam

R2's contract (r2-port-plan §4) delivers: CSequence G5 gate → AnimationDoneSentinel → GameWindow anim tick drain (:9876-9890 area) → manager.AnimationDone(true) per sentinel + manager.UseTime() once per tick → MotionTableManager countdown pop → action-class head pops the MANAGER's own MotionState.RemoveActionHead() (R2-OWNED) → IMotionDoneSink.MotionDone(uint motion, bool success) — bound in R2-Q4 to a diagnostic recorder.

R3-W2 replaces the recorder:

MotionTableManager (per entity)                      [R2-Q3, exists]
  └─ IMotionDoneSink.MotionDone(motion, success)     [R2 seam — signature UNCHANGED]
       └─ R3: the entity's MotionInterpreter.MotionDone(motion, success)
            [stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 →
             MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone
             0x00527ec0 — both intermediates are null-guarded relays with ZERO
             logic; R5 inserts MovementManager between manager and interp
             WITHOUT changing this behavior]
            body: head-peek pending_motions;
                  head.Motion & 0x10000000 → unstick seam (no-op → R5)
                                           + InterpretedState.RemoveAction()
                                           + RawState.RemoveAction();
                  pop head UNCONDITIONALLY (A7: motion id + success are IGNORED —
                  positional protocol, never match-by-id, never branch on success)

Binding points (App, per entity, at creation):

  • Remote: where RemoteMotion is constructed (each remote already owns a MotionInterpreterremoteMot.Motion, GameWindow.cs:4646): the same construction that gives the entity its MotionTableManager (R2-Q4/Q5) passes sink: remoteMot.Motion (MotionInterpreter implements IMotionDoneSink directly).
  • Local player: PlayerMovementController exposes its MotionInterpreter (new internal property Motion); GameWindow binds the player entity's manager to it when the player's AnimatedEntity/sequencer is created (same site as AttachCycleVelocityAccessor, :384).

Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b):

  1. Two queues, never merged: MotionTableManager.pending_animations (CPartArray side, duration-in-ticks) vs CMotionInterp.pending_motions (movement side, waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000 bit: manager's MotionState action FIFO (popped in R2's AnimationDone), InterpretedState.actions + RawState.actions (popped in R3's MotionDone).
  2. Tick placement provisional until R6: the sentinel drain + UseTime stay at the R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush: CMotionInterp.PerformMovement calls the manager's CheckForCompletedMotions after every op (W5), which fires MotionDone same-call for zero-tick nodes.

Success-flag adjudication (closes r2-plan §4's warning): retail CMotionInterp::MotionDone never reads the flag in this build — R3's jump/HitGround logic does NOT key off it after all. The flag's only real consumers are server/weenie-side (OnMotionDone) and the R2 manager's own countdown semantics. Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded true from CheckForCompletedMotions) for R5 signature parity; document as inert at the interp.

Enter/exit world: exit paths call manager.HandleExitWorld() (drains pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step) THEN interp.HandleExitWorld() (drains any remainder — retail runs both layers' drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld independently). Enter-world: manager-side only (remove_all_link_animations + drain); MovementManager::HandleEnterWorld's interp relay is the mis-symbolicated tailcall (decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative result, decomp §6e/§1d).


5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents)

  • CPhysicsObj::IsAnimating / IsMovingOrAnimatingnot in this PDB (decomp §7f). ACE's PhysicsObj.IsAnimating bool is an ACE-ism. acdream queries motions_pending() / is_standing_still() / CPartArray.HasAnims individually.
  • MovementManager::HandleUpdateTarget — not in this PDB (§6j); likely MoveToManager-internal (R4). Do not stub.
  • CMotionInterp::HandleEnterWorld — does not exist (§6e); enter-world work is manager/CPartArray-side only.
  • MakeMoveToManager / MoveToManager internals — R4. R3 leaves the B.6 auto-walk + interrupt_current_movement/cancel_moveto as registered no-op seams.
  • NoticeHandler::RecvNotice_PrevSpellSelection (HandleEnterWorld/ReportExhaustion relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research note only (no ACE reference exists — decomp-direct if ever needed).
  • LeaveGround address is 0x00528b00 — the 0x00529710 in MotionInterpreter.cs's doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations (several header comments cite old FUN_ addresses — sweep them to named symbols).