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>
44 KiB
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));LeaveGround0x00528b00 verbatim (velocity- autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations +
apply_current_movement);
RemoveLinkAnimationsseam =Action?on MotionInterpreter, App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence);enter_default_state0x00528c80 (A8: append sentinel, no clear;Inittedfield; LeaveGround tail);Inittedgates apply_current_movement + ReportExhaustion;apply_current_movementbecomes 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);ReportExhaustion0x005288d0 (A3 gate;movement_is_autonomousflag added to PhysicsBody, set true at the local-player chokepoint, false on DR-applied updates);SetHoldKey0x00528bb0 +set_hold_run0x00528b70;SetPhysicsObject/SetWeenieObjectre-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'sskipTransitionLinkparameter + 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_movementcall (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4);localAnimCmd/LocalAnimationSpeedsynthesis (: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/LocalAnimationSpeedfields (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 fromget_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 :825DoMotion(Ready)arrival-idle becomesStopCompletely()— 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 frominput— 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; :4344ApplyServerRunRateretargets tomy_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
RemoteMotionis constructed (each remote already owns aMotionInterpreter—remoteMot.Motion, GameWindow.cs:4646): the same construction that gives the entity its MotionTableManager (R2-Q4/Q5) passessink: remoteMot.Motion(MotionInterpreter implements IMotionDoneSink directly). - Local player:
PlayerMovementControllerexposes itsMotionInterpreter(new internal propertyMotion); GameWindow binds the player entity's manager to it when the player's AnimatedEntity/sequencer is created (same site asAttachCycleVelocityAccessor, :384).
Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b):
- Two queues, never merged:
MotionTableManager.pending_animations(CPartArray side, duration-in-ticks) vsCMotionInterp.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). - 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.PerformMovementcalls 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/IsMovingOrAnimating— not in this PDB (decomp §7f). ACE'sPhysicsObj.IsAnimatingbool is an ACE-ism. acdream queriesmotions_pending()/is_standing_still()/CPartArray.HasAnimsindividually.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_movetoas 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).LeaveGroundaddress 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).