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

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

1232 lines
70 KiB
Markdown

# 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<MoveToManager::MovementNode> 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, &current_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, &current_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, &current_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, &current_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) |