From 988304e13b6a0793ab86245b099713a5b3763348 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 10:31:12 +0200 Subject: [PATCH] =?UTF-8?q?docs(R4-V-1):=20MoveToManager=20research=20base?= =?UTF-8?q?=20=E2=80=94=20decomp=20extraction=20+=20ACE=20cross-ref=20+=20?= =?UTF-8?q?port=20work-list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-07-03-r4-moveto/r4-ace-moveto.md | 857 ++++++++++++ .../2026-07-03-r4-moveto/r4-moveto-decomp.md | 1232 +++++++++++++++++ .../2026-07-03-r4-moveto/r4-port-plan.md | 452 ++++++ 3 files changed, 2541 insertions(+) create mode 100644 docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md create mode 100644 docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md create mode 100644 docs/research/2026-07-03-r4-moveto/r4-port-plan.md diff --git a/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md b/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md new file mode 100644 index 00000000..d44b3e57 --- /dev/null +++ b/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md @@ -0,0 +1,857 @@ +# R4 ACE cross-reference — MoveToManager / MovementParameters + +**Purpose:** ACE-side oracle for the R4 MoveToManager port. Per-method transcription of +`references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` (874 lines) + +`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, call-site map, +ACE-ism flags (each spot-checked against the named retail decomp where noted), and the +acdream R4 blast-radius inventory. + +**Note on paths:** the ACE reference lives in the MAIN repo tree +(`C:/Users/erikn/source/repos/acdream/references/ACE/...`), NOT in the worktree — the worktree's +`references/` only contains WorldBuilder. ACE's file is `Physics/Managers/MoveToManager.cs` +(namespace still says `Physics.Animation` — ACE moved the file without renaming the namespace). + +**Retail anchor table** (named pseudo-C, `docs/research/named-retail/acclient_2013_pseudo_c.txt`): + +| Function | Address | pseudo-C line | +|---|---|---| +| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 | +| `heading_greater` (free fn) | 0x00528f60 | 306281 | +| `heading_diff` (free fn) | 0x00528fb0 | 306327 | +| `MoveToManager::_DoMotion` | 0x00529010 | 306351 | +| `MoveToManager::_StopMotion` | 0x00529080 | 306368 | +| `MoveToManager::CheckProgressMade` | 0x005290f0 | 306385 | +| `MoveToManager::GetCurrentDistance` | 0x005291b0 | 306435 | +| `MoveToManager::is_moving_to` | 0x00529220 | 306464 | +| `MoveToManager::InitializeLocalVariables` | 0x00529250 | 306490 | +| `MoveToManager::RemovePendingActionsHead` | 0x00529380 | 306538 | +| `MoveToManager::MoveToManager` (ctor) | 0x005293b0 | 306554 | +| `MoveToManager::CleanUp` | 0x005295c0 | 306710 | +| `MoveToManager::CleanUpAndCallWeenie` | 0x00529650 | 306740 | +| `MoveToManager::MoveToObject` | 0x00529680 | 306756 | +| `MoveToManager::TurnToObject` | 0x005297d0 | 306820 | +| `MoveToManager::CancelMoveTo` | 0x00529930 | 306886 | +| `MoveToManager::BeginMoveForward` | 0x00529a00 | 306957 | +| `MoveToManager::BeginTurnToHeading` | 0x00529b90 | 307046 | +| `MoveToManager::BeginNextNode` | 0x00529cb0 | 307123 | +| `MoveToManager::HitGround` | 0x00529d70 | 307175 | +| `MoveToManager::HandleMoveToPosition` | 0x00529d80 | 307187 | +| `MoveToManager::HandleTurnToHeading` | 0x0052a0c0 | 307442 | +| `MoveToManager::MoveToPosition` | 0x0052a240 | 307521 | +| `MoveToManager::MoveToObject_Internal` | 0x0052a400 | 307597 | +| `MoveToManager::TurnToObject_Internal` | 0x0052a550 | 307667 | +| `MoveToManager::TurnToHeading` | 0x0052a630 | 307706 | +| `MoveToManager::UseTime` | 0x0052a780 | 307776 | +| `MoveToManager::HandleUpdateTarget` | 0x0052a7d0 | 307802 | +| `MoveToManager::PerformMovement` | 0x0052a900 | 307871 | +| `MovementParameters::towards_and_away` | 0x0052a9a0 | 307917 | +| `MovementParameters::get_command` | 0x0052aa00 | 307946 | +| `MovementParameters::get_desired_heading` | 0x0052aad0 | 308016 | +| `MovementParameters::Pack / UnPack / UnPackNet` | 0x0052ab20 / 0x0052abc0 / 0x0052ac50 | 308037 / 308078 / 308118 | + +Retail struct layout (verbatim PDB, `acclient.h:31473-31497`): `movement_type, sought_position, +current_target_position, starting_position, movement_params, previous_heading, previous_distance, +previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count, +sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command, +aux_command, moving_away, initialized, pending_actions (DLList), physics_obj, weenie_obj`. +`MovementNode : DLListData { MovementTypes::Type type; float heading; }` (`acclient.h:57702`). + +--- + +## 1. ACE MoveToManager — state + +`MoveToManager.cs:13-35`: + +``` +MovementType MovementType // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 +Position SoughtPosition // MoveToObject: interpolated target pos; also carries heading for TurnTo* +Position CurrentTargetPosition // raw target position (distance measured against THIS) +Position StartingPosition // for FailDistance check +MovementParameters MovementParams +float PreviousHeading // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful) +float PreviousDistance; double PreviousDistanceTime +float OriginalDistance; double OriginalDistanceTime +int FailProgressCount // incremented, NEVER read (see §5 negative results — retail-faithful) +uint SoughtObjectID, TopLevelObjectID +float SoughtObjectRadius, SoughtObjectHeight +uint CurrentCommand // active forward/turn motion (full 32-bit command) +uint AuxCommand // in-motion heading-correction turn command +bool MovingAway, Initialized +List PendingActions // MovementNode { MovementType Type; float Heading } +PhysicsObj PhysicsObj; WeenieObject WeenieObj +bool AlwaysTurn // ACE-ONLY (no retail counterpart) — see flag A7 +``` + +MotionCommand constants used: `WalkForward = 0x45000005`, `WalkBackwards = 0x45000006`, +`TurnRight = 0x6500000D`, `TurnLeft = 0x6500000E`, `RunForward = 0x44000007`. +`PhysicsGlobals.EPSILON = 0.0002f` (retail literal `0.000199999995f` everywhere in this family). + +### Init / InitializeLocalVars (`:57-89`) + +``` +Init(): MovementParams = new MovementParameters(); PendingActions = new List(); +InitializeLocalVars(): + MovementType = Invalid + MovementParams.DistanceToObject = 0 // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object + MovementParams.ContextID = 0 + PreviousDistanceTime = OriginalDistanceTime = now + PreviousHeading = 0 + FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false + SoughtPosition = new Position(); CurrentTargetPosition = new Position() + SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0 + // ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both +``` + +### PerformMovement(MovementStruct mvs) (`:91-112`) — retail 0x0052a900 ✓ matches + +``` +CancelMoveTo(WeenieError.ActionCancelled) // 0x36 — retail same constant +PhysicsObj.unstick_from_object() // ⚠ no null check; retail also derefs unconditionally here +switch (mvs.Type): + MoveToObject → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params) + MoveToPosition → MoveToPosition(mvs.Position, mvs.Params) + TurnToObject → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params) + TurnToHeading → TurnToHeading(mvs.Params) +return WeenieError.None // retail returns 0 unconditionally too +``` + +### MoveToObject(objectID, topLevelID, radius, height, params) (`:114-139`) — retail 0x00529680 ✓ + +``` +if (PhysicsObj == null) return // retail: null branch still runs StopCompletely-if-nonnull tail (no-op) +PhysicsObj.StopCompletely(false) +StartingPosition = copy(PhysicsObj.Position) +SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height +MovementType = MoveToObject; TopLevelObjectID = topLevelID +MovementParams = copy(movementParams) // retail: full field-by-field copy incl flags; Sticky NOT unset here +Initialized = false +if (PhysicsObj.ID != topLevelID): + PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0) // contextID=0, radius=0.5, quantum=0 — retail identical constants + return +CleanUp(); PhysicsObj.StopCompletely(false) // self-target degenerate path +``` + +No nodes are queued here — the chain continues via TargetManager voyeur callback → +`HandleUpdateTarget` → `MoveToObject_Internal`. + +### MoveToObject_Internal(targetPosition, interpolatedPosition) (`:141-182`) — retail 0x0052a400 ✓ + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return } +SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition) +iHeading = PhysicsObj.Position.heading(interpolatedPosition) // note: heading measured to INTERPOLATED pos +heading = iHeading - PhysicsObj.get_heading() +dist = GetCurrentDistance() +if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360 +MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway) // on the STORED member — retail 0052a4d6 same +if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode() +if (MovementParams.UseFinalHeading): // member read is CORRECT here (params stored by MoveToObject already) + dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360 + AddTurnToHeadingNode(dHeading) +Initialized = true +BeginNextNode() +``` + +### MoveToPosition(position, params) (`:184-228`) — retail 0x0052a240 — ⚠ flag A1 + +``` +if (PhysicsObj == null) return +PhysicsObj.StopCompletely(false) +CurrentTargetPosition = copy(position); SoughtObjectRadius = 0 +distance = GetCurrentDistance() +headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above +movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway) // on the ARGUMENT — retail 0052a304 same +if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode() +if (MovementParams.UseFinalHeading) // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads + AddTurnToHeadingNode(movementParams.DesiredHeading) // the ARGUMENT's flag byte (arg3->__inner0 & 0x40) +SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position) +MovementType = MoveToPosition +MovementParams = copy(movementParams); MovementParams.Sticky = false // retail: full copy then __inner0 &= 0xffffff7f ✓ +BeginNextNode() +``` + +### TurnToObject(objectID, topLevelID, params) (`:230-259`) — retail 0x005297d0 ✓ + +``` +if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return } +if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) // retail gates on flag byte2 & 1 (0x10000) ✓ +MovementType = TurnToObject; SoughtObjectID = objectID +CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓ +TopLevelObjectID = topLevelID +MovementParams = copy(movementParams) // retail: full copy; Sticky NOT unset (unlike TurnToHeading) +if (PhysicsObj.ID != topLevelID): + Initialized = false + PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0) + return +CleanUp(); PhysicsObj.StopCompletely(false) +``` + +Note the desired-heading value written into `CurrentTargetPosition.Frame` is CLOBBERED by +`TurnToObject_Internal` before it is ever read (Internal overwrites CurrentTargetPosition from the +wire target and reads `SoughtPosition.Frame`'s heading, which InitializeLocalVars reset to 0). +Verified this is a *retail* quirk, not an ACE bug — the retail decomp does exactly the same +(0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim. + +### TurnToObject_Internal(targetPosition) (`:261-282`) — retail 0x0052a550 ✓ + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } +CurrentTargetPosition = copy(targetPosition) +targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition) +soughtHeading = SoughtPosition.Frame.get_heading() +heading = (targetHeading + soughtHeading) % 360 // retail: fmod(…, 360) via CIfmod +SoughtPosition.Frame.set_heading(heading) +PendingActions.Add(TurnToHeading node, heading) // retail: node type 9 +Initialized = true +BeginNextNode() +``` + +### TurnToHeading(params) (`:284-304`) — retail 0x0052a630 — ⚠ flag A4 + +``` +if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return } +if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) +MovementParams = copy(movementParams); MovementParams.Sticky = false // retail __inner0 &= 0xffffff7f ✓ +SoughtPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052a71d ✓ +MovementType = TurnToHeading +PendingActions.Add(TurnToHeading node, DesiredHeading) +// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert. +``` + +### BeginNextNode (`:316-349`) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback) + +``` +if (PendingActions non-empty): + head.Type == MoveToPosition → BeginMoveForward() + head.Type == TurnToHeading → BeginTurnToHeading() + (other types: fall through / return — retail explicitly returns for any other type) +else: + if (MovementParams.Sticky): // retail: signed test on flags byte0 (bit 0x80) + capture (radius, height, topLevelID) BEFORE CleanUp + CleanUpAndCallWeenie(WeenieError.None) // retail: CleanUp + StopCompletely inline — NO weenie call + if (PhysicsObj != null) + PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height) + else: + CleanUpAndCallWeenie(WeenieError.None) +``` + +### BeginMoveForward (`:351-399`) — retail 0x00529a00 ✓ + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } +dist = GetCurrentDistance() +heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize +MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway) // stored member ✓ +if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return } +mp = new MovementParameters { HoldKeyToApply = holdKey, // the get_command RESULT — retail 00529af4 ✓ + CancelMoveTo = false, // retail flags &= 0xffff7fff ✓ + Speed = MovementParams.Speed } +result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return } +CurrentCommand = motion; MovingAway = moveAway +MovementParams.HoldKeyToApply = holdKey // write-back onto stored params — retail 00529b45 ✓ +PreviousDistance = OriginalDistance = dist +PreviousDistanceTime = OriginalDistanceTime = now +``` + +### HandleMoveToPosition (`:404-504`) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9 + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } +curPos = copy(Position) +mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed, + HoldKeyToApply = MovementParams.HoldKeyToApply } // retail 00529dc7-00529dea ✓ +if (!PhysicsObj.IsAnimating): // retail: motions_pending() == 0 + heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition) + if (heading >= 360) heading -= 360 + diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON) + if (diff > 20 && diff < 340): // retail constants 20 / (360-20) ✓ + motionID = (diff >= 180) ? TurnLeft : TurnRight + if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID } + else: + if (AuxCommand != 0) PhysicsObj.set_heading(heading, true) // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate") + stop_aux_command(mp) // retail only stops the aux command, no snap +else: + stop_aux_command(mp) // retail: aux stop under motions_pending ✓ +dist = GetCurrentDistance() +if (!CheckProgressMade(dist)): + if (!IsInterpolating() && !IsAnimating) FailProgressCount++ // retail identical gate ✓ +else: + inRange = false // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate") + if (!MovementParams.UseSpheres): // including the extra PreviousDistance/Time refresh + if (dist < 1.0 && PreviousDistance < dist) inRange = true + PreviousDistance = dist; PreviousDistanceTime = now + FailProgressCount = 0 + if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange): + PendingActions.RemoveAt(0) + _StopMotion(CurrentCommand, mp); CurrentCommand = 0 + stop_aux_command(mp) // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓ + BeginNextNode() + else: + if (StartingPosition.Distance(Position) > MovementParams.FailDistance) + CancelMoveTo(YouChargedTooFar) // 0x3D — retail same constant (00529f79) +if (TopLevelObjectID != 0 && MovementType != Invalid): + v = |get_velocity()| + if (v > 0.1): // retail double literal 0.1 ✓ + time = dist / v + if (|time - get_target_quantum()| > 1.0) set_target_quantum(time) // retail 1.0 ✓ +``` + +Arrival predicate cross-check: flee branch is explicit in the retail decomp +(`dist >= min_distance` → arrival, 00529f44-00529f51). The chase branch's literal BN rendering +*appears* inverted (`dist <= distance_to_object` branching to the fail-distance check) — that is a +known `test ah, 0x41` branch-sense garble; the flee branch + symmetric structure + two prior +independent research passes (see `RemoteMoveToDriver.cs:186-199`) fix the correct reading as: +**chase arrives at `dist <= distance_to_object`, flee arrives at `dist >= min_distance`** — which +is exactly ACE's line 476. (The stale class-doc comment in acdream's `RemoteMoveToDriver.cs:53-57` +claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the +same file — see §6 blast radius.) + +### BeginTurnToHeading (`:510-565`) — retail 0x00529b90 — ⚠ flags A7, A10 + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } + // ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null; + // ACE indexes PendingActions[0] later (throws if empty — callers guard) +if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only +headingDiff = heading_diff(node.Heading, get_heading(), TurnRight) +if (headingDiff <= 180): + if (headingDiff > EPSILON) motionID = TurnRight + else { RemovePendingActionsHead(); BeginNextNode(); return } +else: + if (headingDiff + EPSILON <= 360) motionID = TurnLeft + else { RemovePendingActionsHead(); BeginNextNode(); return } +mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed, + HoldKeyToApply = MovementParams.HoldKeyToApply } +result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return } +CurrentCommand = motionID +PreviousHeading = headingDiff // stores the DIFF, not a heading — retail 00529c82 identical quirk +``` + +(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure — +`heading_diff(node, current, TurnRight)`, ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon — +matches ACE's reading. Constants: 180, 360, EPSILON.) + +### HandleTurnToHeading (`:570-621`) — per-tick turn driver — retail 0x0052a0c0 ✓ + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } +if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return } +node = PendingActions[0]; heading = get_heading() +if (heading_greater(heading, node.Heading, CurrentCommand)): // crossed the target heading + FailProgressCount = 0 + PhysicsObj.set_heading(node.Heading, true) // snap exactly — retail 0052a146 ✓ (this snap IS retail) + RemovePendingActionsHead() + mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply } + _StopMotion(CurrentCommand, mp); CurrentCommand = 0 + BeginNextNode(); return +diff = heading_diff(heading, PreviousHeading, CurrentCommand) +if (diff > EPSILON && diff < 180): + FailProgressCount = 0; PreviousHeading = heading +else: + PreviousHeading = heading + if (!IsInterpolating() && !IsAnimating) FailProgressCount++ +``` + +Note ACE omits `movementParams.Speed` in the stop-params here (retail also builds a default-speed +params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓). + +### HandleUpdateTarget(TargetInfo) (`:623-666`) — retail 0x0052a7d0 ✓ (reordered but equivalent) + +``` +if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } +if (TopLevelObjectID != targetInfo.ObjectID) return +if (Initialized): + if (status == OK): + if (MovementType == MoveToObject): + SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition) + PreviousDistance = OriginalDistance = float.MaxValue + PreviousDistanceTime = OriginalDistanceTime = now + else CancelMoveTo(ObjectGone) // 0x37 — retail same (0052a80f) +else if (TopLevelObjectID == PhysicsObj.ID): + SoughtPosition = CurrentTargetPosition = copy(Position) + CleanUpAndCallWeenie(None) +else if (status == OK): + MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition) + MovementType == TurnToObject → TurnToObject_Internal(TargetPosition) +else CancelMoveTo(NoObject) // 0x38 — retail same (0052a8bd) +``` + +Retail evaluates `!initialized` first and nests the self-target + status checks inside it; ACE +flipped to `if (Initialized)` first. Same reachable behavior; error codes verified identical +(0x37 initialized-path, 0x38 uninitialized-path — no transposition). + +### CheckProgressMade(currDistance) (`:668-688`) — retail 0x005290f0 ✓ + +``` +deltaTime = now - PreviousDistanceTime +if (deltaTime > 1.0): // 1-second window + diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr + if (diffDist / deltaTime < 0.25) return false // 0.25 m/s minimum closing rate + PreviousDistance = curr; PreviousDistanceTime = now + dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr + if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false +return true +``` + +### CancelMoveTo(retval) (`:690-699`) — retail 0x00529930 ✓ + +``` +if (MovementType == Invalid) return +PendingActions.Clear() // retail: DLList drain + delete +CleanUpAndCallWeenie(retval) +``` + +### CleanUp (`:701-719`) — retail 0x005295c0 ✓ + +``` +mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false } +if (PhysicsObj != null): + if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp) + if (AuxCommand != 0) _StopMotion(AuxCommand, mp) + if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target() +InitializeLocalVars() +``` + +### CleanUpAndCallWeenie(status) (`:721-729`) — retail 0x00529650 — ⚠ flag A5 + +``` +CleanUp() +if (PhysicsObj != null) PhysicsObj.StopCompletely(false) +WeenieObj.OnMoveComplete(status) // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY + // (despite the function name, the 2013 client build has no weenie + // call here — compiled out / client-side no-op). + // ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if + // constructed via the parameterless ctor (flag A11). +``` + +### GetCurrentDistance (`:731-741`) — retail 0x005291b0 + +``` +if (PhysicsObj == null) return float.MaxValue +if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition) +return Position.CylinderDistance(GetRadius(), GetHeight(), Position, + SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition) +``` + +Distance is measured against **CurrentTargetPosition** in both variants; the sphere variant folds +in both bodies' radius/height (this is why ACE chase packets can carry `distance_to_object = 0.6` +and still stop at melee range). + +### HitGround (`:743-747`) — retail 0x00529d70 ✓ + +``` +if (MovementType != Invalid) BeginNextNode() +``` + +### UseTime (`:765-787`) — retail 0x0052a780 — ⚠ flag A12 (gate inversion) + +``` +if (PhysicsObj == null || !TransientState.Contact) return // retail: transient_state & 1 ✓ +if (PendingActions.Count == 0) return +if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized): // ⚠ A12: retail gate is + head.Type == MoveToPosition → HandleMoveToPosition() // (top_level_object_id == 0 + head.Type == TurnToHeading → HandleTurnToHeading() // || movement_type == Invalid + // || initialized != 0) +``` + +Retail (0052a7b4): proceed when `topLevel == 0 || type == Invalid || initialized` — i.e. **skip +only while a MoveToObject/TurnToObject is still waiting for its first target resolution**. ACE +negated the first two clauses. In practice ACE is masked by the `PendingActions.Count == 0` early +return (nodes only exist after `*_Internal` ran, which also sets `Initialized`), but port the +retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear +PendingActions) differs. + +### _DoMotion / _StopMotion (`:789-815`) — retail 0x00529010 / 0x00529080 ✓ + +``` +if (PhysicsObj == null) return NoPhysicsObject // 8 +minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter // 0xB +minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply) +return minterp.DoInterpretedMotion(motion, movementParams) // _StopMotion → StopInterpretedMotion +``` + +The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again — +`MotionInterp.cs:117-129`) **is retail-faithful**: retail's 0x00529057 calls +`CMotionInterp::adjust_motion` and `CMotionInterp::DoInterpretedMotion` also adjusts internally. +Do NOT "fix" the double adjust. + +### heading_diff(h1, h2, motion) (`:817-828`) — retail free fn 0x00528fb0 (x87-garbled) + +``` +result = h1 - h2 +if (|result| < EPSILON) result = 0 +if (result < -EPSILON) result += 360 +if (result > EPSILON && motion != TurnRight) result = 360 - result // mirror for TurnLeft +``` + +The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word +int) and doesn't show the `motion != TurnRight` mirror — the R4 port should verify the mirror once +in Ghidra (`/decompile_function?address=0x00528fb0`), but ACE's shape is consistent with every call +site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff). + +### heading_greater(h1, h2, motion) (`:830-858`) — retail free fn 0x00528f60 ✓ + +``` +diff = |h1 - h2| +if (diff <= 180): result = h1 > h2 +else: result = h2 > h1 +if (motion != TurnRight) result = !result +return result +``` + +ACE's rewrite verified equivalent to the retail comparison tree (including the equality case — +equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE +comment at `:832-836`. + +### is_moving_to / stop_aux_command / small helpers (`:860-872`, `:306-314`, `:749-763`) + +``` +is_moving_to() => MovementType != Invalid +stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 } +AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition)) // retail node type 7 +AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h)) // retail node type 9 +RemovePendingActionsHead(): if (Count > 0) RemoveAt(0) +SetPhysicsObject / SetWeenieObject: plain assignments +``` + +--- + +## 2. ACE MovementParameters (`Physics/Animation/MovementParameters.cs`) + +Bool-per-flag class; `Flags` property round-trips through `MovementParamFlagsHelper` +(`MovementParamFlags.cs`): `CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8, +CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200, +UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000, +ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000, +DisableJumpDuringLink 0x20000`. Matches `acclient.h` bit assignments (cross-checked against +acdream `UpdateMotion.cs` wire comments). + +### Defaults (ctor, `:52-85`) — retail 0x00524380 — ⚠ flags A13, A14 + +| Field | ACE default | Retail default | +|---|---|---| +| flags | CanWalk+CanRun+CanSidestep+CanWalkBackwards+**CanCharge**+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = **0x1EE1F** | **0x1EE0F** (CanCharge 0x10 CLEAR) — ⚠ A13 | +| DistanceToObject | 0.6f | 0.6f ✓ | +| FailDistance | float.MaxValue | FLT_MAX ✓ | +| MinDistance | 0 | 0 ✓ | +| Speed | 1.0f | 1.0f ✓ | +| WalkRunThreshold | **1.0f** (15.0f commented out) | **15.0f** — ⚠ A14 (ACE server tuning) | +| DesiredHeading | 0 | 0 ✓ | +| HoldKeyToApply | Invalid | Invalid ✓ | +| ContextID / ActionStamp | 0 | 0 ✓ | + +(Retail caches the default flag word in a static `set_moveto_flags::normal_bitfield` — one-time +compute of 0x1EE0F, 005243c1-005243e1.) + +### Copy constructors (`:90-150`) + +- `MovementParameters(mvp, onlyBits=false)` — copies Flags always, plus non-flag fields + (`CopyNonFlag`: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed, + WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless `onlyBits`. + Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent. +- `CopySome(mvp)` — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability + + behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway, + Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE `TODO: review` — + interop with the R3 MotionInterp port, not an R4 concern per se.) +- `MovementParameters(MoveToParameters)` — wire-side ctor (server outbound); flags from the packed + dword + the six floats. Matches `MovementParameters::UnPackNet` 0x0052ac50 field order. + +### get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (`:152-184`) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped) + +ACE: + +``` +if (MoveTowards || !MoveAway): + if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway) + else: + if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0 +else if (MoveAway): + if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0 +if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run +else holdKey = None +``` + +Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode): + +``` +if (MoveTowards): + if (MoveAway) towards_and_away(...) // both set → bidirectional band + else chase-only (dist > distance_to_object → WalkForward, away=0; else 0) +else if (!MoveAway): chase-only // neither set → still chase-only (matches ACE's OR) +else: flee-only (dist < min_distance → WalkForward, away=1; else 0) +// HoldKey (0052aa90-0052aab4): +if (flags & 0x10 /*CanCharge*/) → HoldKey_Run // ⚠ unconditional run fast-path +else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None +else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None +else → HoldKey_Run +``` + +Movement-command selection matches ACE exactly. The hold-key selection does NOT: **retail's +CanCharge (0x10) bit short-circuits straight to HoldKey_Run** before any CanRun/CanWalk/threshold +logic; ACE dropped it entirely. (This is the already-documented #77 finding — +`memory/feedback_autowalk_cancharge_bit.md`; acdream's B.6 honors the wire CanCharge bit.) Note the +two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's +own server, but a verbatim port must take retail's version of BOTH. + +### towards_and_away(dist, heading, ref command, ref movingAway) (`:200-214`) — retail 0x0052a9a0 ✓ + +``` +if (dist > DistanceToObject) { command = WalkForward; movingAway = false } +else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true } // retail 0x45000006 ✓ +else command = 0 +``` + +(Note: `heading` param is unused in both ACE and retail.) + +### get_desired_heading(motion, movingAway) (`:186-198`) — retail 0x0052aad0 ✓ + +``` +RunForward / WalkForward → movingAway ? 180 : 0 +WalkBackwards → movingAway ? 0 : 180 +default → 0 +``` + +--- + +## 3. Call sites into MoveToManager (ACE) + +**MovementManager** (`Physics/Managers/MovementManager.cs`) — owns exactly one `MotionInterpreter` ++ one `MoveToManager`: + +- `PerformMovement(mvs)` (`:124-157`): `PhysicsObj.set_active(true)` (ACE-ism, server activity + tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state), + types 6-9 → MoveToManager.PerformMovement (lazy-create), default → `WeenieError.GeneralMovementFailure`. +- `CancelMoveTo(err)` (`:27`), `HandleUpdateTarget(targetInfo)` (`:60`), `UseTime()` (`:176`, + forwards to MoveToManager only), `HitGround()` (`:66-73`, forwards to BOTH MotionInterp.HitGround + and MoveToManager.HitGround), `IsMovingTo()` (`:97`), `MakeMoveToManager()` (`:112`), + `SetWeenieObject` (`:167`). + +**PhysicsObj** (`Physics/PhysicsObj.cs`) seams that drive the manager: + +- `UseTime` ticks: `:1729` + `:1791` (`MovementManager.UseTime()` from the per-tick update paths, + followed by `PositionManager.UseTime()`). +- `set_on_walkable` (`:3791-3814`): OnWalkable rising edge → `MovementManager.HitGround()`, falling + edge → `LeaveGround()`. Additional LeaveGround sites `:1243`, `:2242`, `:2911` (clear_transient_states + and friends). +- `cancel_moveto()` (`:2143-2147`): → `MovementManager.CancelMoveTo(ActionCancelled)`. Called from + MotionInterp on every user-motion (`DoInterpretedMotion :123-124`, `StopMotion :371-372`, + `StopCompletely :305`, `jump :714`, `move_to_interpreted_state :794`) — i.e. any fresh motion with + the CancelMoveTo param bit set kills the active MoveTo. +- `HandleUpdateTarget(targetInfo)` (`:637-644`): forwards to MovementManager AND PositionManager. +- Teleport: `:4036-4037` → `CancelMoveTo(WeenieError.ITeleported)`. +- `StopCompletely(bool)` (`:1546-1551`): builds `MovementStruct(StopCompletely)` → + MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto — + recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs + InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie). +- `set_target(ctx, objID, radius, quantum)` (`:3947`), `clear_target()` (`:2231`), + `get_target_quantum()` (`:2638`, returns `TargetManager.TargetInfo.Quantum` or 0), + `set_target_quantum(q)` (`:3955`), `unstick_from_object()` (`:4134` → PositionManager.Unstick). + +**TargetManager / TargetInfo** (`Physics/Managers/TargetManager.cs`, `Physics/Combat/TargetInfo.cs`): +`SetTarget` clears old target, registers this obj as a voyeur on the target +(`target.add_voyeur(ID, radius, quantum)`); target movement beyond `radius` from last-sent → +`SendVoyeurUpdate` → `voyeurObj.receive_target_update` → `TargetManager.ReceiveUpdate` → +`PhysicsObj.HandleUpdateTarget` → MoveToManager.HandleUpdateTarget. `TargetStatus`: +`Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6`. +`HandleTargetting` runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole +subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead). + +**MotionInterp seams used by MoveToManager:** `adjust_motion(ref motion, ref speed, holdKey)` +(`MotionInterp.cs:394-428` — WalkBackwards→WalkForward·−BackwardsFactor, TurnLeft→TurnRight·−1, +SideStepLeft→SideStepRight·−1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→ +apply_run_to_command), `DoInterpretedMotion`, `StopInterpretedMotion`. These are R3-ported in +acdream's `MotionInterpreter` already. + +--- + +## 4. ACE-ism / oddity flags for the R4 retail cross-check + +Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above. + +- **A1 — stale-member UseFinalHeading read in MoveToPosition** (`MoveToManager.cs:214`). + ACE reads `MovementParams.UseFinalHeading` (the PREVIOUS move's stored params — the new ones + aren't assigned until `:222`); retail 0052a33b reads the ARGUMENT's flag (`arg3->__inner0 & 0x40`). + RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read. +- **A2 — InitializeLocalVars zeroes the wrong field** (`:68`). ACE: `MovementParams.DistanceToObject = 0`. + Retail 0052925b: `movement_params.__inner0 = 0` (the FLAGS word) + `context_id = 0`. + RETAIL-VERIFIED transposition. +- **A3 — InitializeLocalVars omits distance resets**. Retail 0052926c/0052928a also set + `previous_distance = original_distance = FLT_MAX`; ACE leaves them stale (masked because + BeginMoveForward reseeds them). RETAIL-VERIFIED. +- **A4 — TurnToHeading missing BeginNextNode** (`:284-304`). Retail 0052a76d calls + `BeginNextNode()` immediately after queuing the node; ACE returns without it (turn starts one + UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading). + RETAIL-VERIFIED control-flow divergence. +- **A5 — CleanUpAndCallWeenie weenie callback** (`:728`). `WeenieObj.OnMoveComplete(status)` is an + ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is + CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's + `AutoWalkArrived`-style completion notification belongs architecturally — but retail does NOT + notify anything here. RETAIL-VERIFIED. +- **A6 — HandleMoveToPosition aligned-branch `set_heading` snap** (`:444-446`). ACE-only, self-labeled + "custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band — + the residual alignment is finished by the normal per-tick turn integration. acdream runs at + client tick rates → do NOT port the snap. RETAIL-VERIFIED absent. +- **A7 — `AlwaysTurn` field** (`:35`, used `:520`). ACE-only override of the IsAnimating gate in + BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent. +- **A8 — HandleMoveToPosition `inRange` block** (`:463-473`). ACE-only ("custom for low monster + update rate"): when `!UseSpheres`, arrival also fires if `dist < 1.0 && PreviousDistance < dist` + (distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside + CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port. +- **A9 — (non-flag) arrival stop order** — ACE pops the node, stops CurrentCommand, zeroes it, stops + aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it. +- **A10 — BeginTurnToHeading missing empty-head cancel**. Retail 00529bad treats + `pending_actions.head == null` the same as physics-obj-null → `CancelMoveTo(8)`; ACE would throw + on `PendingActions[0]` instead (unreachable through current callers, but port the retail guard). + RETAIL-VERIFIED. +- **A11 — CleanUpAndCallWeenie NPE asymmetry** (`:721-729`). `PhysicsObj` is null-checked, + `WeenieObj` is not — the parameterless `MoveToManager()` ctor leaves WeenieObj null → NPE on any + cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved + client-side, but don't transcribe. +- **A12 — UseTime gate negation** (`:775`). ACE: `TopLevelObjectID != 0 || MovementType != Invalid || Initialized`. + Retail 0052a7b4: `top_level_object_id == 0 || movement_type == Invalid || initialized != 0`. + The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's). + RETAIL-VERIFIED boundary inversion. +- **A13 — default CanCharge flag**. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is + 0x1EE0F (CanCharge clear). RETAIL-VERIFIED. +- **A14 — Default_WalkRunThreshold = 1.0f** vs retail 15.0f (0x005243b5). ACE deliberately retuned + (15.0f left commented out at `MovementParameters.cs:49-50`). acdream already treats 15 m as the + retail/wire default (#77 work). RETAIL-VERIFIED. +- **A15 — get_command drops the CanCharge fast-path**. Retail 0052aa95: `flags & 0x10` → + unconditional `HoldKey_Run` BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade. + A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both. + RETAIL-VERIFIED (and consistent with `memory/feedback_autowalk_cancharge_bit.md`). +- **A16 — server-tick constants to re-check at 30 Hz.** ACE runs MoveToManager on its own cadence; + the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum + hysteresis) are retail-identical (verified), so no rescaling is needed — but the *feel* of the + aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks. + +### BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C) + +- `HandleMoveToPosition` chase-arrival branch (00529f8f `test ah,0x41`) renders INVERTED — the flee + branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at + `dist <= distance_to_object`. See §1 HandleMoveToPosition note. +- `BeginTurnToHeading` turn-direction branches (00529bf4/00529c23 `test ah,0x41`/`0x5`) — senses + garbled; ACE's ≤180→TurnRight reading is correct. +- `heading_diff` (0x00528fb0) — x87 return garble; the `motion != TurnRight → 360 - result` mirror + is invisible in the pseudo-C. Verify once via Ghidra MCP before porting. +- `get_command`'s first flag read is BN-rendered as a bogus `(int16_t)` pointer deref of the flags + word — it's just `flags & 0x200` / `& 0x100` on byte 1. + +### Negative results (look like ACE-isms, are retail-faithful) + +- **FailProgressCount is vestigial in RETAIL too** — grep shows only writes (reset/increment) at + 005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere. + Do not invent a cancel-after-N-failures mechanism. +- **Double adjust_motion** (_DoMotion + DoInterpretedMotion-internal) — retail does the same. +- **TurnToObject desired-heading clobber** (write CurrentTargetPosition → Internal overwrites and + reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim. +- **BeginTurnToHeading storing a DIFF into PreviousHeading** — retail 00529c82 does the same. +- **PerformMovement returning success unconditionally** — retail returns 0 too. +- **Error codes all line up** (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB, + ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against + both retail immediates and ACE's WeenieError enum. + +--- + +## 5. acdream R4 blast radius — what exists today + +All paths worktree-relative (`.claude/worktrees/vigorous-joliot-f0c3ad`). + +### `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (341 lines, static) + +Per-tick steering for **server-controlled remotes** during mt 6/7. Approximates +`HandleMoveToPosition`'s aux-turn + arrival only: + +- `Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient)` + (`:170-254`): horizontal distance; arrival `moveTowards ? dist <= distanceToObject + ε : dist >= minDistance − ε` + (`ArrivalEpsilon = 0.05` — acdream fudge, not retail); heading via quaternion yaw; **±20° snap-to-heading** + (`HeadingSnapToleranceRad`, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band); + otherwise rotate at `TurnRateRadPerSec = π/2`. +- Constants: `RunTurnFactor = 1.5` (retail run_turn_factor @ 0x007c8914), `BaseTurnRateRadPerSec = π/2`, + `TurnRateFor(running)`, `StaleDestinationSeconds = 1.5` (acdream-only staleness guard — replaces + retail's TargetManager re-tracking), `ClampApproachVelocity` (`:296-329`, acdream-only overshoot clamp), + `OriginToWorld` (`:263-277`, landblock-local → render world). +- ⚠ Its class-level doc (`:53-57`) still claims "ACE swaps the chase/flee arrival predicates vs + retail" — WRONG, contradicted by its own later comment (`:186-199`) and by this document. Fix the + doc comment during R4. +- What it deliberately skips (doc `:44-51`): target re-tracking, Sticky/StickTo, fail-distance + + progress detector, cylinder-sphere distance — the R4 port's main new surface. + +### `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (88 lines, static) + +Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand): +`PlanMoveToStart(moveToSpeed, runRate, canRun)` ≈ `BeginMoveForward → get_command → adjust_motion` +collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic +at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). `PlanFromVelocity` (fallback from +observed velocity; `StopSpeed=0.20`, `RunThreshold=1.25` — acdream heuristics, not retail). +R4 replaces this seeding with real `MovementParameters.get_command`. + +### `src/AcDream.App/Input/PlayerMovementController.cs` — B.6 auto-walk block (KEEP-LIST until R4) + +- Fields `:264-296`: `_autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/ + _autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning`; turn-direction tracker + `_autoWalkTurnDirectionThisFrame` (`:323`); `IsServerAutoWalking` (`:308`); `AutoWalkArrived` event + (`:340`, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround). +- `BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge)` (`:452-487`): + installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches + retail A15 semantics relayed by ACE's server-side `Creature.SetWalkRunThreshold`). +- `EndServerAutoWalk(reason)` (`:495-503`): idempotent; fires `AutoWalkArrived` on `reason=="arrived"`. +- `DriveServerAutoWalk(dt, input)` (`:567-790`) — the local-player approximation of + HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel; + horizontal distance; arrival `moveTowards ? dist <= distanceToObject : dist >= minDistance + ε` + **gated on 5° alignment** (acdream-only: retail fires arrival purely on distance; the alignment + gate exists because ACE rotates server-side before the Use callback); continuous turn at + `TurnRateFor(_autoWalkInitiallyRunning)` with no snap (deliberately retail-er than + RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the + discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving); + turn-in-place phase issues `DoMotion(Ready)` + zeroes horizontal velocity; forward phase issues + `DoMotion(WalkForward, runRate|1.0)` + `set_local_velocity` from `get_state_velocity`. + R4 replaces this whole function with real pending-node MoveToManager driving. +- Call site: `Update` `:896` (`autoWalkConsumedMotion` skips the user-input motion/velocity blocks); + `:1028` and `:1528` carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover). + +### `src/AcDream.App/Rendering/GameWindow.cs` — MoveTo packet handling + +- Inbound UM seeding `:4401-4435`: when `update.MotionState.IsServerControlledMoveTo` and + ForwardCommand absent → `ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate, + MoveToCanRun)` provides `fullMotion/speedMod` for the animation funnel. +- Local player `:4507-4547`: mt 6/7 with `MoveToPath` payload → `OriginToWorld` → + `_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge)`. + Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (`:4534-4546`, + trace-verified ACE behavior). +- Remotes `:4570-4610`: `remoteMot.ServerMoveToActive = IsServerControlledMoveTo`; path payload → + `MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/ + LastMoveToPacketTime`; non-MoveTo UM clears `HasMoveToDestination`. +- Remote per-tick `:9594-9682`: staleness guard (1.5 s) → else `RemoteMoveToDriver.Drive` → + orientation write; Arrived → zero velocity; Steering → `rm.Motion.apply_current_movement` + + `ClampApproachVelocity`. MoveTo-active-but-no-path → hold velocity zero (882a07c stance). +- Speculative install `:12122-12164`: `InstallSpeculativeTurnToTarget` — local prediction of ACE's + MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule + (`AceCanChargeDistance = WalkRunThreshold/2`); per-type use radius (0.6 default / 3.0 creature / + 2.0 door-lifestone-portal-corpse). +- `:4942`: second `PlanMoveToStart` consumer (same seeding on another path — CreateObject-time). + +### Wire layer + +- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`: parses mt 6/7 union → `CreateObject.MoveToPathData` + (`:157`, `:287-344`); exposes `IsServerControlledMoveTo` (mt is 6 or 7), `MoveToCanRun` + (params bit 0x2), `MoveTowards` (0x200), `CanCharge` (0x10), `MoveToSpeed`, `MoveToRunRate`. +- `src/AcDream.Core.Net/Messages/CreateObject.cs:292-302`: `MoveToPathData(TargetGuid, OriginCellId, + OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading)` — + the full retail `MovementParameters` float block is already captured on the wire; FailDistance and + WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers). +- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`: `ProbeAutoWalkEnabled` (`ACDREAM_PROBE_AUTOWALK`) + gates `[autowalk-mt]/[autowalk-begin]/[autowalk-end]` traces. + +### What R4 must reconcile + +Today there are **three independent approximations** of one retail mechanism: DriveServerAutoWalk +(local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart +(animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical), +driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as +`MotionInterpreter`). The wire already delivers every field the retail manager needs +(MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/ +MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade + +fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the +TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness +guard — decide in R4 whether that substitution stays as a documented divergence-register row). diff --git a/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md b/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md new file mode 100644 index 00000000..d7c0527b --- /dev/null +++ b/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md @@ -0,0 +1,1232 @@ +# R4 — MoveToManager: verbatim decomp extraction + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file +line numbers, not addresses). Structs: `docs/research/named-retail/acclient.h`. +Style/pin conventions follow `docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md`; +the MovementParameters bit masks are the R3 **A4 pin** (`W0-pins.md`) and are used as-is here. +ACE cross-ref: `references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` +(interpretation aid only; decomp wins). StickyManager / ConstraintManager bodies are +**out of scope (R5)** — only their call shapes from MoveToManager appear here. + +Coverage: every `MoveToManager::` member in the raw (grep enumerated 33 members, all +extracted below), the MovementManager relay/dispatch, the MovementParameters +command-selection family, and the CPhysicsObj-side entry points. + +--- + +## 0. Key structs (acclient.h) + +```c +/* acclient.h:31473 — struct #3462 */ +struct __cppobj __declspec(align(8)) MoveToManager +{ + MovementTypes::Type movement_type; // +0x00 + Position sought_position; // +0x04 (Position = vtable+objcell_id+Frame = 0x48) + Position current_target_position; // +0x4C + Position starting_position; // +0x94 + MovementParameters movement_params; // +0xDC (bitfield +0xE0, distance_to_object +0xE4, + // min_distance +0xE8, desired_heading +0xEC, + // speed +0xF0, fail_distance +0xF4, + // walk_run_threshhold +0xF8) + float previous_heading; // +0x108 + float previous_distance; // +0x10C + long double previous_distance_time; // +0x110 + float original_distance; // +0x118 + long double original_distance_time; // +0x120 + unsigned int fail_progress_count; // +0x128 + unsigned int sought_object_id; // +0x12C + unsigned int top_level_object_id; // +0x130 + float sought_object_radius; // +0x134 + float sought_object_height; // +0x138 + unsigned int current_command; // +0x13C + unsigned int aux_command; // +0x140 + int moving_away; // +0x144 + int initialized; // +0x148 + DLList pending_actions; // +0x14C head, +0x150 tail + CPhysicsObj *physics_obj; // +0x154 (confirmed: Create writes +0x154) + CWeenieObject *weenie_obj; // +0x158 (confirmed: Create writes +0x158) +}; // sizeof = 0x160 (operator new(0x160) in Create) + +/* acclient.h:57702 — struct #6350; alloc sites use operator new(0x10) */ +struct __cppobj MoveToManager::MovementNode : DLListData +{ // +0 dllist_next, +4 dllist_prev (DLListData) + MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued + float heading; // +0xc — only meaningful for type 9 +}; + +/* acclient.h:2856 — enum #229 */ +enum MovementTypes::Type { Invalid=0, RawCommand=1, InterpretedCommand=2, + StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, + MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 }; + +/* acclient.h:38069 — struct #4067 */ +struct __cppobj MovementStruct +{ + MovementTypes::Type type; + unsigned int motion; // types 1-4 only + unsigned int object_id; // types 6, 8 + unsigned int top_level_id; // types 6, 8 + Position pos; // type 7 + float radius; // type 6 + float height; // type 6 + MovementParameters *params; // types 1-4, 6-9 +}; + +/* acclient.h:31453 + bitfield 31423-31443 — the A4 pin masks apply: + 0x1 can_walk / 0x2 can_run / 0x4 can_sidestep / 0x8 can_walk_backwards / + 0x10 can_charge / 0x20 fail_walk / 0x40 use_final_heading / 0x80 sticky / + 0x100 move_away / 0x200 move_towards / 0x400 use_spheres / 0x800 set_hold_key / + 0x1000 autonomous / 0x2000 modify_raw_state / 0x4000 modify_interpreted_state / + 0x8000 cancel_moveto / 0x10000 stop_completely / 0x20000 disable_jump_during_link. + Ctor default 0x1EE0F; distance_to_object=0.6, min_distance=0, fail_distance=FLT_MAX, + desired_heading=0, speed=1, walk_run_threshhold=15, hold_key=HoldKey_Invalid. */ + +/* acclient.h:31591 — struct #3482 */ +struct __cppobj TargetInfo +{ + unsigned int context_id; + unsigned int object_id; + float radius; + long double quantum; + Position target_position; + Position interpolated_position; + AC1Legacy::Vector3 interpolated_heading; + AC1Legacy::Vector3 velocity; + TargetStatus status; + long double last_update_time; +}; + +/* acclient.h:7264 */ +enum TargetStatus { Undef=0, Ok_TargetStatus=1, ExitWorld=2, Teleported=3, + Contained=4, Parented=5, TimedOut=6 }; + +/* acclient.h:3394 */ +enum HoldKey { HoldKey_Invalid=0, HoldKey_None=1, HoldKey_Run=2 }; +``` + +Motion-command literals used throughout (cross-ref ACE `MotionCommand`): +`0x45000005` WalkForward, `0x45000006` WalkBackwards, `0x44000007` RunForward, +`0x6500000d` TurnRight, `0x6500000e` TurnLeft. + +--- + +## 1. Creation / lifecycle + +### 1a. `MoveToManager::MoveToManager` — `005293b0` @ 306554-306593 + +Verbatim (member-init noise elided): default-constructs `sought_position`, +`current_target_position`, `starting_position` (identity Frame, objcell_id 0, +`Frame::cache` each), `MovementParameters::MovementParameters(&this->movement_params)`, +`pending_actions.head_ = 0; tail_ = 0; physics_obj = 0; weenie_obj = 0;` then +`MoveToManager::InitializeLocalVariables(this)` (@306592). + +### 1b. `MoveToManager::Create` — `00529470` @ 306597-306614 + +```c +00529475 void* eax = operator new(0x160); +0052947f if (eax == 0) return 0; +00529483 void* result = MoveToManager::MoveToManager(eax); +0052948a if (result != 0) { +00529494 *(uint32_t*)((char*)result + 0x154) = arg1; // physics_obj +0052949a *(uint32_t*)((char*)result + 0x158) = arg2; // weenie_obj +0052948a } +005294a0 return result; +``` + +Note: unlike `MovementManager::Create`, this writes the pointers DIRECTLY (no +`SetPhysicsObject`/`SetWeenieObject` virtual-ish path). + +### 1c. `MoveToManager::InitializeLocalVariables` — `00529250` @ 306490-306534 + +```c +00529259 this->movement_type = 0; // Invalid +0052925b this->movement_params.__inner0 = 0; // bitfield CLEARED (not re-defaulted!) +00529261 this->movement_params.context_id = 0; +0052926c this->previous_distance = 3.40282347e+38f; // FLT_MAX +0052927e this->previous_distance_time = Timer::cur_time; // [BN renders the 8-byte double copy + // as two 32-bit halves @306497-306498] +0052928a this->original_distance = 3.40282347e+38f; +005292a5 this->original_distance_time = Timer::cur_time; +005292ab this->previous_heading = 0f; +005292b1 this->fail_progress_count = 0; +005292b7 this->current_command = 0; +005292bd this->aux_command = 0; +005292c3 this->moving_away = 0; +005292c9 this->initialized = 0; + /* @306508-306529: sought_position and current_target_position reset to + objcell_id 0 + identity Frame (starting_position NOT reset) */ +0052935d this->sought_object_id = 0; +00529363 this->top_level_object_id = 0; +00529369 this->sought_object_radius = 0f; +0052936f this->sought_object_height = 0f; +``` + +Cleaned intent: full soft-reset. Only `movement_params.bitfield` + `context_id` are +zeroed of the params (the float fields keep stale values — every entry point copies +all ten fields anyway). `pending_actions` is NOT drained here (callers drain first). + +### 1d. `MoveToManager::Destroy` — `005294b0` @ 306618-306663 + +Drains `pending_actions` with the inlined DLList unlink+`operator delete` loop +(@306621-306659, byte-identical in shape to the drain in `CancelMoveTo` §7c), then +tailcalls `InitializeLocalVariables` (@306662). + +### 1e. `MoveToManager::~MoveToManager` — `005299d0` @ 306945-306953 + +`Destroy(this)` then repoints the four Position/PackObj vtables (`0x79285c`) — pure +dtor bookkeeping. + +### 1f. `SetWeenieObject` `00529230` @ 306474-306478 / `SetPhysicsObject` `00529240` @ 306482-306486 + +Plain field stores. **No side effects** (unlike CMotionInterp's versions, which +re-dispatch movement — R3 A3). + +### 1g. `MoveToManager::is_moving_to` — `00529220` @ 306464-306470 + +```c +00529226 result = this->movement_type != Invalid; +``` + +--- + +## 2. MovementManager relay layer + +### 2a. `MovementManager::MakeMoveToManager` — `00524000` @ 300124-300129 + +```c +00524008 if (this->moveto_manager == 0) +0052401a this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); +``` + +### 2b. `MovementManager::PerformMovement` — `005240d0` @ 300194-300237 + +```c +005240d9 CPhysicsObj::set_active(this->physics_obj, 1); +005240e4 void* eax_1 = (arg2->type - 1); +005240e8 if (eax_1 > 8) return 0x47; +005240f1 switch (eax_1) { + case 0..4: // RawCommand, InterpretedCommand, StopRawCommand, + // StopInterpretedCommand, StopCompletely +005240fb if (this->motion_interpreter == 0) { +00524105 eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); +00524112 this->motion_interpreter = eax_3; +00524114 if (physics_obj != 0) CMotionInterp::enter_default_state(eax_3); +005240fb } +00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); + case 5..8: // MoveToObject(6), MoveToPosition(7), TurnToObject(8), TurnToHeading(9) +0052412f if (this->moveto_manager == 0) +00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); +00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); +0052414f return 0; + } +``` + +The lookup table @300244-300255 confirms the 1-5 → interp / 6-9 → moveto split. +Type 0 (Invalid) and >9 return `0x47`. **Moveto types always return 0** — errors +surface via `CancelMoveTo`, not the return value. + +### 2c. `MovementManager::CancelMoveTo` — `005241b0` @ 300277-300288 + +```c +005241b0 class MoveToManager* moveto_manager = this->moveto_manager; +005241b5 if (moveto_manager == 0) return; +005241b7 uint32_t edx; +005241b7 return MoveToManager::CancelMoveTo(moveto_manager, edx); // [BN uninit-edx relay + // artifact — A7 family; + // arg2 (WeenieError) is + // passed through] +``` + +### 2d. Per-tick / event forwarders + +- `MovementManager::UseTime` `005242f0` @ 300411-300421: `if (moveto_manager) tailcall MoveToManager::UseTime`. **CMotionInterp is NOT ticked here** — UseTime is moveto-only. +- `MovementManager::HitGround` `00524300` @ 300425-300440: `minterp->HitGround()` then `tailcall MoveToManager::HitGround(moveto_manager)`. +- `MovementManager::HandleUpdateTarget` `00524790` @ 300723-300730: `if (moveto_manager) MoveToManager::HandleUpdateTarget(moveto_manager, &arg2)`. **Confirms the R3 negative result: HandleUpdateTarget lives on MoveToManager** (§6 below). +- `MovementManager::IsMovingTo` `00524260` @ 300352-300361: `moveto_manager && MoveToManager::is_moving_to(...)`. +- `MovementManager::Destroy` `005243f0` @ 300538-300559: deletes both minterp (`~CMotionInterp` + `operator delete`) and moveto (`~MoveToManager` + `operator delete`). +- `MovementManager::SetWeenieObject` `00524020` @ 300133-300146: forwards to both members. + +### 2e. ⚠ COMDAT-folded no-ops (BN symbol-collision mislabels) + +Three forwarders tailcall `IDClass<_tagDataID,32,0>::~IDClass` — the TRIVIAL EMPTY +function; the linker's identical-COMDAT folding collapsed several empty methods into +one body and the PDB picked the IDClass dtor as the canonical name: + +- `MovementManager::LeaveGround` `00524320` @ 300444-300459: `minterp->LeaveGround()` then "tailcall IDClass dtor(moveto_manager)" → **`MoveToManager::LeaveGround` is a no-op in this build.** +- `MovementManager::ReportExhaustion` `00524360` @ 300491-300506: `minterp->ReportExhaustion()` then the same folded no-op on moveto_manager → **`MoveToManager::ReportExhaustion` is a no-op.** +- `MovementManager::HandleEnterWorld` `00524340` @ 300463-300473: folded no-op on motion_interpreter → **`CMotionInterp::HandleEnterWorld` is a no-op** (HandleExitWorld @300477-300487 calls the real `CMotionInterp::HandleExitWorld`). + +Port: MoveToManager needs no LeaveGround/ReportExhaustion members; do not invent +behavior for them. + +### 2f. `MovementManager::unpack_movement` — `00524440` @ 300563-300719 (the wire entry) + +The client-side parse of the server movement event. Head (@300566-300598): requires +`motion_interpreter != 0 && physics_obj != 0`; then +`CPhysicsObj::interrupt_current_movement` + `CPhysicsObj::unstick_from_object`; +reads uint16 `type` header + uint16 style index; `ecx_5 = command_ids[style]`; if the +minterp's current style differs (@300597 — `CBaseFilter::GetPinVersion(minterp)` is a +**mislabeled trivial getter** of the interp's current style, symbol-collision artifact) +→ `CMotionInterp::DoMotion(minterp, style, &defaultParams)`. Then per type: + +```c +case 0: // full InterpretedMotionState +00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); +0052455d if (header & 0x100) sticky_object_guid = read_dword(); // [flag bit via var_a4 byte1&1] +0052457c MovementManager::move_to_interpreted_state(this, &var_28); +00524583 if (sticky_object_guid) CPhysicsObj::stick_to_object(this->physics_obj, guid); +0052458e this->motion_interpreter->standing_longjump = (header & 0x200); +case 6: // MoveToObject +005245b3 MovementManager::MakeMoveToManager(this); +005245ba object_id = read_dword(); +005245ce Position::UnPackOrigin(&var_70, ...); // target origin +005245db MovementParameters::UnPackNet(&var_9c, MoveToObject, ...); +005245e9 this->motion_interpreter->my_run_rate = read_float(); +005245f9 if (CPhysicsObj::GetObjectA(object_id) == 0) goto label_524668; // object not + // resolvable → +00524604 CPhysicsObj::MoveToObject(this->physics_obj, object_id, &var_9c); // else full path +case 7: // MoveToPosition +00524629 MakeMoveToManager; UnPackOrigin(&var_70); UnPackNet(MoveToPosition); my_run_rate = read_float(); +00524668 label_524668: +00524668 MoveToManager::MoveToPosition(this->moveto_manager, &var_70, &var_9c); + // ^ the case-6 fallback DEGRADES MoveToObject to MoveToPosition(wire origin) +case 8: // TurnToObject +0052468d MakeMoveToManager; object_id = read_dword(); wire_heading = read_dword(); +005246b5 UnPackNet(&var_9c, TurnToObject, ...); +005246c5 if (GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; // @300676: + goto label_524725; } // fallback → TurnToHeading +005246d0 CPhysicsObj::TurnToObject(this->physics_obj, object_id, &var_9c); +case 9: // TurnToHeading +00524704 MakeMoveToManager; UnPackNet(&var_9c, TurnToHeading, ...); +00524725 label_524725: +00524725 MoveToManager::TurnToHeading(this->moveto_manager, &var_9c); +``` + +(Return 1 on every handled case, 0 otherwise; jump table @300707-300719.) + +### 2g. `MovementParameters::UnPackNet` — `0052ac50` @ 308118-308205 (wire field order) + +- MoveToObject / MoveToPosition (0x1c bytes, 7 dwords): `bitfield, distance_to_object, + min_distance, fail_distance, speed, walk_run_threshhold, desired_heading`. +- TurnToObject / TurnToHeading (0xc bytes, 3 dwords): `bitfield, speed, desired_heading`. +- Types outside 6-9 → return 0. **The wire bitfield carries can_charge 0x10** — this is + the walk-vs-run answer consumed in §5c (cross-ref `feedback_autowalk_cancharge_bit`). + +(`Pack`/`UnPack` @308037-308114 are the full 0x28-byte 10-field forms — dat/persist +serialization, order: bitfield, distance_to_object, min_distance, fail_distance, +desired_heading, speed, walk_run_threshhold, context_id, hold_key, action_stamp.) + +--- + +## 3. Entry points (movement_type setters) + +### 3a. `MoveToManager::PerformMovement` — `0052a900` @ 307871-307904 + +```c +0052a901 int32_t var_8 = 0x36; // WeenieError.ActionCancelled arg setup +0052a905 MoveToManager::CancelMoveTo(this, edx); // [uninit-edx artifact; 0x36 is the arg] +0052a910 CPhysicsObj::unstick_from_object(this->physics_obj); // → PositionManager::UnStick +0052a923 switch (arg2->type - 6) { +0052a940 case 0: MoveToManager::MoveToObject(this, arg2->object_id, arg2->top_level_id, + arg2->radius, arg2->height, arg2->params); break; +0052a955 case 1: MoveToManager::MoveToPosition(this, &arg2->pos, arg2->params); break; +0052a96e case 2: MoveToManager::TurnToObject(this, arg2->object_id, arg2->top_level_id, + arg2->params); break; +0052a97f case 3: MoveToManager::TurnToHeading(this, arg2->params); break; + } +0052a987 return 0; +``` + +Every new moveto CANCELS the previous one (0x36) and UNSTICKS first. + +### 3b. `MoveToManager::MoveToObject` — `00529680` @ 306756-306817 + +```c +0052968e if (physics_obj != 0) { +005296cc CPhysicsObj::StopCompletely(physics_obj, edx); +005296e0 this->starting_position = physics_obj->m_position; // objcell + Frame copy +005296fe this->sought_object_id = arg2; +00529708 this->sought_object_radius = arg4; +00529712 this->sought_object_height = arg5; +00529718 this->movement_type = 6; // MoveToObject +0052971e this->top_level_object_id = arg3; +00529727-306783 this->movement_params = *arg6; // all 10 fields copied individually +00529784 this->initialized = 0; +00529791 if (arg3 == physics_obj->id) { // targeting SELF +00529795 MoveToManager::CleanUp(this); +0052979a if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); // label_52979a +00529791 } else { +005297c4 CPhysicsObj::set_target(physics_obj, 0, this->top_level_object_id, 0.5f, 0f); + // (context_id=0, target=top_level, radius=0.5, quantum=0.0) + } } + // physics_obj == 0 path (@306798-306807): dead frame-cache noise, falls into + // label_52979a (StopCompletely if obj appeared) — effectively a no-op guard. +``` + +**No nodes are queued yet** — object moves are deferred until the first +`HandleUpdateTarget` callback delivers the target's position (§6). Note the wire's +`MovementParameters.sticky` (0x80) is preserved here (unlike position moves, §3c). + +### 3c. `MoveToManager::MoveToPosition` — `0052a240` @ 307521-307593 + +```c +0052a24e if (physics_obj_1 != 0) { +0052a259 CPhysicsObj::StopCompletely(physics_obj_1, edx); +0052a26c this->current_target_position = *arg2; +0052a276 this->sought_object_radius = 0f; +0052a280 MoveToManager::GetCurrentDistance(this); // → curr_distance (x87) +0052a292 heading_diff = Position::heading(&myPos, arg2) - CPhysicsObj::get_heading(); +0052a2ba if (fabs(heading_diff) < 0.000199999995f) heading_diff = 0; // @307547 +0052a2d5 if (heading_diff < -0.000199999995f) heading_diff += 360f; // @307554 +0052a304 MovementParameters::get_command(arg3, curr_distance, heading_diff, + &cmd, &holdKey, &movingAway); +0052a30f if (cmd != 0) { +0052a31b MoveToManager::AddTurnToHeadingNode(this, Position::heading(&myPos, arg2)); +0052a332 MoveToManager::AddMoveToPositionNode(this); +0052a30f } +0052a33b if ((arg3->bitfield & 0x40) != 0) // use_final_heading +0052a343 MoveToManager::AddTurnToHeadingNode(this, arg3->desired_heading); +0052a34f this->sought_position = *arg2; +0052a366 this->starting_position = physics_obj->m_position; +0052a378 this->movement_type = 7; // MoveToPosition +0052a381-307589 this->movement_params = *arg3; // 10-field copy +0052a3e5 this->movement_params.__inner0 &= 0xffffff7f; // CLEAR sticky (0x80) +0052a3eb MoveToManager::BeginNextNode(this); +``` + +Node plan: `[TurnToHeading(face target)] → [MoveToPosition] → [TurnToHeading(final)]?`. +If `get_command` says no motion needed (already inside distance_to_object), NO +move nodes are queued — only the optional final-heading turn. Position moves and +heading turns **force sticky off**; only object moves can stick (§4c). + +### 3d. `MoveToManager::TurnToObject` — `005297d0` @ 306820-306882 + +```c +005297de if (physics_obj != 0) { +0052984c if ((arg4->bitfield & 0x10000) != 0) // stop_completely +00529850 CPhysicsObj::StopCompletely(physics_obj, edx); +00529859 this->movement_type = 8; // TurnToObject +0052985f this->sought_object_id = arg2; +0052986c Frame::set_heading(&this->current_target_position.frame, arg4->desired_heading); + // ^ seeds CURRENT_TARGET's heading — but TurnToObject_Internal later + // OVERWRITES current_target_position wholesale and reads + // SOUGHT_position's heading (§6c). ACE MoveToManager.cs:246 matches + // this verbatim (also writes CurrentTargetPosition) — retail quirk, + // NOT a BN artifact: desired_heading is effectively DISCARDED for + // TurnToObject; the final heading = heading-to-target + sought.frame + // heading (0 after InitializeLocalVariables). +00529875 this->top_level_object_id = arg3; +0052987e-306847 this->movement_params = *arg4; // 10-field copy +005298d5 if (arg3 == physics_obj->id) { CleanUp; StopCompletely; } // self-target + else { +0052990c this->initialized = 0; +00529916 CPhysicsObj::set_target(physics_obj_1, 0, arg3, 0.5f, 0f); + } } + // physics_obj == 0 path @306862-306872: stores context_id only + dead + // frame noise, then StopCompletely-if-obj (label_5298e8). +``` + +Deferred like MoveToObject; `stop_completely` (0x10000) is honored HERE (conditional) +where MoveToObject/MoveToPosition stop unconditionally. + +### 3e. `MoveToManager::TurnToHeading` — `0052a630` @ 307706-307772 + +```c +0052a641 if (physics_obj != 0) { +0052a6aa if ((arg2->bitfield & 0x10000) != 0) // stop_completely +0052a6ad CPhysicsObj::StopCompletely(physics_obj, edx); +0052a6b5-307729 this->movement_params = *arg2; // 10-field copy +0052a70c this->movement_params.__inner0 &= 0xffffff7f; // CLEAR sticky +0052a71d Frame::set_heading(&this->sought_position.frame, arg2->desired_heading); +0052a722 this->movement_type = 9; // TurnToHeading +0052a731 node = operator new(0x10); node->type(+8) = 9; node->heading(+0xc) = arg2->desired_heading; +0052a766 DLListBase::InsertAfter(&this->pending_actions, node, tail_); +0052a76d MoveToManager::BeginNextNode(this); +0052a641 } else { /* @307753-307770: context_id store + StopCompletely-if-obj noise */ } +``` + +Note `initialized` is NOT set for TurnToHeading/MoveToPosition — the UseTime gate +(§6a) passes anyway because `top_level_object_id == 0` for non-object moves. + +--- + +## 4. Node stepping + +### 4a. Node factories + +`AddTurnToHeadingNode` `00529530` @ 306667-306685: `new(0x10)`, `+0xc = heading`, +`+8 = 9`, `DLListBase::InsertAfter(&pending_actions, node, tail_)` (tail-append). +`AddMoveToPositionNode` `00529580` @ 306689-306706: same with `+8 = 7`, no heading. +`RemovePendingActionsHead` `00529380` @ 306538-306550: `DLListBase::Remove(head)` + +`operator delete(head)` (several sites inline this same pattern instead of calling it). + +### 4b. `MoveToManager::BeginNextNode` — `00529cb0` @ 307123-307171 + +```c +00529cb6 head_ = this->pending_actions.head_; +00529cbe if (head_ != 0) { +00529cc0 type = *(int*)((char*)head_ + 8); +00529cc6 if (type == 7) return MoveToManager::BeginMoveForward(this); // tailcall +00529ccb if (type == 9) return MoveToManager::BeginTurnToHeading(this); // tailcall +00529ccb return; // unknown node type: stall (defensive) +00529cbe } + // queue EMPTY → moveto complete: +00529ce5 if ((int8_t)(this->movement_params.bitfield & 0xff) < 0) { // byte0 sign = bit7 = STICKY +00529cef height = this->sought_object_height; radius = this->sought_object_radius; +00529d00 tlid = this->top_level_object_id; +00529d0c MoveToManager::CleanUp(this); +00529d19 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); +00529d3a PositionManager::StickTo(CPhysicsObj::get_position_manager(this->physics_obj), + tlid, radius, height); // ← R5 seam (call shape) +00529d44 return; +00529ced } +00529d47 MoveToManager::CleanUp(this); +00529d54 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); +``` + +**This is the sticky handoff**: arrival at a sticky object move transfers control to +StickyManager via `PositionManager::StickTo(top_level_object_id, sought_radius, +sought_height)`. The radius/height/tlid are read BEFORE CleanUp zeroes them. + +### 4c. `MoveToManager::BeginMoveForward` — `00529a00` @ 306957-307042 + +```c +00529a0e if (physics_obj == 0) { CancelMoveTo(this, 8); return; } // [8 = NoPhysicsObject + // via __saved_edi store] +00529a1d dist = MoveToManager::GetCurrentDistance(this); // [x87 return] +00529a3e heading = Position::heading(&myPos, ¤t_target_position) - get_heading(); +00529a5a if (fabs(heading) < 0.000199999995f) heading = 0; +00529a75 if (heading < -0.000199999995f) heading += 360f; // normalize [0,360) +00529aa4 MovementParameters::get_command(&this->movement_params, dist, heading, + &cmd, &holdKey, &movingAway); +00529aaf if (cmd == 0) { // nothing to do (already in range) +00529ab1 pop+delete head node; // inlined RemovePendingActionsHead +00529ad6 MoveToManager::BeginNextNode(this); +00529ae0 return; +00529aaf } +00529ae5 MovementParameters localParams; // default-ctor +00529af4 localParams.hold_key_to_apply = holdKey; // [BN dead-store garble: the +00529b07 localParams.bitfield &= 0xffff7fff; // var_8/var_28 writes are stores +00529b0e localParams.speed = this->movement_params.speed; // into the stack params — + // hoisted-register artifact. + // 0xffff7fff clears 0x8000 + // cancel_moveto so _DoMotion + // doesn't cancel THIS moveto] +00529b12 err = MoveToManager::_DoMotion(this, cmd, &localParams); +00529b19 if (err != 0) { MoveToManager::CancelMoveTo(this, err); return; } +00529b35 this->current_command = cmd; +00529b3f this->moving_away = movingAway; +00529b45 this->movement_params.hold_key_to_apply = holdKey; // chosen key WRITTEN BACK +00529b4b this->previous_distance = dist; +00529b5d this->previous_distance_time = Timer::cur_time; +00529b69 this->original_distance = dist; +00529b7c this->original_distance_time = Timer::cur_time; +``` + +### 4d. `MoveToManager::BeginTurnToHeading` — `00529b90` @ 307046-307120 + +```c +00529bad if (head_ == 0 || physics_obj == 0) { CancelMoveTo(this, 8); return; } +00529bba if (CPhysicsObj::motions_pending(physics_obj) != 0) return; // wait for anims to drain +00529bc0 target_heading = node->heading; // head +0xc +00529be0 diff = heading_diff(target_heading, get_heading(), 0x6500000d /*TurnRight*/); + // pick direction / early-out: +00529bee if (diff > 180f) { // [flag-test rendering @307074] +00529c19 if (diff + 0.000199999995f >= 360f) // effectively already there +00529c8f { RemovePendingActionsHead; BeginNextNode; return; } // label_529c8f +00529c28 turn = 0x6500000e; // TurnLeft +00529bf7 } else { +00529bf9 if (diff <= 0.000199999995f) // @307094: & 0x41 → ≤ eps +00529c04 goto label_529c8f; // done +00529c0a turn = 0x6500000d; // TurnRight +00529bf7 } +00529c31 MovementParameters localParams; // default; & 0xffff7fff (clear + // cancel_moveto); speed + +00529c58 localParams.speed = movement_params.speed; // hold_key copied from +00529c5c localParams.hold_key = movement_params.hold_key_to_apply; // this->movement_params +00529c60 err = MoveToManager::_DoMotion(this, turn, &localParams); +00529c67 if (err != 0) { CancelMoveTo(this, err); return; } +00529c7b this->current_command = turn; +00529c82 this->previous_heading = diff; // NOTE: stores the REMAINING DIFF, not a heading + // (HandleTurnToHeading's progress test then + // compares heading against this seed on its + // first tick — retail quirk, keep verbatim; + // ACE matches: PreviousHeading = diff) +``` + +--- + +## 5. Distance / progress / command-selection helpers + +### 5a. `MoveToManager::GetCurrentDistance` — `005291b0` @ 306435-306460 + +```c +005291bb if (physics_obj == 0) return; // [x87 return garbled to void] +005291ce if ((movement_params.bitfield & 0x400) == 0) // use_spheres NOT set +0052920f return Position::distance(&physics_obj->m_position, ¤t_target_position); + // use_spheres: +005291d0 return Position::cylinder_distance(GetRadius(physics_obj), GetHeight(physics_obj), + &physics_obj->m_position, + this->sought_object_radius, this->sought_object_height, + &this->current_target_position); + // [BN garbles the x87 float plumbing; arg order per the PDB signature — + // own radius/height from CPhysicsObj::GetRadius/GetHeight, target's from + // the stored sought_object_* fields] +``` + +Object moves (which set sought radius/height and get `use_spheres` on the wire) use +edge-to-edge cylinder distance; position moves use center distance. + +### 5b. `MoveToManager::CheckProgressMade` — `005290f0` @ 306385-306431 + +```c +005290f7 elapsed = Timer::cur_time - this->previous_distance_time; +00529102 if (elapsed <= 1.0) return 1; // [@306392 & 0x41: window < 1s → OK] +00529113 progress = moving_away ? (curr - previous_distance) + : (previous_distance - curr); // @306397-306400 +00529135 if (progress / elapsed >= 0.25f) { // [@306402-306406 flag test] +0052914a previous_distance = curr; previous_distance_time = Timer::cur_time; +00529175 total = moving_away ? (curr - original_distance) + : (original_distance - curr); +0052918d total_rate = total / (Timer::cur_time - original_distance_time); +0052918f if (total_rate >= 0.25f) return 1; // @306419-306423 +00529142 } +005291a1 return 0; // no progress +``` + +Cleaned intent (adjudicated with ACE `CheckProgressMade`, which matches): progress +is only evaluated after a **1-second** window; requires BOTH the incremental rate +(since last checkpoint) AND the overall rate (since the move began) to be +**≥ 0.25 units/second** toward (or away from, when moving_away) the target. +The incremental checkpoint only advances when the incremental rate passes. + +### 5c. `MovementParameters::get_command` — `0052aa00` @ 307946-308012 — **walk-vs-run + command pick** + +```c +0052aa03 flags = (int16_t)this->bitfield; + // --- motion command + moving_away --- +0052aa09 if (flags & 0x200) { // move_towards +0052aa0e if ((flags & 0x100) == 0) goto label_52aa66; // not move_away → plain towards +0052aa26 MovementParameters::towards_and_away(this, dist, heading, &cmd, &movingAway); +0052aa09 } else if ((flags & 0x100) == 0) { // neither towards nor away +0052aa66 label_52aa66: // plain TOWARDS: +0052aa66 if (dist > this->distance_to_object) // [@307962 test ah,0x41 — BN's if-sense +0052aa84 { cmd = 0x45000005; movingAway = 0; } // is INVERTED in the text; adjudicated +0052aa74 else cmd = 0; // via towards_and_away's explicit twin + // @307924 + ACE GetCommand] +0052aa30 } else { // move_away only: +0052aa32 if (dist < this->min_distance) // @307978 & 1 = C0 (explicit, reliable) +0052aa54 { cmd = 0x45000005; movingAway = 1; } // WalkForward, away (heading flips +0052aa44 else cmd = 0; // via get_desired_heading +180) +0052aa30 } + // --- HoldKey (walk vs run) --- +0052aa90 flags = (int16_t)this->bitfield; +0052aa95 if ((flags & 0x10) == 0) { // can_charge NOT set: +0052aa99 if ((flags & 2) == 0) // can_run not set → +0052aac2 { *holdKey = HoldKey_None; return; }// always walk +0052aa9d if ((flags & 1) != 0) { // can_walk set: +0052aaa3 if ((dist - distance_to_object) <= walk_run_threshhold) // @308003 & 0x41 ≤ +0052aac2 { *holdKey = HoldKey_None; return; } // close → WALK +0052aa9d } +0052aa95 } +0052aab4 *holdKey = HoldKey_Run; // can_charge, or far, or walk-incapable +``` + +**THE walk-vs-run rule** (confirms `feedback_autowalk_cancharge_bit`): +`HoldKey_Run` ⇐ `can_charge (0x10)` set, OR (`can_run` set AND (`can_walk` clear OR +`dist - distance_to_object > walk_run_threshhold` [default 15 units])). +`HoldKey_None` (walk) ⇐ no `can_run`, or walking-capable within threshold of target. +The chosen key rides into `_DoMotion` via `localParams.hold_key_to_apply` and is +written back to `movement_params.hold_key_to_apply` (§4c) — MoveToManager never +touches `set_hold_run` directly. + +### 5d. `MovementParameters::towards_and_away` — `0052a9a0` @ 307917-307942 + +```c +0052a9a4 if (dist > this->distance_to_object) // @307924 & 0x41 == 0 → greater (explicit) +0052a9b6 { *cmd = 0x45000005; *movingAway = 0; return; } // WalkForward, towards +0052a9cc if ((dist - this->min_distance) < 0.000199999995f) // inside min band +0052a9e1 { *cmd = 0x45000006; *movingAway = 1; } // WalkBackwards, away +0052a9d7 else *cmd = 0; // inside [min, dto] — idle + // [@307932-307937: BN's if(p)-sense inverted in the text; the cleaned + // branch assignment above is adjudicated by ACE TowardsAndAway + the + // physical meaning (back away only when too close)] +``` + +Note the asymmetry vs §5c: pure-away uses WalkForward+turn-around; towards_and_away +backs up with WalkBackwards (no turn). + +### 5e. `MovementParameters::get_desired_heading` — `0052aad0` @ 308016-308033 — ⚠ BN garble + +```c +0052aae9 if ((arg2 == 0x44000007 || arg2 == 0x45000005)) // RunForward | WalkForward +0052ab00 result = arg3; +0052aaec else if (arg2 != 0x45000006) return (arg2 - 0x45000006); // ← x87-return garble +0052aaee else result = arg3; // WalkBackwards +``` + +The BN text is an x87 setcc-garbled float return (the `arg2 - 0x45000006` "return" is +flag-synthesis nonsense — the R3 A5 artifact class). Cleaned intent (ACE +`get_desired_heading`, matching the command grouping the BN text preserves): + +``` +WalkForward | RunForward : return moving_away ? 180.0f : 0.0f; +WalkBackwards : return moving_away ? 0.0f : 180.0f; +default : return 0.0f; +``` + +i.e. the heading OFFSET added to raw heading-to-target so an "away" walk faces +away and an "away" backstep faces the target. **Port verify:** worth one Ghidra MCP +decompile of `0x0052aad0` when a CodeBrowser is open (server was down this session) +to confirm the two 180/0 constants — confidence high but the BN text alone doesn't +show them. + +### 5f. `heading_greater` — `00528f60` @ 306281-306323 (free function) + +```c +00528f68 if (fabs(arg1 - arg2) > 180.0) // wrapped case: compare flipped +00528f81 greater = (arg2 > arg1); // [@306291 & 0x41 → not-≤ = >] +00528f77 else greater = (arg1 > arg2); +00528fa1 if (arg3 == 0x6500000d) return greater; // TurnRight: heading increases +00528fa7 return greater == 0; // TurnLeft: inverted +``` + +"Has the turn passed the target heading" — direction-aware, 360°-wrap-aware +(cleaned reading; the two fcom branches @306291-306308 are explicit `& 0x41` +patterns, reliable). + +### 5g. `heading_diff` — `00528fb0` @ 306327-306347 (free function) + +```c +00528fb4 d = arg1 - arg2; +00528fc9 if (fabs(d) <= 0.000199999995f) d = 0; // [@306335 & 0x41] +00528fe1 if (d < -0.000199999995f) d += 360f; // [@306338-306342 flag test] +00529009 return d; // [BN renders the x87 return as flag + // synthesis vs eps — garble; callers + // (§4d, §6d) consume it as the + // normalized [0,360) diff] +``` + +`arg3` (the turn command) is UNUSED in this build's body — keep the parameter for +signature parity (ACE keeps it too). + +--- + +## 6. Per-tick drivers + target updates + +### 6a. `MoveToManager::UseTime` — `0052a780` @ 307776-307798 — **the tick gate** + +```c +0052a791 if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) { // CONTACT bit +0052a793 head_ = this->pending_actions.head_; +0052a7b4 if (head_ != 0 && ((this->top_level_object_id == 0 || + this->movement_type == Invalid) || + this->initialized != 0)) { +0052a7b6 type = *(int*)((char*)head_ + 8); +0052a7bc if (type == 7) return MoveToManager::HandleMoveToPosition(this); +0052a7c1 if (type == 9) return MoveToManager::HandleTurnToHeading(this); +0052a7b4 } +0052a791 } +``` + +Three gates: (1) grounded (`transient_state & 1` = CONTACT — no moveto progress +mid-air; `HitGround` §6e resumes), (2) a node exists, (3) object-moves +(`top_level_object_id != 0 && movement_type != Invalid`) must be `initialized` +(i.e. the first target update arrived). + +### 6b. `MoveToManager::HandleMoveToPosition` — `00529d80` @ 307187-307438 — the big per-tick driver + +```c +00529d94 if (physics_obj == 0) { CancelMoveTo(this, 8); return; } +00529db5 curPos = physics_obj->m_position; // objcell + Frame stack copy +00529dc7 MovementParameters localParams; // default; speed = + // movement_params.speed; + // bitfield &= ~0x8000; + // hold_key = movement_params. + // hold_key_to_apply [same + // dead-store garble as §4c] + // ---- PHASE 1: aux turn steering (only when not animating) ---- +00529df5 if (CPhysicsObj::motions_pending(this->physics_obj) != 0) { +00529ef6 if (aux_command) { _StopMotion(aux_command, &localParams); aux_command = 0; } +00529df5 } else { +00529e03 heading = get_desired_heading(&movement_params, current_command, moving_away) + + Position::heading(&curPos, ¤t_target_position); // @307225-307227 +00529e2d if (heading >= 360f) heading -= 360f; +00529e4e diff = heading - get_heading(); +00529e68 if (fabs(diff) < 0.000199999995f) diff = 0; +00529e80 if (diff < -0.000199999995f) diff += 360f; +00529e8b if (diff <= 20f || diff >= 340f) { // deadband [@307255-307261] +00529ee3 if (aux_command) { _StopMotion(aux); aux_command = 0; } +00529ead } else { +00529eaf turn = (diff >= 180f) ? 0x6500000e /*TurnLeft*/ : 0x6500000d /*TurnRight*/; + // [@307274-307280: BN default TurnRight + `if(p_3)` flip — the + // test-sense is ambiguous in the text; adjudicated via ACE + // HandleMoveToPosition (diff >= 180 → TurnLeft) + §4d's twin] +00529ecc if (turn != aux_command) { _DoMotion(turn, &localParams); aux_command = turn; } +00529ead } +00529df5 } + // ---- PHASE 2: arrival / progress ---- +00529f15 dist = GetCurrentDistance(); +00529f2c if (CheckProgressMade(this, dist) == 0) { +0052a003 if (!CPhysicsObj::IsInterpolating(physics_obj) && + !CPhysicsObj::motions_pending(physics_obj)) +0052a014 this->fail_progress_count += 1; // counter is WRITE-ONLY (§8) +00529f2c } else { +00529f3e this->fail_progress_count = 0; + // arrival predicate [@307306-307331: fcomp garbles; away-branch @307310 + // `& 1`=C0 is explicit; towards-branch if-sense inverted in BN text — + // adjudicated via ACE + the away branch]: + // arrived = moving_away ? (dist >= min_distance) // fcomp [esi+0xe8] + // : (dist <= distance_to_object) // fcomp [esi+0xe4] + if (!arrived) { +00529f63 startDist = Position::distance(&starting_position, &physics_obj->m_position); +00529f68 if (startDist > movement_params.fail_distance) // fcomp [esi+0xf4] +00529f7d MoveToManager::CancelMoveTo(this, 0x3d); // YouChargedTooFar + } else { // label_529f94 +00529f94 pop+delete head node; +00529fc5 _StopMotion(this->current_command, &localParams); +00529fd2 this->current_command = 0; +00529fd8 if (aux_command) { _StopMotion(aux); aux_command = 0; } +00529fef MoveToManager::BeginNextNode(this); + } +00529f2c } + // ---- PHASE 3: TargetManager quantum retune (object moves only) ---- +0052a029 if (this->top_level_object_id != 0 && this->movement_type != Invalid) { +0052a03a v = CPhysicsObj::get_velocity(this->physics_obj); + speed = sqrt(v.x² + v.y² + v.z²); // @307380-307393 raw x87 +0052a05f if (speed > 0.10000000000000001) { // double @data_7a6db0 +0052a06c eta = dist / speed; // @307413-307415 +0052a07e if (fabs(eta - CPhysicsObj::get_target_quantum(physics_obj)) >= 1.0) +0052a0a6 CPhysicsObj::set_target_quantum(this->physics_obj, eta); +0052a05f } +0052a029 } +``` + +Phase-3 cleaned intent (the x87 block @307378-307436 is heavily garbled; structure +adjudicated against ACE, which matches operation-for-operation): while chasing an +object, retune the TargetManager update quantum to the estimated time-to-arrival +(distance/speed) whenever it drifts ≥1 s from the current quantum — faster target +updates as you close in. **ACE-divergence trap:** ACE lines 444-446 add a +`\ custom: sync for server ticrate` `set_heading` in the stop-aux path — retail has +NO set_heading there; do not copy. + +### 6c. `MoveToManager::HandleTurnToHeading` — `0052a0c0` @ 307442-307517 + +```c +0052a0ce if (physics_obj == 0) { CancelMoveTo(this, 8); return; } +0052a0de cmd = this->current_command; +0052a0f0 if (cmd != 0x6500000e && cmd != 0x6500000d) // not currently turning +0052a0f4 { MoveToManager::BeginTurnToHeading(this); return; } +0052a0ff head_ = pending_actions.head_; +0052a10d heading = CPhysicsObj::get_heading(physics_obj); +0052a130 if (heading_greater(heading, head_->heading, cmd) != 0) { // PASSED the target +0052a13a this->fail_progress_count = 0; +0052a146 CPhysicsObj::set_heading(physics_obj, head_->heading, 1); // snap + send(1) +0052a14b pop+delete head node; +0052a16a MovementParameters localParams; // default; &= ~0x8000; hold_key copied +0052a195 _StopMotion(this->current_command, &localParams); +0052a19c this->current_command = 0; +0052a1a2 MoveToManager::BeginNextNode(this); +0052a1ae return; +0052a130 } +0052a1bc diff = heading_diff(heading, this->previous_heading, cmd); +0052a1cf if (diff < 180f && diff > 0.000199999995f) { // made rotational progress +0052a1e5 this->fail_progress_count = 0; +0052a1ef this->previous_heading = heading; +0052a1f9 return; +0052a1dc } +0052a206 this->previous_heading = heading; // no progress: +0052a213 if (!CPhysicsObj::IsInterpolating(physics_obj) && + !CPhysicsObj::motions_pending(this->physics_obj)) +0052a224 this->fail_progress_count += 1; +``` + +### 6d. `MoveToManager::HandleUpdateTarget` — `0052a7d0` @ 307802-307867 — **CONFIRMED here** (R3 open item closed) + +```c +0052a7db if (physics_obj == 0) { CancelMoveTo(this, 8); return; } +0052a7f6 if (this->top_level_object_id != arg2->object_id) return; // not our target +0052a804 if (this->initialized == 0) { + // ---- first callback: build the node plan ---- +0052a886 if (top_level_object_id == physics_obj->id) { // self-target +0052a88f sought_position = physics_obj->m_position; +0052a8aa current_target_position = physics_obj->m_position; +0052a8aa MoveToManager::CleanUpAndCallWeenie(this, 0); // instant success +0052a8b1 return; +0052a886 } +0052a8bb if (arg2->status != Ok_TargetStatus) +0052a8c1 { CancelMoveTo(this, 0x38); return; } // NoObject +0052a8d0 if (movement_type == MoveToObject) // 6 +0052a8f1 MoveToManager::MoveToObject_Internal(this, &arg2->target_position, + &arg2->interpolated_position); +0052a8d0 else if (movement_type == 8) // TurnToObject +0052a8dd MoveToManager::TurnToObject_Internal(this, &arg2->target_position); +0052a804 } else { + // ---- retarget while running ---- +0052a80d if (arg2->status != Ok_TargetStatus) +0052a813 { CancelMoveTo(this, 0x37); return; } // ObjectGone +0052a820 if (this->movement_type == MoveToObject) { +0052a82d sought_position = arg2->interpolated_position; +0052a839 current_target_position = arg2->target_position; +0052a843 previous_distance = 3.40282347e+38f; // FLT_MAX +0052a855 previous_distance_time = Timer::cur_time; +0052a861 original_distance = 3.40282347e+38f; +0052a873 original_distance_time = Timer::cur_time; // progress reset +0052a820 } +0052a804 } +``` + +Note the retarget path updates positions + resets the progress clock but does NOT +requeue nodes — the running MoveToPosition node keeps steering toward the moved +`current_target_position` each tick (§6b Phase 1). TurnToObject gets NO retarget +handling (heading was frozen at the initial callback). + +### 6e. `MoveToManager::HitGround` — `00529d70` @ 307175-307183 + +```c +00529d73 if (this->movement_type == Invalid) return; +00529d75 return MoveToManager::BeginNextNode(this); // tailcall — re-arm after landing +``` + +### 6f. `MoveToManager::MoveToObject_Internal` — `0052a400` @ 307597-307663 + +```c +0052a40e if (physics_obj == 0) { CancelMoveTo(this, 8); return; } +0052a42d this->sought_position = *arg3; // interpolated_position +0052a443 this->current_target_position = *arg2; // target_position +0052a455 iHeading = Position::heading(&physics_obj->m_position, arg3); // toward INTERPOLATED +0052a460 dist = GetCurrentDistance(); // vs current_target (cylinder if spheres) +0052a46f diff = iHeading - get_heading(); +0052a48c if (fabs(diff) < 0.000199999995f) diff = 0; +0052a4a7 if (diff < -0.000199999995f) diff += 360f; +0052a4d6 MovementParameters::get_command(&this->movement_params, dist, diff, + &cmd, &holdKey, &movingAway); +0052a4e1 if (cmd != 0) { +0052a4ea MoveToManager::AddTurnToHeadingNode(this, iHeading); +0052a4f1 MoveToManager::AddMoveToPositionNode(this); +0052a4e1 } +0052a4fd if ((this->movement_params.bitfield & 0x40) != 0) { // use_final_heading +0052a503 final = iHeading + this->movement_params.desired_heading; +0052a513 if (final >= 360f) final -= 360f; // [@307653-307656] +0052a52f MoveToManager::AddTurnToHeadingNode(this, final); +0052a4fd } +0052a536 this->initialized = 1; +0052a540 MoveToManager::BeginNextNode(this); +``` + +Same node plan as MoveToPosition (§3c) but heading aims at the INTERPOLATED position +and the final heading is RELATIVE (`heading-to-target + desired_heading`) instead of +absolute. + +### 6g. `MoveToManager::TurnToObject_Internal` — `0052a550` @ 307667-307702 + +```c +0052a55b if (physics_obj == 0) { CancelMoveTo(this, 8); return; } +0052a571 this->current_target_position = *arg2; // target_position +0052a58d soughtHeading = Frame::get_heading(&this->sought_position.frame); +0052a59b targetHeading = Position::heading(&physics_obj->m_position, ¤t_target_position); +0052a5aa final = fmod(targetHeading + soughtHeading, 360.0); // j__CIfmod @307682; the BN + // float plumbing @307680-307684 + // is garbled — structure per + // ACE TurnToObject_Internal + // (matches call-for-call) +0052a5ba Frame::set_heading(&this->sought_position.frame, final); +0052a5c1 node = new(0x10); node->type = 9; node->heading = final; // inlined AddTurnToHeadingNode +0052a607 DLListBase::InsertAfter(&pending_actions, node, tail_); +0052a60e this->initialized = 1; +0052a618 MoveToManager::BeginNextNode(this); +``` + +With §3d's quirk, `soughtHeading` is 0 for a fresh TurnToObject (sought was reset by +`InitializeLocalVariables`; `desired_heading` went to current_target's frame and was +overwritten here) → the final heading is simply "face the object". ACE matches +verbatim (MoveToManager.cs:246 + 271-276). + +--- + +## 7. Motion issuing + cleanup family + +### 7a. `MoveToManager::_DoMotion` — `00529010` @ 306351-306364 + +```c +0052901b if (physics_obj == 0) return 8; // NoPhysicsObject +0052902d if (CPhysicsObj::get_minterp(physics_obj) == 0) return 0xb; // NoMotionInterpreter +00529057 CMotionInterp::adjust_motion(get_minterp(physics_obj), &arg2, &arg3->speed, + arg3->hold_key_to_apply); +00529076 return CMotionInterp::DoInterpretedMotion(get_minterp(physics_obj), arg2, arg3); +``` + +### 7b. `MoveToManager::_StopMotion` — `00529080` @ 306368-306381 + +Identical shape; tail is `CMotionInterp::StopInterpretedMotion(minterp, arg2, arg3)`. + +**This is the entire CMotionInterp seam**: MoveToManager only ever issues +`adjust_motion` → `DoInterpretedMotion` / `StopInterpretedMotion`. `adjust_motion` +(already ported, R3) applies the hold key: WalkForward + HoldKey_Run → +RunForward + speed scale. It never calls `DoMotion`, `set_hold_run`, or raw-state +APIs directly. + +### 7c. `MoveToManager::CancelMoveTo` — `00529930` @ 306886-306940 + +```c +0052993a if (this->movement_type != Invalid) { +00529946 while (pending_actions.head_ != 0) { /* inlined unlink + operator delete, + @306891-306929 */ } +005299b5 MoveToManager::CleanUp(this); +005299c2 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); +0052993a } +``` + +Signature is `(this, uint32_t arg2)` — **arg2 (the WeenieError) is NEVER READ in the +body**. Every call site stores a code into a stack slot right before the call +(`8`, `0x36`, `0x37`, `0x38`, `0x3d`) which BN disconnects into dead stores + +uninit-edx (A7 artifact family). In this client build the code is dropped (the +weenie-callback plumbing of the original source is compiled out). Port: keep the +`WeenieError` parameter for parity/logging; the retail-visible behavior is +error-independent. Codes seen (ACE names): `8` NoPhysicsObject, `0x36` +ActionCancelled, `0x37` ObjectGone, `0x38` NoObject, `0x3d` YouChargedTooFar. + +### 7d. `MoveToManager::CleanUp` — `005295c0` @ 306710-306736 + +```c +005295ca MovementParameters localParams; // default ctor +005295d9 localParams.hold_key_to_apply = this->movement_params.hold_key_to_apply; +005295eb localParams.bitfield &= 0xffff7fff; // clear cancel_moveto [dead-store garble] +005295ef if (this->physics_obj != 0) { +005295f9 if (current_command) _StopMotion(this, current_command, &localParams); +00529610 if (aux_command) _StopMotion(this, aux_command, &localParams); +0052962c if (this->top_level_object_id != 0 && this->movement_type != Invalid) +00529634 CPhysicsObj::clear_target(this->physics_obj); // → TargetManager::ClearTarget +005295ef } +0052963b MoveToManager::InitializeLocalVariables(this); +``` + +Does NOT drain `pending_actions` (CancelMoveTo/Destroy do that first). + +### 7e. `MoveToManager::CleanUpAndCallWeenie` — `00529650` @ 306740-306752 + +```c +00529653 MoveToManager::CleanUp(this); +00529661 if (physics_obj == 0) return; +0052966b return CPhysicsObj::StopCompletely(physics_obj, edx); // tailcall +``` + +Despite the name, **no weenie call survives in this build** (same compiled-out +callback as §7c; the `arg2 = 0` @306749 is argument-setup residue). Body ≡ +CleanUp + StopCompletely. + +--- + +## 8. `fail_progress_count` — write-only (negative result with teeth) + +Exhaustive grep (`fail_progress_count`, 6 hits total @306503/307297/307303/307472/ +307506/307516): initialized to 0, reset to 0 at three progress sites, incremented at +two stall sites (§6b/§6c) — **never compared, never read**. The stall counter is +vestigial in the 2013 client; there is NO give-up-after-N-ticks path (the only +abort conditions are fail_distance §6b and target-status §6d). Port it as a field +(cheap parity + diagnostics) but wire no behavior to it. + +--- + +## 9. CPhysicsObj-side entry points (MovementSystem-facing) + +### 9a. `CPhysicsObj::MoveToObject` — `00512860` @ 280598-280655 + +```c +0051286c if (movement_manager == 0) { MovementManager::Create + EnterDefaultState; + set update_time; transient_state |= 0x80; } // lazy-make +005128c9 if (CPhysicsObj::obj_maint != 0) { +005128d2 target = CObjectMaint::GetObjectA(obj_maint, arg2); +005128db if (target != 0) { +005128e9 height = target->part_array ? CPartArray::GetHeight(part_array) : 0; +00512903 radius = target->part_array ? CPartArray::GetRadius(part_array_1) : 0; +00512913 top = target->parent ? target->parent : target; // TOP-LEVEL resolve +00512932 CPhysicsObj::MoveToObject_Internal(this, arg2, top->id, radius, height, arg3); + } } +``` + +`CPhysicsObj::MoveToObject_Internal` `005102e0` @ 278254-278296: lazy-make again, +then builds a stack `MovementStruct { type=6 (@278293), object_id=arg2 (@278290), +top_level_id=arg3 (@278288), radius=arg4 (@278289), height=arg5 (@278294), +params=arg6 (@278291) }` → `MovementManager::PerformMovement(movement_manager, &ms)`. + +### 9b. `CPhysicsObj::TurnToObject` — `00512940` @ 280659-280679 + +Same GetObjectA + parent-resolve shape (NO radius/height fetch) → +`CPhysicsObj::TurnToObject_Internal(this, arg2, top->id, arg3)`; `_Internal` +`005103f0` @ 278300-278340 builds `MovementStruct { type=8, object_id, top_level_id, +params }` → PerformMovement. + +### 9c. `CPhysicsObj::TurnToHeading` — `00512980` @ 280683-280721 + +Lazy-make; `MovementStruct { type=9 (@280718), params=arg2 (@280719) }` → PerformMovement. + +### 9d. `CPhysicsObj::StopCompletely` — `00510180` @ 278165-278185 + +`if (movement_manager)` build `MovementStruct { type=5 }` → PerformMovement. The +`arg2` in its `__fastcall(this, int32_t arg2)` signature is unused (BN phantom-edx +at every call site). + +### 9e. `CPhysicsObj::interrupt_current_movement` — `005101f0` @ 278189-278200 — the cancel entry + +```c +005101f8 if (movement_manager != 0) { +005101fa int32_t var_4_1 = 0x36; // ActionCancelled +005101fc MovementManager::CancelMoveTo(movement_manager, edx); // [uninit-edx artifact] +005101f8 } +``` + +**There is no `CPhysicsObj::cancel_moveto` symbol** (grepped, §10) — this is the +public cancel path (called from unpack_movement heads, StopCompletely-family, etc.). + +### 9f. Target plumbing (TargetManager seam — call shapes) + +- `CPhysicsObj::set_target` `0050ed30` @ 276571-276589: lazy-`new TargetManager(0x18)`, + `TargetManager::SetTarget(tm, context_id, object_id, radius, quantum)`. MoveToManager + always passes `(0, top_level_object_id, 0.5f, 0.0)`. +- `CPhysicsObj::clear_target` `0050ed90` @ 276593-276603: `TargetManager::ClearTarget`. +- `CPhysicsObj::get_target_quantum` `0050edc0` @ 276620-276634: returns + `target_manager->target_info` quantum — the BN text reads `last_update_time` and + returns a pointer; **field-attribution garble** (x87 double-return through a + TargetInfo field; ACE: returns `TargetInfo.Quantum`, default 0). +- `CPhysicsObj::set_target_quantum` `0050eda0` @ 276606-276616: + `TargetManager::SetTargetQuantum(tm, quantum)`. +- `CPhysicsObj::receive_target_update` `0050ede0` @ 276638-276648: + `TargetManager::ReceiveUpdate(tm, TargetInfo*)` — the inbound side that eventually + fans back out through `CPhysicsObj::HandleUpdateTarget`. +- `CPhysicsObj::HandleUpdateTarget` `00512bc0` @ 280794-280813: + +```c +00512bc9 if (arg2.context_id == 0) { // context 0 = the movement context +00512bd3 if (movement_manager) MovementManager::HandleUpdateTarget(mm, copy(TargetInfo)); +00512bfd if (position_manager) PositionManager::HandleUpdateTarget(pm, copy(TargetInfo)); +00512bc9 } +``` + +(Sticky/interp side of PositionManager::HandleUpdateTarget is R5.) + +### 9g. Sticky call shapes referenced from this extraction (bodies = R5) + +- `PositionManager::StickTo(pm, object_id, radius, height)` — from §4b (arrival with + sticky bit) and from `CPhysicsObj::stick_to_object` `005127e0` @ 280559-280595 + (parent-resolved id + CPartArray radius/height, unpack case-0 path). +- `PositionManager::UnStick(pm)` — via `CPhysicsObj::unstick_from_object` `0050eae0` + @ 276403-276413 (called at the head of every `MoveToManager::PerformMovement` and + `unpack_movement`). +- `CPhysicsObj::IsInterpolating` `0050eb50` @ 276464-276474 → + `PositionManager::IsInterpolating` (consumed by the fail-progress stall tests). + +--- + +## 10. Negative results (grepped-not-found) + +- **`MoveToManager::add_listener` / `remove_listener` / `AddListener` / `RemoveListener` + — DO NOT EXIST** (grep over the full raw for both managers: zero hits). No + listener/observer machinery on MoveToManager or MovementManager in this build. +- **`CPhysicsObj::cancel_moveto` — does not exist**; the cancel entry is + `interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` — does not + exist** either; position moves enter only via `unpack_movement` case 7 or a direct + `MoveToManager::MoveToPosition` call. +- **`MoveToManager::LeaveGround` / `ReportExhaustion` — no bodies**; COMDAT-folded + no-ops (§2e). `CMotionInterp::HandleEnterWorld` likewise. +- **`fail_progress_count` has no consumer** (§8) — no give-up threshold exists. +- **`heading_diff`'s third arg (turn command) is unused** in the body (§5g). +- **R3 open item CLOSED:** `HandleUpdateTarget` does live on MoveToManager + (`0x0052a7d0`), reached via `CPhysicsObj::HandleUpdateTarget` → + `MovementManager::HandleUpdateTarget` → here, fed by + `TargetManager::ReceiveUpdate`. +- Older Ghidra chunk (`docs/research/decompiled/chunk_00520000.c`) is from a + DIFFERENT build for this region — function boundaries don't align + (`FUN_005297c0` there ≠ `MoveToManager::TurnToObject` 0x005297d0 here); unusable + for adjudication. Ghidra MCP was down this session (both ports probed) — the one + soft item worth a live decompile later is §5e's 180/0 constants. + +## 11. BN artifact ledger (this extraction) + +| Site | Artifact class | Adjudication | +|---|---|---| +| `get_command` towards branch @307962, `towards_and_away` @307932, HandleMoveToPosition arrival @307329 + turn pick @307277 | flag-test `if(p)` sense inversion | explicit `& 0x41`/`& 1` twins in the same bodies + ACE (matches retail line-for-line in this class) | +| `get_desired_heading` @308027 | x87 setcc garbled return | ACE shape (180/0 offsets); verify via Ghidra when up | +| `heading_diff` return @306346, `GetCurrentDistance` void-return @306448/306459, HandleMoveToPosition Phase-3 @307378-307436, `TurnToObject_Internal` fmod plumbing @307682 | x87 float plumbing lost | structure per ACE, constants verbatim from the raw | +| local MovementParameters writes rendered as dead `var_28`/`var_8` stores (§4c/§4d/§6b/§7d) | hoisted-register / lost store destination | field offsets (+4 bitfield, +0x24 hold_key, +0x14 speed) + ACE | +| `CancelMoveTo(this, edx)` at 7 sites; `StopCompletely(obj, edx)` everywhere | uninit-edx arg relay (A7 family) | stack store adjacent to each call is the real arg; both callees ignore it | +| `MovementManager::LeaveGround`/`ReportExhaustion`/`HandleEnterWorld` tails → `IDClass::~IDClass` | COMDAT-fold symbol collision | trivial-body fold; treat as no-ops | +| `CBaseFilter::GetPinVersion(minterp)` @300597 | symbol-collision mislabel | trivial getter of the interp's current style | +| `CPhysicsObj::get_target_quantum` reading `last_update_time` @276630 | field-attribution on double return | ACE: returns TargetInfo.Quantum | + +## 12. Constants inventory + +| Constant | Meaning | Sites | +|---|---|---| +| `0.000199999995f` | universal heading/distance epsilon (same literal as R3's A5/A6) | §3c/§4c/§4d/§5d/§5g/§6b/§6f | +| `20f` / `340f` (=360-20) | aux-turn deadband while move-walking | HandleMoveToPosition @307251-307257 | +| `180f` | turn-direction pick + wrap tests | @306285/307069/307274/307496 | +| `360f` | heading normalization | throughout | +| `1.0` (double) | min progress-check window (s); quantum-retune delta (s) | CheckProgressMade @306389; @307423 | +| `0.25f` | min progress rate (units/s), incremental AND overall | CheckProgressMade @306402/306419 | +| `0.10000000000000001` (double) | min speed for quantum retune | @307400 | +| `0.5f` / `0.0` | set_target radius / initial quantum | @306794/306858 | +| `3.40282347e+38f` | FLT_MAX distance reset | @306496/306499/307858/307861 | +| `0.6f` / `15f` / FLT_MAX / `1f` | params defaults: distance_to_object / walk_run_threshhold / fail_distance / speed | ctor @300513-300519 | +| `0x1EE0F` | params default bitfield (A4 pin; can_charge CLEAR) | @300531 | +| `0x45000005/6`, `0x44000007`, `0x6500000d/e` | WalkForward/WalkBackwards, RunForward, TurnRight/TurnLeft | §5c/§5d/§5e/§4d/§6b | +| `transient_state & 1` | CONTACT gate for UseTime | @307781 | +| `transient_state \|= 0x80` | ACTIVE flag on lazy manager creation | §9a-9c | +| `8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47` | WeenieError codes (NoPhysicsObject, NoMotionInterpreter, ActionCancelled, ObjectGone, NoObject, YouChargedTooFar, InvalidMovementType) | §7a/§7c/§2b | +| `0x1c` / `0xc` / `0x28` | UnPackNet sizes (move/turn) / full Pack size | §2g | +| `0x160` / `0x10` | sizeof MoveToManager / MovementNode | §1b/§4a | + +## 13. Summary table (function → address → raw lines → completeness) + +| Function | Address | Raw lines | Completeness | +|---|---|---|---| +| MoveToManager::MoveToManager (ctor) | 005293b0 | 306554-306593 | full | +| MoveToManager::Create | 00529470 | 306597-306614 | full | +| MoveToManager::InitializeLocalVariables | 00529250 | 306490-306534 | full | +| MoveToManager::Destroy | 005294b0 | 306618-306663 | full | +| MoveToManager::~MoveToManager | 005299d0 | 306945-306953 | full | +| MoveToManager::SetWeenieObject / SetPhysicsObject | 00529230 / 00529240 | 306474-306486 | full | +| MoveToManager::is_moving_to | 00529220 | 306464-306470 | full | +| MoveToManager::PerformMovement | 0052a900 | 307871-307904 | full | +| MoveToManager::MoveToObject | 00529680 | 306756-306817 | full | +| MoveToManager::MoveToPosition | 0052a240 | 307521-307593 | full | +| MoveToManager::TurnToObject | 005297d0 | 306820-306882 | full | +| MoveToManager::TurnToHeading | 0052a630 | 307706-307772 | full | +| MoveToManager::MoveToObject_Internal | 0052a400 | 307597-307663 | full | +| MoveToManager::TurnToObject_Internal | 0052a550 | 307667-307702 | full (fmod plumbing via ACE) | +| MoveToManager::HandleUpdateTarget | 0052a7d0 | 307802-307867 | full | +| MoveToManager::UseTime | 0052a780 | 307776-307798 | full | +| MoveToManager::HandleMoveToPosition | 00529d80 | 307187-307438 | full (Phase-3 x87 via ACE) | +| MoveToManager::HandleTurnToHeading | 0052a0c0 | 307442-307517 | full | +| MoveToManager::BeginNextNode | 00529cb0 | 307123-307171 | full | +| MoveToManager::BeginMoveForward | 00529a00 | 306957-307042 | full | +| MoveToManager::BeginTurnToHeading | 00529b90 | 307046-307120 | full | +| MoveToManager::HitGround | 00529d70 | 307175-307183 | full | +| MoveToManager::AddTurnToHeadingNode / AddMoveToPositionNode | 00529530 / 00529580 | 306667-306706 | full | +| MoveToManager::RemovePendingActionsHead | 00529380 | 306538-306550 | full | +| MoveToManager::CleanUp | 005295c0 | 306710-306736 | full | +| MoveToManager::CleanUpAndCallWeenie | 00529650 | 306740-306752 | full | +| MoveToManager::CancelMoveTo | 00529930 | 306886-306940 | full | +| MoveToManager::_DoMotion / _StopMotion | 00529010 / 00529080 | 306351-306381 | full | +| MoveToManager::CheckProgressMade | 005290f0 | 306385-306431 | full | +| MoveToManager::GetCurrentDistance | 005291b0 | 306435-306460 | full (x87 args via signature) | +| MovementParameters::get_command | 0052aa00 | 307946-308012 | full | +| MovementParameters::towards_and_away | 0052a9a0 | 307917-307942 | full | +| MovementParameters::get_desired_heading | 0052aad0 | 308016-308033 | shape (constants via ACE; garbled) | +| MovementParameters::Pack / UnPack / UnPackNet | 0052ab20 / 0052abc0 / 0052ac50 | 308037-308205 | full | +| heading_greater / heading_diff | 00528f60 / 00528fb0 | 306281-306347 | full | +| MovementManager::MakeMoveToManager | 00524000 | 300124-300129 | full | +| MovementManager::PerformMovement | 005240d0 | 300194-300255 | full | +| MovementManager::CancelMoveTo | 005241b0 | 300277-300288 | full | +| MovementManager::UseTime / HitGround / IsMovingTo / HandleUpdateTarget | 005242f0 / 00524300 / 00524260 / 00524790 | 300352-300440, 300723-300730 | full | +| MovementManager::LeaveGround / ReportExhaustion / HandleEnterWorld | 00524320 / 00524360 / 00524340 | 300444-300506 | full (folded no-ops) | +| MovementManager::Destroy | 005243f0 | 300538-300559 | full | +| MovementManager::unpack_movement | 00524440 | 300563-300719 | full | +| CPhysicsObj::MoveToObject (+_Internal) | 00512860 / 005102e0 | 280598-280655, 278254-278296 | full | +| CPhysicsObj::TurnToObject (+_Internal) | 00512940 / 005103f0 | 280659-280679, 278300-278340 | full | +| CPhysicsObj::TurnToHeading | 00512980 | 280683-280721 | full | +| CPhysicsObj::StopCompletely | 00510180 | 278165-278185 | full | +| CPhysicsObj::interrupt_current_movement | 005101f0 | 278189-278200 | full | +| CPhysicsObj::MakeMovementManager / MakePositionManager | 00510270 / 00510210 | 278204-278250 | full | +| CPhysicsObj::set_target / clear_target / set_target_quantum / get_target_quantum / receive_target_update / HandleUpdateTarget | 0050ed30-0050ede0, 00512bc0 | 276571-276648, 280794-280813 | full (quantum getter garbled, noted) | +| CPhysicsObj::stick_to_object / unstick_from_object / IsInterpolating | 005127e0 / 0050eae0 / 0050eb50 | 280559-280595, 276403-276413, 276464-276474 | call shapes (R5 owns internals) | diff --git a/docs/research/2026-07-03-r4-moveto/r4-port-plan.md b/docs/research/2026-07-03-r4-moveto/r4-port-plan.md new file mode 100644 index 00000000..37a4b572 --- /dev/null +++ b/docs/research/2026-07-03-r4-moveto/r4-port-plan.md @@ -0,0 +1,452 @@ +# 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 `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); + - `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 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 +`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):** +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.