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>
46 KiB
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 360−raw; 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_update → HandleUpdateTarget (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 || dist−dto > 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
MoveToCompleteseam, §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 +autoWalkConsumedMotionskip plumbing (:1028).AutoWalkArrivedis replaced by theMoveToComplete(None)seam subscription (AD-27 re-anchored, same Use-resend behavior).InstallSpeculativeTurnToTarget(:12122) rewires to a LOCALMovementStruct{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), constructMoveTo = new MoveToManager(...)with:interp=remoteMot.Motion(the_DoMotiontarget — 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;sendflags 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); unstick→Motion.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 onNone).
- Local player:
PlayerMovementControllerconstructs/exposesMoveTobound to its ownMotion; 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
InterruptCurrentMovement → CancelMoveTo, 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):
- MotionSequenceGate first (unchanged).
Motion.MyRunRate = MoveToRunRate(unpack @300603/@300660).- mt 6: resolve
TargetGuidin the entity table → top-level parent id (retail resolvesparent ?: 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). - mt 7: MoveToPosition(OriginToWorld(origin), FromWire params).
- mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading = wire_heading, TurnToHeading (§2f fallback).
- mt 9: TurnToHeading(FromWire 3-field params).
- 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::HandleEnterWorldlikewise. No members, no seams, no behavior.fail_progress_countis 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.CleanUpAndCallWeeniecontains NO weenie call in this build (§7e — the name is vestigial; body ≡ CleanUp + StopCompletely). acdream'sMoveToCompleteseam is a documented CLIENT addition standing in for ACE's server-sideOnMoveComplete— do not present it as retail.- No listener/observer machinery on MoveToManager or MovementManager
(grep-negative, §10).
MoveToCompleteis the one seam, added consciously. CPhysicsObj::cancel_movetodoes not exist — the cancel entry isinterrupt_current_movement(§9e).CPhysicsObj::MoveToPositiondoes 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, A8inRangearrival 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,0x41inversions — the adjudicated readings (chase arrives atdist ≤ distance_to_object; diff ≥ 180 → TurnLeft) are pinned; do not re-derive from the literal pseudo-C. TheRemoteMoveToDriver.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.cis from a DIFFERENT build for this address region (§10) — function boundaries don't align; unusable for adjudication. Use the named raw + Ghidra MCP onpatchmem.gpronly. - 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.