acdream/docs/research/2026-07-03-r4-moveto/r4-port-plan.md
Erik 988304e13b docs(R4-V-1): MoveToManager research base — decomp extraction + ACE cross-ref + port work-list
Workflow-produced R4 research (3 docs, 2,541 lines):
- r4-moveto-decomp.md: all 33 MoveToManager members verbatim
  (0x00529010-0x0052a987) + the MovementManager type-6/7/8/9 relay +
  MovementParameters::get_command. HandleUpdateTarget CONFIRMED on
  MoveToManager (0x0052a7d0 — closes the R3 negative result; object
  moves are DEFERRED until the first target-update callback). Walk-vs-
  run = the CanCharge rule exactly (can_charge OR can_run && dist-gap >
  walk_run_threshhold, riding hold_key_to_apply into DoInterpretedMotion
  — MoveToManager never calls set_hold_run). Sticky handoff located
  (empty queue + 0x80 → StickTo). fail_progress_count is WRITE-ONLY in
  retail (no give-up threshold — do not invent one). 8-class BN
  artifact ledger.
- r4-ace-moveto.md: 16 flagged ACE-isms, all retail-verified — incl. a
  stale-member read (MoveToPosition checks the PREVIOUS move's
  UseFinalHeading), a field transposition (InitializeLocalVars zeroes
  DistanceToObject where retail zeroes the flags word), a dropped
  BeginNextNode (ACE turns start one tick late), a UseTime gate
  inversion, and the canceling CanCharge default+fast-path pair.
  Blast radius: acdream has THREE independent approximations of this
  one mechanism (DriveServerAutoWalk / RemoteMoveToDriver /
  ServerControlledLocomotion) + an outdated chase/flee claim in
  RemoteMoveToDriver's class doc.
- r4-port-plan.md: 7 pins (P1 blocker: retail interrupts current
  movement on EVERY UM but ACE sends a companion mt-0 echo after each
  MoveTo — needs a discriminator pin before V5; P3: heading_diff's
  TurnLeft mirror needs one Ghidra decompile of 0x00528fb0; P4:
  type-6/8 moves need a minimal TargetTracker adapter until R5),
  17 gaps M1-M17, commits V0-V6. Retires AD-8/AD-9/AP-8/AP-9 + TS-36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:31:12 +02:00

46 KiB
Raw Blame History

R4 port work-list — MoveToManager verbatim + the three-approximation cutover

Inputs: r4-moveto-decomp.md (verbatim retail extraction, same dir), r4-ace-moveto.md (ACE cross-ref + blast radius, same dir), plan of record docs/plans/2026-07-02-retail-motion-animation-rewrite.md (stage R4), R3 pins docs/research/2026-07-02-r3-motioninterp/W0-pins.md (A4 MovementParameters masks — used as-is), current code src/AcDream.Core/Physics/RemoteMoveToDriver.cs (340 lines, static), src/AcDream.Core/Physics/ServerControlledLocomotion.cs (87 lines), src/AcDream.App/Input/PlayerMovementController.cs (1604 lines, post-R3-W6; B.6 auto-walk block :275-:766 + :896), src/AcDream.App/Rendering/GameWindow.cs (local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick :9594-9682, speculative install :11915/:12004/:12122), src/AcDream.Core.Net/Messages/UpdateMotion.cs (mt 6/7 parse :252-356), src/AcDream.Core/Physics/Motion/MovementParameters.cs (R3-W1, flags+scalars only), src/AcDream.Core/Physics/MotionInterpreter.cs (R3-complete: DoInterpretedMotion :2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118, InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585).

Precondition / state at R4 start: R1/R2/R3 SHIPPED (R3 through W6 fb7beb70; R2+R3 share one pending visual pass — R4 code work does not block on it). The R3 seams R4 consumes all exist: DoInterpretedMotion(uint, MovementParameters) / StopInterpretedMotion(uint, MovementParameters) / adjust_motion(ref,ref,HoldKey) (the entire retail _DoMotion shape), MotionsPending(), TransientStateFlags.Contact on PhysicsObj, HitGround/LeaveGround (W4), the InterruptCurrentMovement no-op Action? seam (register TS-36 — "R4 is where cancel_moveto lands"), and MovementParameters with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge false). What does NOT exist: any MoveToManager, the command-selection family (get_command/towards_and_away/get_desired_heading), MovementType values 0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any heading/cylinder-distance helper in Core. MovementManager itself is R5 — R4 binds MoveToManager to the interp directly and keeps type-dispatch at the existing call sites (same "null-guarded relay inserted later without behavior change" pattern R3 §4 used for MotionDone).


0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit)

# Ambiguity Evidence each way Pin method
P1 ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine. Retail MovementManager::unpack_movement (0x00524440 @300566-300598) calls CPhysicsObj::interrupt_current_movement (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (launch-slice2.log; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). decomp §2f head vs GameWindow:4534-4546 trace note; ACE Player.OnMoveComplete/MoveToChain broadcast code (a) Re-capture the echo with ACDREAM_PROBE_AUTOWALK=1 + ACDREAM_DUMP_MOTION=1 and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's current_command (post-adjust) while is_moving_to() — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a).
P2 get_desired_heading (0x0052aad0) 180/0 constants. BN body is an x87 setcc garble (decomp §5e); ACE gives WalkForward|RunForward → movingAway ? 180 : 0, WalkBackwards → movingAway ? 0 : 180, default 0. The command GROUPING is visible in the BN text; the constants are not. decomp §5e vs ACE MovementParameters.cs:186-198 One Ghidra MCP decompile of 0x0052aad0 (/decompile_function?address=0x0052aad0) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc.
P3 heading_diff (0x00528fb0) third-arg mirror — the two research docs CONTRADICT. r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has if (result > EPSILON && motion != TurnRight) result = 360 - result and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: HandleTurnToHeading @0052a1bc passes the LIVE current_command (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360raw; without it, the progress window (ε, 180) reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the previous_heading update path differs). decomp §5g vs ACE MoveToManager.cs:817-828 Ghidra decompile of 0x00528fb0 — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on acclient!heading_diff logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned.
P4 TargetManager scope — what feeds HandleUpdateTarget for type 6/8. Retail: set_target registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via receive_target_updateHandleUpdateTarget (decomp §9f; ACE TargetManager.cs with a 0.5 s HandleTargetting cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, initialized never flips and type-6/8 moves never start (UseTime gate §6a). decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) DECISION (made here, confirm in V0): R4 ships a minimal App-side TargetTracker adapter, not a TargetManager port: on set_target(0, tlid, 0.5, 0) GameWindow's entity table delivers one immediate TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); clear_target unsubscribes; despawn/teleport of the target delivers status=ExitWorld/Teleported (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). set_target_quantum is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting TargetManager::SetTarget/ReceiveUpdate/HandleTargetting from the raw (grep TargetManager:: — est. ~200 lines) and porting verbatim in V4 instead.
P5 Position::heading convention + coordinate space. MoveToManager consumes Position::heading(from, to) (degrees), Frame::get_heading/set_heading, Position::distance, Position::cylinder_distance. acdream has NO Core heading helper (only SceneryHelpers.SetHeading); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire DesiredHeading is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via OriginToWorld, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or use_final_heading faces the wrong way. acclient Position::heading (not in this extraction); RemoteMoveToDriver.OriginToWorld :263-277; L.2b outbound heading packers (already convert yaw→AC heading) Grep the named raw for Position::heading + Frame::get_heading and pin the formula (expected: heading = fmod(450 atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips.
P6 TurnToObject (mt 8) wire layout. Decomp §2f case 8 reads object_id dword, then a wire_heading dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes desired_heading = wire_heading and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer Read ACE's MoveToObject/TurnToObject GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes).
P7 Remote contact for the UseTime gate. MoveToManager::UseTime gates on transient_state & 1 (CONTACT). Remotes' PhysicsObj.TransientState is maintained well enough that R3's funnel contact_allows_move works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) Code read of the remote PhysicsBody state writers + one ACDREAM_DUMP_MOTION smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate.

V0 also commits the research docs into the repo (docs/research/2026-07-03-r4-movetomanager/: the decomp extraction, the ACE cross-ref, this plan, and a V0-pins.md in the W0-pins format), and fixes the two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57 ("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1; the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch.

V0 cdb capture (optional, non-blocking): all seven pins are textually resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens anyway: bp MoveToManager::PerformMovement / BeginMoveForward / BeginTurnToHeading / HandleMoveToPosition / HandleUpdateTarget / MovementParameters::get_command (args+ret) while the user runs: use a door at range (MoveToObject + arrival), use while facing away (TurnToHeading node first), let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic + ACE-writer fixtures suffice without it.


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

Severity: BLOCKER = stage deliverable impossible without it; HIGH = visible behavior wrongness / blocks R5-R6; MED = edge-visible; LOW = textual.

# Retail behavior acdream lacks/diverges on Retail anchor Current-code anchor Severity
M1 No MoveToManager exists — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no pending_actions node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: DriveServerAutoWalk (local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart (animation seed). struct acclient.h:31473; whole extraction §§1-7 RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 BLOCKER
M2 Command-selection family absent: get_command (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk || distdto > threshhold 15) → Run else walk), towards_and_away (0x0052a9a0 — WalkBackwards inside the min band), get_desired_heading (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). PlanMoveToStart has NO distance logic at all. decomp §5c/§5d/§5e PlayerMovementController.cs:452-487 (_autoWalkInitiallyRunning one-shot), :701-706; ServerControlledLocomotion.cs:35-53 BLOCKER
M3 No node plan ([TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]? with BeginNextNode stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the use_final_heading (0x40) node is entirely missing — wire DesiredHeading is parsed and thrown away. §3c/§4a/§4b/§6f PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) BLOCKER
M4 No aux-command steering: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through _DoMotion when |diff| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (RemoteMoveToDriver π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 HIGH
M5 Arrival predicate approximated + wrong distance metric: retail GetCurrentDistance uses cylinder_distance(own r/h, target r/h) when use_spheres (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's max(minDistance, distanceToObject) hack + an invented ArrivalEpsilon 0.05 + the AD-26 facing gate. Retail: moving_away ? dist ≥ min_distance : dist ≤ distance_to_object, distance-only. GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) HIGH
M6 No progress detector / fail distance: CheckProgressMade (1 s window, 0.25 units/s incremental AND overall), fail_distance → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into MoveToPathData without a consumer. CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused HIGH
M7 Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g UpdateMotion.cs:252 (movementType is 6 or 7 — no 8/9 branch) HIGH
M8 Target re-tracking absent (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into MoveToPathData.TargetGuid and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) HIGH (P4 pins the R4 answer)
M9 TS-36 dangling: InterruptCurrentMovement is a no-op Action — jump() (:921/:1833 sites), StopCompletely (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 BLOCKER for V5
M10 PerformMovement discipline absent: every new moveto must CancelMoveTo(0x36) + unstick_from_object FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's BeginServerAutoWalk just overwrites fields. PerformMovement §3a; §3b-§3e PlayerMovementController.cs:452-487 HIGH
M11 MovementType/MovementStruct too narrow: enum lacks Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9; MovementStruct lacks ObjectId/TopLevelId/Pos/Radius/Height/Params. MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 MotionInterpreter.cs:101-113 (1-5 only), :408-422 MED (mechanical)
M12 WeenieError lacks the moveto codes: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. codes §7c/§7a; A10 table MotionInterpreter.cs:136-205 LOW
M13 my_run_rate not fed from the MoveTo wire: retail unpack cases 6/7 write minterp->my_run_rate = read_float(); acdream parses MoveToRunRate but only feeds the PlanMoveToStart seed — the interp's MyRunRate (:585) never sees it, so apply_run_to_command's speed scale during a moveto uses stale rate. unpack @300603/@300660 UpdateMotion.cs:342; GameWindow.cs:4415 MED
M14 Sticky discipline absent: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (&= 0xffffff7f); BeginNextNode's empty-queue sticky branch hands off to PositionManager::StickTo(tlid, radius, height) — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (_motionFlags dead at UpdateMotion.cs:135). §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d nothing; UpdateMotion.cs:135 MED (seam now, body R5)
M15 Heading/geometry helpers absent in Core: Position::heading, heading_diff (P3), heading_greater, Frame::get/set_heading, cylinder_distance. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. §5f/§5g/§5a; P5 SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw BLOCKER (mechanical)
M16 PlanFromVelocity invented heuristics carry NO register row (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. none (that's the problem) ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 MED (bookkeeping, fix in V4)
M17 Speculative use-turn bypasses the (future) manager: InstallSpeculativeTurnToTarget (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local CPhysicsObj::TurnToObject/MoveToObject through the SAME manager (§9a/§9b callers). §9a/§9b GameWindow.cs:11915/:12004/:12122-12164 MED (rewire in V5)

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

Behavior Retail anchor acdream anchor
MovementParameters flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes ctor 0x00524380; W0-pins A4 Motion/MovementParameters.cs — V1 EXTENDS with get_command family; no field changes
The entire _DoMotion/_StopMotion seam target: adjust_motion(ref,ref,HoldKey) + DoInterpretedMotion/StopInterpretedMotion(uint, MovementParameters) incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") §7a/§7b; 0x00529010/0x00529080 MotionInterpreter.cs:1241/:2861/:3016 — untouched
WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values A10 table MotionInterpreter.cs:136-205
mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session UnPackNet 0x0052ac50 §2g UpdateMotion.cs:280-356 + CreateObject.MoveToPathData — V3 adds mt 8/9 beside it
OriginToWorld (landblock-local origin → streaming world) Position rebase equivalence (P5) RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file)
MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate 0xF74C dispatch pc:357214 GameWindow OnLiveMotionUpdated head
R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work R2/R3 shipped Motion/ classes + MotionTableDispatchSink
Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's transient_state & 1 gate reads the same source contact_allows_move uses (P7 confirms) @307781 MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState
HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) MovementManager::HitGround 0x00524300 MotionInterpreter.cs HitGround/LeaveGround + their App call sites
Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 PlayerMovementController.cs sections 4-8
AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new MoveToComplete seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) §7e GameWindow.cs:11915 subscription; register AD-27
AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored ACE Player_Move.cs:66 GameWindow.cs:12102-12164; register AP-23
PlanFromVelocity (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only PlanMoveToStart dies in R4 retire R6 (per-tick order) ServerControlledLocomotion.cs:54-87

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

New code target: src/AcDream.Core/Physics/Motion/MoveToManager.cs (+ MoveToMath.cs for the free functions) per plan rule 4 — pure logic, GL-free, seams only. NAME WATCH: retail's MoveToManager::MovementNode {type, heading} must NOT collide with R2's Motion/MotionNode.cs (the pending_motions node) — name it MoveToNode, register-note the rename. Tests: tests/AcDream.Core.Tests/Physics/Motion/. Every commit: build+test green, register rows added/retired in-commit.

V0 — pins + research docs (docs only). Commit docs/research/2026-07-03-r4-movetomanager/ (decomp extraction, ACE cross-ref, this plan, V0-pins.md resolving P1-P7 in the W0-pins format — P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6 are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale claim in the same commit. Deps: none.

V1 — command-selection family + state widening (Core, no consumers). (closes M2-mechanics, M11, M12, M15) MovementParameters gains GetCommand(dist, heading, out motion, out holdKey, out movingAway) (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped — A13+A15 trap: retail's version of BOTH), TowardsAndAway (§5d), GetDesiredHeading (P2 pin), plus a FromWire(uint bitfield, dto, min, fail, speed, threshhold, desiredHeading) factory (UnPackNet semantics — bit masks per A4) and the field-by-field copy the entry points do. MoveToMath.cs: HeadingDiff (P3 pin), HeadingGreater (§5f), PositionHeading + GetHeading/SetHeading (P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention in the codebase), CylinderDistance (§5a signature). MovementType gains Invalid/6/7/8/9; MovementStruct gains ObjectId/TopLevelId/Pos(world+cell)/ Radius/Height/Params. WeenieError += 0x0B/0x36/0x37/0x38/0x3D. Tests first: get_command truth table (all four flag quadrants × distance bands × the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <, walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon (0.000199999995f literal) tables, cardinal-heading goldens (P5). Fixture source: synthetic + A4/A10 pins. Deps: V0.

V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring). (closes M1/M3/M4/M5/M6/M10/M14-core)
All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags
word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3)
/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/
MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading
(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/
TurnToObject_Internal (fmod, sought-heading read); node factories + MoveToNode
(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads
radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward
(localParams: clear 0x8000, holdKey from get_command, write-back to stored
params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8)
per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim);
UseTime (retail gate polarity: tlid == 0 || type == Invalid || initialized
NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6
set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min
dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam); HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true)
  • pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes, FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates); GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux, clear_target gate, InitializeLocalVariables — does NOT drain nodes); CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering
  • the MoveToComplete seam, §4); CancelMoveTo (drain + CleanUp + Stop; the WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust then dispatch); is_moving_to; fail_progress_count as a write-only field (§8). Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors, own+target radius/height, IsInterpolating, Contact, set_target/clear_target/ quantum, unstick, StickTo, MoveToComplete. Tests first: per-function conformance tables from the extraction's constants inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie → interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object; BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6 stall); a scripted end-to-end table drive (positions fed per tick → expected node pops + dispatched motion ids/hold keys, incl. run→walk demote inside threshold 15). Fixture source: synthetic + §12 constants (+ V0 cdb goldens if captured). Deps: V1.

V3 — wire completion: mt 8/9 + my_run_rate + params exposure. (closes M7, M13, M14-wire-note) UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and mt 9 (3-dword UnPackNet) into a widened MoveToPathData (or a sibling TurnToPathData) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL UnPackNet fields reach the consumer as a MovementParameters via FromWire (today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement item, not consumed here. MoveToRunRate documented as the MyRunRate write the V4/V5 consumers perform (unpack @300603). Tests first: golden-byte fixtures generated from ACE's event writers (P6) for all four types + flag permutations; existing mt 6/7 fixtures green unchanged. Fixture source: ACE-writer golden bytes. Deps: V1 (types), parallel with V2.

V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED. (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row) Each RemoteMotion gains a MoveTo manager bound to its existing Motion interp + body (construction beside the R3 sink bind, §4). GameWindow remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table → MovementStruct{MoveToObject, radius/height from the entity's setup}MoveTo.PerformMovement; unresolvable → degrade to MoveToPosition(wire origin) per §2f; mt 7/8/9 likewise; Motion.MyRunRate = MoveToRunRate. The P4 TargetTracker adapter feeds HandleUpdateTarget (register row). Per-tick: the :9594-9682 block becomes rm.MoveTo.UseTime() (same slot the legacy driver occupied — see the placement decision below); remote HitGround sites add rm.MoveTo.HitGround() after rm.Motion.HitGround() (§2d order). DELETE: RemoteMoveToDriver.cs (OriginToWorld moves to MoveToMath; TurnRateFor's surviving consumers, if any outside the deleted paths, get the constant from the interp's own apply_run_to_command home), PlanMoveToStart + its :4412 seeding branch (the manager's own _DoMotion → funnel sink now produces the cycle, identical mechanism to every other interpreted motion), the HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/ ServerMoveToActive RemoteMotion fields and their :4917/:5329 reads. Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop distance in the visual pass). Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving a manager against a mocked tracker; dispatched-motion trace conformance (NPC chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table); existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION. Fixture source: V2 harness + live smoke. Deps: V2+V3.

V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices). (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation) PlayerMovementController gains a MoveTo manager bound to its Motion interp (exposed like Motion was for R3-W2's bind). GameWindow local routing (:4507-4547): the BeginServerAutoWalk call becomes MovementStruct → MoveTo.PerformMovement (OriginToWorld unchanged); the P1 pin governs the mt-0 companion echo (adaptation row if that's the pin). Motion.InterruptCurrentMovement binds to MoveTo.CancelMoveTo(ActionCancelled) — TS-36 RETIRED; user input now cancels the moveto exactly the retail way (input edge → DoMotion with CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit "user-input cancel" check dies with the block. DELETE the whole B.6 block: fields :275-:340 (incl. IsServerAutoWalking, _autoWalkTurnDirectionThisFrame

  • its :1528-1540 consumer), BeginServerAutoWalk :452, EndServerAutoWalk :495, DriveServerAutoWalk :567-766, the :896 call + autoWalkConsumedMotion skip plumbing (:1028). AutoWalkArrived is replaced by the MoveToComplete(None) seam subscription (AD-27 re-anchored, same Use-resend behavior). InstallSpeculativeTurnToTarget (:12122) rewires to a LOCAL MovementStruct{TurnToObject/MoveToObject} through the player's manager (retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius source, row re-anchored). Per-tick: MoveTo.UseTime() at the :896 slot (placement decision below). [autowalk-*] probes retarget to the manager (PhysicsDiagnostics name kept). Tests first: full suite green; controller edge-driven suites unchanged; manager-driven use-walk scenario (turn node → walk → demote-to-walk near target → arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto cancels it — TS-36's predicted failure); outbound golden-byte parity for a scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues non-autonomous motions, so outbound autonomous traffic must NOT change). ONE user visual pass (with R2/R3's if still pending): use a door at range (walk-up + arrival + door opens once), use while facing away (visible turn first), NPC chase legs (turn cycles during corrections, walk-pace close-in), TurnTo emote-target if reachable. Fixture source: pre-cutover traces + golden-byte + V2 harness. Deps: V4.

V6 — register sweep + roadmap + digest (docs/cleanup only). Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added: TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned), MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows (→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27, AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped); milestones check; memory digest note (animation deep-dive: MoveToManager gap CLOSED; the "three approximations" pattern retired). Deps: V5.

Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5 sequential (remotes prove the manager before the local cutover). V4 and V5 both touch GameWindow — single-agent each.

DECISION — B.6 auto-walk dies in R4 (V5), not R5. Grounds: the plan of record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate is delete-in-stage; everything DriveServerAutoWalk does is a subset of the verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band = retail's 20° aux band; arrival = the distance predicate; user cancel = the cancel_moveto interrupt chain); keeping it alongside a real manager would mean two writers on the same motion channels — the exact double-motion hazard TS-36 warns about. What survives R5-ward is only the two seams the manager can't fill yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row).

DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots. MoveTo.UseTime() runs (a) for the player: in PlayerMovementController.Update at the exact point DriveServerAutoWalk runs today (:896, before the input-driven motion block); (b) for remotes: in the GameWindow per-remote tick block where RemoteMoveToDriver.Drive runs today (:9594). Justification: least-invasive — both slots already sit at the "after inbound wire, before/with physics integrate" altitude the legacy approximations were tuned for, so the cutover changes WHAT steers, not WHEN, and every behavior diff in the visual pass is attributable to the manager itself rather than to a reordering. Retail's true slot (UpdateObjectInternal: … transition sweep → MovementManager.UseTime → PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement provisional until R6"). The provisional placement gets a one-line note in both call sites, not a register row (ordering-within-tick is R6's audited scope).


4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing)

Retail chain being stood in for (R5 inserts MovementManager as a null-guarded relay WITHOUT behavior change — same rule as R3 §4's MotionDone note):

retail:  unpack_movement / CPhysicsObj entry points
           └─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager)
                └─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp
                                     (adjust_motion → DoInterpretedMotion/StopInterpretedMotion)
R4:      GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch)
           ├─ mt 0        → funnel (unchanged; P1 pin governs the local-player echo)
           ├─ mt 1-5      → interp paths (unchanged)
           └─ mt 6/7/8/9  → entity.MoveTo.PerformMovement(MovementStruct)   [+ MyRunRate write]

Construction & binding (per entity, at the same site as the R3 sink bind):

  • Remote: where RemoteMotion.Motion + the MotionTableManager sink are wired (GameWindow entity creation), construct MoveTo = new MoveToManager(...) with:
    • interp = remoteMot.Motion (the _DoMotion target — adjust_motion + DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never calls DoMotion/set_hold_run/raw-state APIs — decomp §7b),
    • stopCompletely = () => Motion.StopCompletely() (retail routes CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct call is the same body pre-R5),
    • body accessors: position+cell, GetHeading/SetHeading(deg, send) (P5 convention; send flags the outbound heading snap — remotes: no-op send), own radius/height (setup shape), Contact (PhysicsObj.TransientState), IsInterpolating = InterpolationManager queue-non-empty,
    • target seams: set_target/clear_target → the P4 TargetTracker; get/set_target_quantum → tracker-recorded value (accepted, non-throttling);
    • unstickMotion.UnstickFromObject (existing no-op seam, R5); stickTo(tlid, radius, height) → NEW no-op Action seam (R5 StickyManager; register row);
    • MoveToComplete(WeenieError) → remote: nothing (retail-faithful); local player: the AD-27 Use-resend subscription (fires ONLY on None).
  • Local player: PlayerMovementController constructs/exposes MoveTo bound to its own Motion; GameWindow routes local mt 6-9 to it.

The cancel chain (TS-36 retires here):

user input edge / jump() / StopCompletely
  └─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833)
       └─ InterruptCurrentMovement?.Invoke()          [R3 seam, was no-op]
            └─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled)
                 └─ drain nodes → CleanUp (stops current+aux via _StopMotion,
                    clear_target, InitializeLocalVariables → movement_type=Invalid)
                    → StopCompletely

Reentrancy invariant (MUST be tested, V2): the tail StopCompletely re-enters InterruptCurrentMovementCancelMoveTo, which NO-OPS because CleanUp already reset movement_type to Invalid BEFORE the stop (retail ordering §7e; ACE note r4-ace §3). Do not reorder CleanUp/StopCompletely.

HandleUpdateTarget feed (P4): TargetTracker (App) watches the entity table; delivery → entity.MoveTo.HandleUpdateTarget(TargetInfo); context 0 only (retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0). Target despawn → status = ExitWorld (manager cancels 0x37/0x38 itself).

Per-tick + ground events: MoveTo.UseTime() at the two legacy slots (decision above). Every existing Motion.HitGround() call site adds MoveTo.HitGround() AFTER it (retail §2d order: minterp then moveto). LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not invent.

GameWindow mt 6-9 routing detail (V4/V5):

  1. MotionSequenceGate first (unchanged).
  2. Motion.MyRunRate = MoveToRunRate (unpack @300603/@300660).
  3. mt 6: resolve TargetGuid in the entity table → top-level parent id (retail resolves parent ?: target, §9a — acdream: container/parent link if the entity model has one, else the guid itself) + setup radius/height → MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height, Params=FromWire(...)}. Unresolvable → MovementStruct{MoveToPosition, Pos=OriginToWorld(wire origin)} (the §2f degrade — NOT an error).
  4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
  5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading = wire_heading, TurnToHeading (§2f fallback).
  6. mt 9: TurnToHeading(FromWire 3-field params).
  7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire discriminator); for remotes: unchanged funnel + (retail head shape) an interrupt — remotes' UM streams from ACE already interleave moveto and interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via PerformMovement regardless, so remotes are insensitive to the pin either way).

What the manager dispatches (animation side — zero new work): _DoMotion(0x45000005 WalkForward, localParams{holdKey}) → adjust_motion promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire RunForward takes today. PlanMoveToStart's seeding job ceases to exist rather than being replaced.


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

  • MoveToManager::LeaveGround / ReportExhaustion — COMDAT-folded no-ops in this build (decomp §2e); CMotionInterp::HandleEnterWorld likewise. No members, no seams, no behavior.
  • fail_progress_count is write-only in RETAIL too (§8: 6 sites, zero reads). NO give-up-after-N mechanism exists — the only aborts are fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall timeout to replace AD-9's timer; deleting the timer is the point.
  • CancelMoveTo's WeenieError arg is NEVER READ in the body (§7c) — every call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the parameter (R5 parity + logging), wire no behavior to its value.
  • CleanUpAndCallWeenie contains NO weenie call in this build (§7e — the name is vestigial; body ≡ CleanUp + StopCompletely). acdream's MoveToComplete seam is a documented CLIENT addition standing in for ACE's server-side OnMoveComplete — do not present it as retail.
  • No listener/observer machinery on MoveToManager or MovementManager (grep-negative, §10). MoveToComplete is the one seam, added consciously.
  • CPhysicsObj::cancel_moveto does not exist — the cancel entry is interrupt_current_movement (§9e). CPhysicsObj::MoveToPosition does not exist — position moves enter via unpack case 7 / direct manager call only.
  • Do NOT copy the ACE-isms (all RETAIL-VERIFIED in r4-ace §4): A1 stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2 InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word), A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6 set_heading snap in the aligned branch ("custom: sync for server ticrate"), A7 AlwaysTurn, A8 inRange arrival block ("custom for low monster update rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true default, A14 threshold 1.0, A15 dropped CanCharge fast-path.
  • Do NOT "fix" the retail quirks: TurnToObject's desired_heading write is clobbered before any read (final heading = face-the-object; ACE matches); BeginTurnToHeading stores the remaining DIFF into previous_heading; the double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement returning 0 unconditionally (errors surface via CancelMoveTo, not the return).
  • HandleTurnToHeading's set_heading(node.Heading, true) snap IS retail (0052a146) — the only heading snap in the whole family. Everything else rotates via dispatched turn motions.
  • BN artifact ledger applies (decomp §11): the chase-arrival and turn-pick branch senses in the raw are test ah,0x41 inversions — the adjudicated readings (chase arrives at dist ≤ distance_to_object; diff ≥ 180 → TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The RemoteMoveToDriver.cs:53-57 "ACE swaps the predicates" doc claim is WRONG (superseded in-file at :186-199) — fixed in V0.
  • TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo bodies were NOT extracted — call shapes only (§9f/§9g). Do not invent internals beyond the P4 minimal adapter; R5 owns the ports.
  • The older Ghidra chunk chunk_00520000.c is from a DIFFERENT build for this address region (§10) — function boundaries don't align; unusable for adjudication. Use the named raw + Ghidra MCP on patchmem.gpr only.
  • MovementManager itself is R5 — R4 must not grow a premature facade; the type dispatch stays at the existing GameWindow/controller call sites, and the two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly the relays R5 will absorb.