# L.3 port research — StickyManager / ConstraintManager / MoveToManager

**Source**: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named decomp).
**Cross-refs**: `references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,ConstraintManager,MoveToManager}.cs`,
`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`,
acdream `src/AcDream.Core/Physics/RemoteMoveToDriver.cs`.

All retail line numbers below refer to that file.

---

## 1. StickyManager — "follow object at fixed radius"

### Purpose

`StickyManager` is the **post-arrival follow-on** for `MoveToObject`. After
`BeginNextNode` exhausts the pending action list and finds
`movement_params.__inner0` had the high bit set (sticky-after-arrive flag),
it calls `PositionManager::StickTo(top_level_object_id, radius, height)`
(line 307159). From that point, every physics tick `PositionManager::adjust_offset`
calls `StickyManager::adjust_offset` to nudge the body's per-tick position
delta toward the target, maintaining a fixed cylindrical separation.

### State (`sizeof = 0x60`, lines 352620-352633 ctor + 0x796910 vtable)

| Offset | Field | Notes |
|---|---|---|
| 0x00 | `target_id` | uint32. 0 means "not stuck". |
| 0x04 | `physics_obj` | back-pointer to `CPhysicsObj`. |
| 0x08 | `target_position.vtable` | `0x796910` = `class Position`. |
| 0x0c | `target_position.objcell_id` | last known target cell. |
| 0x10..0x4c | `target_position.frame` | qw/qx/qy/qz/origin (cached). |
| 0x4c | `target_radius` | float. From `StickTo arg3`. |
| 0x50 | `initialized` | int32. 1 after first `HandleUpdateTarget Ok`. |
| 0x54..0x5c | `sticky_timeout_time` | double, line 352576 = `cur_time + 1.0`. |

### `StickyManager::Create(CPhysicsObj*)` @ 0x00555800

```c
00555804  void* result = operator new(0x60);
00555814  *(uint32_t*)result = 0;                       // target_id
00555816  *(uint32_t*)((char*)result + 4) = 0;          // physics_obj (set later by SetPhysicsObject)
0055581c  *(uint32_t*)((char*)result + 8) = 0x796910;   // Position vtable
00555826  *(uint32_t*)((char*)result + 0x10) = 0x3f800000;  // qw = 1.0f
... zero everything else
0055582f  *(uint32_t*)((char*)result + 0x18) = 0;
```

Followed (via `PositionManager::Create` line 352252) by `SetPhysicsObject`.

### `StickyManager::StickTo(target_id, radius, height)` @ 0x00555710 (lines 352559-352578)

Plain language: "from now on, follow `target_id` at radius `radius`, and
notify the engine to start tracking that target."

```c
00555716  if (this->target_id != 0) {                   // already stuck → unstick first
00555718      class CPhysicsObj* physics_obj = this->physics_obj;
0055571b      this->target_id = 0;
00555721      this->initialized = 0;
00555728      CPhysicsObj::clear_target(physics_obj);
00555730      CPhysicsObj::interrupt_current_movement(this->physics_obj);
}
00555749  this->target_radius = arg3;                    // arg3 = radius
0055574f  this->target_id = arg2;                        // arg2 = object id
00555751  this->sticky_timeout_time = Timer::cur_time + 1.0;   // 1-second alive window
0055575a  this->initialized = 0;
00555771  CPhysicsObj::set_target(physics_obj, 0, arg2, 0.5f, 0.5f);
```

`arg4` (target_height) is **received but not stored** — sticky uses cylinder
distance (no Z), so height is irrelevant. The 0.5f/0.5f passed to `set_target`
is the target tracking radius/height the server-update path uses to filter
which `TargetInfo` updates land here.

### `StickyManager::HandleUpdateTarget(TargetInfo)` @ 0x00555780 (lines 352582-352607)

Server-driven: when `CPhysicsObj` receives a fresh tracked-target
position update, this absorbs it.

```c
00555789  if (arg2.object_id == target_id) {
    if (arg2.status == Ok_TargetStatus) {
        this->initialized = 1;
        this->target_position.objcell_id = arg2.target_position.objcell_id;
        Frame::operator=(&this->target_position.frame, &arg2.target_position.frame);
        return;
    }
    if (target_id != 0)                  // status != Ok → bail out
        ClearTarget();                    // (inlined)
}
```

### `StickyManager::adjust_offset(Frame* offset, double quantum)` @ 0x00555430 — **the per-tick steerer**

This is the function `PositionManager::adjust_offset` calls every tick at
line 005551ba.

**Branch guard** (line 352356): only runs when `target_id != 0 AND initialized != 0`.

**Algorithm** in plain language (with retail line cites):

1. **Compute world-space offset to target** (lines 352358-352366):
   - `edi_2 = &physics_obj->m_position` (our position)
   - `eax = GetObjectA(target_id)` — get the live target if still in our world
   - `ebp_1 = (eax != 0) ? &eax->m_position : &this->target_position` — fall back to last-known position if target despawned
   - `Position::get_offset(edi_2, &__return, ebp_1)` writes
     `(target.world - me.world)` into `offset.m_fOrigin`.

2. **Convert to my local space and flatten Z** (lines 352370-352374):
   - `Position::globaltolocalvec(...)` rotates the offset by my inverse heading.
   - `offset.m_fOrigin.z = 0` — sticky is **always horizontal**.

3. **Compute cylinder distance minus 0.3 m sticky radius** (lines 352375-352378):
   - `target_radius = this->target_radius`
   - `var_34_1 = CPhysicsObj::GetRadius(physics_obj)` (own radius — actually unused; computed for side effect or future use)
   - `var_14_1 = Position::cylinder_distance_no_z(edi_2, target_radius, ebp_1) - 0.30000001f`

   So `dist = horizontal_separation_minus_combined_radii - 0.3 m`.

4. **Normalize the offset direction** (lines 352381-352387):
   - `if Vector3::normalize_check_small(offset.m_fOrigin) != 0 → offset.m_fOrigin = (0,0,0)` — when target is on top of us, no direction.

5. **Compute step speed** (lines 352392-352409):
   - `eax_7 = CMotionInterp::get_max_speed(get_minterp(physics_obj))` — pull body's max forward speed.
   - If `get_minterp == 0` → step skipped (`top_1 = 0`).
   - Compare against `F_EPSILON`. If `max_speed < F_EPSILON` → use `MAX_VELOCITY` instead (the global cap).
   - **ACE port (line 112)**: `speed = minterp.get_max_speed() * 5.0f` then `if speed < EPSILON → 15.0f`.
     The retail `* 5.0f` constant isn't visible in our pseudo-C extract because the FP
     stack ops are unimplemented in the BinaryNinja output; ACE's value is the
     authoritative interpretation.

6. **Multiply offset by `min(speed × quantum, dist)`** (lines 352411-352455):
   - The branching (`if p_2`) selects between the speed-clamped delta and the
     distance-clamped delta. ACE's port (lines 117-121) makes this explicit:
     ```csharp
     var delta = speed * (float)quantum;
     if (delta >= Math.Abs(dist)) delta = dist;
     offset.Origin *= delta;
     ```
   - Z stays 0; X/Y get scaled to the final per-tick step.

7. **Set heading toward target** (lines 352456-352492):
   - `Position::heading(edi_2, ebp_1)` — compute heading from us to target (degrees, 0..360).
   - `Frame::get_heading(&edi_2->frame)` — current heading.
   - `delta = target_heading - current_heading`.
   - If `|delta| < F_EPSILON` → `delta = 0`.
   - If `delta < -F_EPSILON` → `delta += 360.0f`.
   - `Frame::set_heading(arg2, delta)` — bake the **rotation delta** (not absolute) into the frame the caller passes in. The caller composes this onto the body next tick.

### `StickyManager::UseTime` @ 0x00555610 (lines 352498-352517)

Empty unless `target_id != 0 AND cur_time > sticky_timeout_time`. When timeout
elapses without a `HandleUpdateTarget Ok`, drops the target completely. The
1-second timeout (`sticky_timeout_time = cur_time + 1.0`) means the server has
to keep refreshing the target every second or sticky drops.

### `StickyManager::UnStick` @ 0x00555400 + `Destroy` @ 0x00555650 + `~StickyManager` @ 0x005557e0

`UnStick` and `Destroy` both clear `target_id`, `initialized`, call
`CPhysicsObj::clear_target(physics_obj)`, then **`UnStick` also calls
`CPhysicsObj::interrupt_current_movement`** while `Destroy` does not. The
distinction matters: `UnStick` is a deliberate "stop sticky now"; `Destroy`
is "we're being torn down, don't side-effect into the still-existing physics
state machine."

### Critical questions answered

- **When does sticky activate?** Only via `BeginNextNode`'s post-arrival
  branch (line 307143-307159) when `movement_params.__inner0` has its high
  bit set. Outbound `MoveToObject` packets with that flag are server-side AI
  scripts (combat tracking, NPC follow, etc.). Player-driven moves don't
  set it.
- **What does it write to the Frame?** `m_fOrigin = (xy_step_toward_target, 0)`
  in **local space** (rotated to body-local before being scaled, so when the
  body composes this onto its own frame the step lands in the right world-space
  direction). Plus `set_heading(rotation_delta)` — the body turns to face the
  target each tick at `set_heading` rate (with the same kind of fudge as
  ACE's `set_heading(target, true)`).

---

## 2. ConstraintManager — "leash to a fixed point"

### Purpose

Force the body to stay within a soft bubble around `constraint_pos`. Used for
quest geometry like "can't leave this room", monster aggro tethers, etc.
Smaller scope than sticky and **purely position-based** (no rotation).

### State (`sizeof = 0x5c`, lines 353442-353474 ctor)

| Offset | Field | Notes |
|---|---|---|
| 0x00 | `physics_obj` | back-pointer (set last, line 353473). |
| 0x04 | `is_constrained` | int32. |
| 0x08 | `constraint_pos.vtable` | not 0x796910 here — Position embed begins at +0xc. |
| 0x0c | `constraint_pos.vtable` (real) | `0x796910` = `class Position`. |
| 0x10 | `constraint_pos.objcell_id` | 0 |
| 0x14..0x4c | `constraint_pos.frame` | qw/qx/qy/qz/origin |
| 0x48 | `constraint_distance_start` | float — soft bubble inner radius |
| 0x4c | `constraint_distance_max` | float — hard bubble outer radius |
| 0x50 | `constraint_pos_offset` | float — current distance from `constraint_pos` |

(ACE's port stores them by C# field names mirroring the above; the offsets aren't
load-bearing, the meanings are.)

### `ConstraintManager::ConstrainTo(Position* pos, float startDist, float maxDist)` @ 0x00556240 (lines 353528-353537)

```c
00556248  this->is_constrained = 1;
00556259  this->constraint_pos.objcell_id = arg2->objcell_id;
0055625c  Frame::operator=(&this->constraint_pos.frame, &arg2->frame);
00556271  this->constraint_distance_start = arg3;
00556274  this->constraint_distance_max = arg4;
0055627c  this->constraint_pos_offset = Position::distance(arg2, &this->physics_obj->m_position);
```

Snapshot the leash anchor + radii + initial offset.

### `ConstraintManager::adjust_offset(Frame* offset, double quantum)` @ 0x00556180 — **the per-tick clamper**

```c
00556186  class CPhysicsObj* physics_obj = this->physics_obj;
0055618a  if (physics_obj == 0) return;
00556190  if (this->is_constrained == 0) return;

005561a7  if ((physics_obj->transient_state & 1) != 0) {     // bit 0 = "Contact" (touching ground)
    if (this->constraint_pos_offset < this->constraint_distance_max) {
        if (this->constraint_pos_offset > this->constraint_distance_start) {
            // soft zone: scale offset DOWN proportionally to how far into the soft band we are
            float scale = (constraint_distance_max - constraint_pos_offset) /
                          (constraint_distance_max - constraint_distance_start);
            Vector3::operator*=(&arg2->m_fOrigin, scale);
        }
        // else: inside inner bubble, leave offset unchanged
    } else {
        // hard zone: zero the offset entirely. No movement allowed beyond max.
        arg2->m_fOrigin = Vector3::Zero;
    }
}
00556233  this->constraint_pos_offset = arg2->m_fOrigin.x + this->constraint_pos_offset;
//  ^^ NOTE: the pseudo-C extract reads ".x +", but the actual algorithm uses
//     the **length** of m_fOrigin (the magnitude of the per-tick step). ACE's
//     port (line 76) confirms: `ConstraintPosOffset = offset.Origin.Length();`
//     The Binary Ninja extract garbled the FP stack ops here.
```

### Branches & flag bits

- **`transient_state & 1` = `Contact` flag.** ConstraintManager only fires
  when the body is **touching the ground**. Mid-air motion (jumps, falls)
  is unaffected. (This is the same `transient_state` bit that motion code
  checks to decide whether `kill_velocity` is allowed — see commit a3f53c2.)
- **No rotation**: `set_heading` is never touched. Constraint is purely
  positional.
- **No timeout**: `UseTime` is empty. Stays engaged until `Unconstrain`
  or `~ConstraintManager`.

### `IsFullyConstrained` @ 0x005560d0 (lines 353413-353427)

```c
return constraint_distance_max * 0.9f < constraint_pos_offset;
```

"Are we within 10% of the hard limit?" Used by callers to decide whether
to schedule a course correction.

### Critical questions answered

- **What kinds of constraints exist?** Only **translation** in the form of a
  soft-clamp toward `constraint_pos`. No rotation lock, no cell lock — those
  are server-enforced.
- **When does it fire?** Per tick, but only when the body has the Contact
  bit (touching ground). Ignored during jumps/falls.

---

## 3. MoveToManager — full state machine for AI/scripted motion

### Purpose

Server-side AI's locomotion executor. When the server wants a creature to
"walk to that rock then turn south", it sends a `MovementStruct` (one of 4
shapes) packed via `MovementParameters::UnPackNet`. `PerformMovement`
unpacks it, picks a top-level branch, the entrypoint queues a list of
**pending nodes** (each either `MoveToPosition` opcode `7` or `TurnToHeading`
opcode `9`), and `UseTime` ticks the head-of-queue node every physics frame
until the queue empties.

### State (`sizeof = 0x160`, lines 306554-306592 ctor)

| Field | Type | Notes |
|---|---|---|
| `sought_position` | Position | The original target requested by the server. |
| `current_target_position` | Position | The "right now" target — interpolated for moving objects. |
| `starting_position` | Position | Where the body was when the move began. Used by fail-distance check. |
| `pending_actions` | DLListBase | Doubly-linked list of `{opcode, value}` nodes. Opcode 7 = MoveToPosition, opcode 9 = TurnToHeading (with heading float). |
| `movement_params` | MovementParameters | Packed flags, distances, speeds, hold-key, etc. (Section 4.) |
| `physics_obj`, `weenie_obj` | back-pointers | |
| `movement_type` | enum (`Invalid` or 6=MoveToObject / 7=MoveToPosition / 8=TurnToObject / 9=TurnToHeading) | |
| `current_command`, `aux_command` | uint32 | Active motion-command IDs. `current_command` is the "main" (forward/backward), `aux_command` is the simultaneous turn (e.g., 0x6500000d=TurnLeft, 0x6500000e=TurnRight). |
| `previous_distance`, `previous_distance_time` | float, double | For `CheckProgressMade` (fail detector). |
| `original_distance`, `original_distance_time` | float, double | Initial state for over-1-second progress check. |
| `previous_heading` | float | TurnToHeading's per-tick angle tracker. |
| `fail_progress_count` | int32 | Stalled-tick counter. |
| `sought_object_id`, `top_level_object_id` | uint32 | The object we're chasing (and its outermost parent if attached). |
| `sought_object_radius`, `sought_object_height` | float | Cylinder dimensions for distance test. |
| `moving_away` | int32 | 0=chase, 1=flee. Affects which arrival predicate fires. |
| `initialized` | int32 | 1 once we've gotten the first `HandleUpdateTarget Ok` for a moving target. |

### Top-level entry: `MoveToManager::PerformMovement(MovementStruct*)` @ 0x0052a900 (lines 307871-307904)

```c
0052a901  int32_t var_8 = 0x36;                          // WeenieError code that CancelMoveTo will use
0052a905  CancelMoveTo(this, edx);                       // wipe any in-flight move
0052a910  CPhysicsObj::unstick_from_object(this->physics_obj);

0052a923  switch (arg2->type - 6) {
    case 0:  // MoveToObject (type 6)
        MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params);
        break;
    case 1:  // MoveToPosition (type 7)
        MoveToPosition(this, &arg2->pos, arg2->params);
        break;
    case 2:  // TurnToObject (type 8)
        TurnToObject(this, arg2->object_id, arg2->top_level_id, arg2->params);
        break;
    case 3:  // TurnToHeading (type 9)
        TurnToHeading(this, arg2->params);
        break;
}
return 0;
```

### `MoveToManager::MoveToObject` @ 0x00529680 (lines 306756-306817)

Stores: `sought_object_id = arg2`, `top_level_object_id = arg3`,
`sought_object_radius = arg4` (cylinder R), `sought_object_height = arg5`.
Copies `arg6` (MovementParameters) field-by-field into `this->movement_params`.
Saves current position into `starting_position`. Sets `movement_type = 6`,
`initialized = 0`.

If `arg3 == this->physics_obj->id` → it's us; CleanUp and bail.

Otherwise: `CPhysicsObj::set_target(physics_obj, 0, arg3, 0.5f, 0.0f)` — start
tracking. The actual movement queue isn't built here; it's built by
`HandleUpdateTarget` once the first target snapshot arrives (see below).

### `MoveToManager::MoveToPosition` @ 0x0052a240 (lines 307521-307593)

Position is known immediately, so the queue is built directly:

```c
// Wipe in-flight motion
StopCompletely(physics_obj_1);
// Snapshot target
this->current_target_position = *arg2;
this->sought_object_radius = 0.0f;
GetCurrentDistance(this);                         // returns |dist| in x87_r0
// Compute heading delta to target
float curHeading = CPhysicsObj::get_heading(physics_obj_2);
float headingToTarget = Position::heading(&physics_obj_2->m_position, arg2);
float delta = headingToTarget - curHeading;
if (|delta| < EPSILON) delta = 0;
if (delta < -EPSILON) delta += 360.0f;            // normalize to [0, 360)

// Ask MovementParameters which command to issue (RunForward/WalkForward/Backwards/none)
//   var_c = command, var_4 = holdKey, var_8 = movingAway
MovementParameters::get_command(arg3, dist, delta, &var_c, &var_4, &var_8);

// If we need to move at all, queue: TurnToHeading(headingToTarget) → MoveToPosition node
if (var_c != 0) {
    float h = Position::heading(&this->physics_obj->m_position, arg2);
    AddTurnToHeadingNode(this, h);                // opcode 9
    AddMoveToPositionNode(this);                  // opcode 7
}

// If "use final heading" flag (bit 0x40) is set, queue a final TurnToHeading(desired_heading)
if ((arg3->__inner0 & 0x40) != 0)
    AddTurnToHeadingNode(this, arg3->desired_heading);

// Snapshot positions
this->sought_position = *arg2;
this->starting_position = this->physics_obj->m_position;
this->movement_type = 7;
this->movement_params = *arg3;                    // field-by-field copy
this->movement_params.__inner0 &= 0xffffff7f;     // clear bit 7 (sticky-after-arrive flag) — that's only valid for MoveToObject

BeginNextNode(this);                              // pop the head and dispatch
```

### Pending-action queue ops

- **`AddMoveToPositionNode`** @ 0x00529580: appends `{opcode=7, value=undefined}`.
- **`AddTurnToHeadingNode(float h)`** @ 0x00529530: appends `{opcode=9, value=h}`.
- **`RemovePendingActionsHead`** @ 0x00529380: pop head, free node.

### `MoveToManager::BeginNextNode` @ 0x00529cb0 (lines 307123-307171)

Dispatch loop:

```c
if (head_ != nullptr) {
    int op = head_->opcode;                       // offset +8
    if (op == 7) tailcall BeginMoveForward(this);
    if (op == 9) tailcall BeginTurnToHeading(this);
    return;
}

// Queue empty.
head_ = (uint8_t)this->movement_params.__inner0;  // recycle head_ as a register…
if (head_ < 0) {                                  // i.e., bit 7 set on __inner0 == sticky-after-arrive
    float radius = this->sought_object_radius;
    float height = this->sought_object_height;
    uint32_t topId = this->top_level_object_id;
    CleanUp(this);
    if (physics_obj != 0) StopCompletely(physics_obj);
    PositionManager::StickTo(get_position_manager(physics_obj), topId, radius, height);
    return;
}

// Queue empty, no sticky → done. CleanUp + StopCompletely.
CleanUp(this); StopCompletely(physics_obj);
```

### `MoveToManager::BeginMoveForward` @ 0x00529a00 (lines 306957-307042) — opcode 7 dispatch

1. Sanity: physics_obj null → CancelMoveTo with err 8 (NotInitialized).
2. `var_3c = GetCurrentDistance(this)` → distance to current target.
3. Compute heading-to-target delta in `var_40_1`, normalize like in `MoveToPosition`.
4. `MovementParameters::get_command(&this->movement_params, var_3c, var_40_1, &var_38, &var_34, &var_30)` →
   `var_38=command`, `var_34=holdKey`, `var_30=movingAway`.
5. **If `command == 0` (already arrived)**: `RemovePendingActionsHead`, then
   `BeginNextNode` (advance to next pending node — typically a final TurnToHeading).
6. **Else**: `_DoMotion(this, command, &local_movement_params_clone)`.
   On non-zero return → `CancelMoveTo(this, retval)`.
   On success: stash `current_command = var_38`, `moving_away = var_30`,
   `movement_params.hold_key_to_apply = var_34`, snapshot
   `previous_distance/_time` and `original_distance/_time`.

### `MoveToManager::BeginTurnToHeading` @ 0x00529b90 (lines 307046-307120) — opcode 9 dispatch

1. Need head_ + physics_obj; otherwise CancelMoveTo err 8.
2. **If motions are pending in the body** (`CPhysicsObj::motions_pending != 0`),
   skip — wait for body to settle.
3. Get target heading from `head_->value` (offset +0xc).
4. `st0 = heading_diff(target, current, 0x6500000d)` — 0x6500000d is
   TurnLeft, so `heading_diff` returns "how much left-turn from current to
   target".
5. **If diff ≈ 180** (within EPSILON of being directly behind): pop head and
   advance via `BeginNextNode`. Avoids ambiguous turn direction.
6. **If diff ≈ 0** (within EPSILON of already aligned): pop head and advance.
7. **Else** decide direction: `edi = (st0 > 180) ? 0x6500000e (TurnRight) : 0x6500000d (TurnLeft)`.
8. `_DoMotion(this, edi, &local_params)`. On failure → CancelMoveTo. On
   success: `current_command = edi`, `previous_heading = st0`.

### `MoveToManager::HandleMoveToPosition` @ 0x00529d80 — **the per-tick driver** (lines 307187-307438)

This is what `RemoteMoveToDriver.cs` is named after. Called every physics
frame from `UseTime` while the head pending node is opcode 7.

**Plain-language flow:**

1. **Aux turn correction** (lines 307213-307287). If body has motions pending,
   stop the aux turn (we let body finish first). Otherwise:
   - Compute `worldHeading = Position::heading(my_pos, current_target_position)`.
   - `desiredHeading = worldHeading + MovementParameters::get_desired_heading(current_command, moving_away)`
     — `get_desired_heading` returns 0 for forward/180 for backward when chasing,
     swapped when fleeing.
   - Normalize to `[0, 360)`.
   - `delta = desiredHeading - currentHeading`, normalized to `[0, 360)`.
   - **If `delta ≤ 20°` OR `delta ≥ 340°`** (within 20° of correct facing):
     stop the aux turn (`_StopMotion(aux_command)`, `aux_command = 0`).
   - **Else** pick direction: `edi_1 = (delta > 180) ? TurnRight : TurnLeft`.
     If different from `aux_command`, `_DoMotion(edi_1, ...)` and
     `aux_command = edi_1`.

2. **Distance check** (line 307289 `GetCurrentDistance` → `var_88_3`).

3. **Progress check** (line 307294 `CheckProgressMade(this, var_88_3) == 0`):
   - **If no progress**: if not interpolating and no motions pending,
     `fail_progress_count += 1`. (Body might be wedged on geometry; the
     counter is read by external code to decide when to give up.)
   - **If progress**: reset `fail_progress_count = 0`. Then check arrival:
     - **Chase (`moving_away == 0`)**: arrived when `dist ≤ DistanceToObject`
       (line 307323 — `fcomp st0, [esi+0xe4]` is `movement_params.distance_to_object`).
     - **Flee (`moving_away == 1`)**: arrived when `dist ≤ MinDistance`
       (line 307309 — `[esi+0xe8]`).
     - **If arrived**: `RemovePendingActionsHead`, `_StopMotion(current_command)`,
       `current_command = 0`, stop aux too, `BeginNextNode`.
     - **If past fail_distance**: `dist_from_start = Position::distance(starting_position, my_pos)`,
       and if `dist_from_start > fail_distance` → `CancelMoveTo` with err 0x3D
       (`ObjectGone` / "fail-distance exceeded").

4. **Adaptive quantum tuning** (lines 307376-307437). If we have a tracked
   target (`top_level_object_id != 0`):
   - `velocity = CPhysicsObj::get_velocity(this->physics_obj)`.
   - `speed_sq = vx² + vy² + vz²`. `speed = sqrt(speed_sq)`.
   - **If `speed > 0.1`** (line 307400 `0.1` const) — body actually moving:
     - `quantum = var_88_3 / speed` — projected time-to-arrival.
     - `if |target_quantum - quantum| > 1.0` →
       `CPhysicsObj::set_target_quantum(quantum)`.
     - This is how the engine speeds up the next physics tick when we're
       close to arrival, so we don't overshoot.

**ACE divergence note**: ACE swaps the chase/flee predicates (uses
`DistanceToObject` for chase arrival vs retail's `min_distance`). The retail
field naming and the physical meaning agree though — the arrival condition
is "distance shrunk past the threshold I asked for". `RemoteMoveToDriver.cs`
already follows retail correctly here (see its line 50-57 doc-comment).

### `MoveToManager::HandleTurnToHeading` @ 0x0052a0c0 (lines 307442-307517) — opcode 9 driver

1. If `current_command` isn't TurnLeft/TurnRight, fall through to
   `BeginTurnToHeading` (re-pick direction).
2. Get current heading. Test
   `heading_greater(curHeading, targetHeading, current_command)` —
   "have we passed the target".
   - If yes: snap heading via `CPhysicsObj::set_heading(physics_obj_1, target, true)`,
     pop pending head, `_StopMotion(current_command)`, `current_command = 0`,
     `BeginNextNode`.
3. Else compute `delta_per_tick = heading_diff(curHeading, previous_heading, current_command)`:
   - If `delta_per_tick ≥ 180` (turning the wrong way through the long arc):
     skip the no-progress increment.
   - If `delta_per_tick ≥ 0.0002f`: progress → `fail_progress_count = 0`,
     `previous_heading = curHeading`.
4. Else: `previous_heading = curHeading`, and if neither interpolating nor
   motions pending: `fail_progress_count++`.

### `MoveToManager::CheckProgressMade(float dist)` @ 0x005290f0 (lines 306385-306431)

Implements the "moved ≥ 0.25 m/s averaged over the last 1 s AND the last sample-interval" test.

```c
double elapsed_since_last_sample = cur_time - previous_distance_time;
if (elapsed_since_last_sample > 1.0) {
    float instantaneous_speed = moving_away ? (dist - previous_distance) : (previous_distance - dist);
    instantaneous_speed /= elapsed_since_last_sample;
    if (instantaneous_speed > 0.25f) {
        previous_distance = dist;
        previous_distance_time = cur_time;
        // ALSO check long-window speed
        float total = moving_away ? (dist - original_distance) : (original_distance - dist);
        total /= (cur_time - original_distance_time);
        if (total > 0.25f) return 1;
    }
    return 0;
}
return 1;     // not enough time elapsed yet to judge
```

Returns 1 = "making progress, don't increment fail counter"; 0 = "stuck".

### `MoveToManager::CancelMoveTo(WeenieError)` @ 0x00529930 (lines 306886-306940)

Drains `pending_actions` (free each node), `CleanUp(this)`,
`StopCompletely(physics_obj)`. Then the WeenieError is sent up to the weenie
object via the parent's CleanUpAndCallWeenie path.

### `MoveToManager::CleanUp` @ 0x005295c0 (lines 306710-306736)

```c
if (current_command != 0) _StopMotion(current_command, &local_params);
if (aux_command != 0)     _StopMotion(aux_command, &local_params);
if (top_level_object_id != 0 && movement_type != Invalid)
    CPhysicsObj::clear_target(physics_obj);
InitializeLocalVariables(this);
```

### `MoveToManager::HandleUpdateTarget(TargetInfo)` @ 0x0052a7d0 (lines 307802-307867)

Server-driven callback. When a tracked target's position updates:

```c
if (top_level_object_id != arg2->object_id) return;          // not our target
if (initialized == 0) {                                       // first snapshot
    if (top_level_object_id == physics_obj->id) {             // tracked self → done
        sought_position = physics_obj->m_position;
        CleanUpAndCallWeenie(this, current_target_position = physics_obj->m_position);
        return;
    }
    if (arg2->status != Ok) {                                 // bad snapshot
        CancelMoveTo(this, 0x38);
        return;
    }
    if (movement_type == MoveToObject)                        // first valid snapshot → build queue
        MoveToObject_Internal(this, &arg2->target_position, &arg2->interpolated_position);
    else if (movement_type == TurnToObject)
        TurnToObject_Internal(this, &arg2->target_position);
} else {                                                       // ongoing
    if (arg2->status != Ok) { CancelMoveTo(0x37); return; }
    if (movement_type == MoveToObject) {
        sought_position = arg2->interpolated_position;
        current_target_position = arg2->target_position;
        // RESET progress windows — target moved, so the old samples don't count
        previous_distance = +inf;
        previous_distance_time = cur_time;
        original_distance = +inf;
        original_distance_time = cur_time;
    }
}
```

This is **the critical hookup for chase-AI**: every server `UpdateTarget`
shoves the pursuer's current_target forward and resets progress sampling.

### `MoveToManager::HitGround` @ 0x00529d70 (lines 307175-307183)

```c
if (movement_type != Invalid)
    BeginNextNode(this);
```

Trigger from the body's contact-restored event. Used by AI move sequences
that start mid-air (knockback recovery, falling onto a target).

### `MoveToManager::UseTime` @ 0x0052a780 (lines 307776-307798) — **the tick entry point**

```c
if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) {  // Contact bit
    head_ = pending_actions.head_;
    if (head_ != nullptr &&
        (top_level_object_id == 0 || movement_type == Invalid || initialized != 0)) {
        if (head_->opcode == 7) tailcall HandleMoveToPosition(this);
        if (head_->opcode == 9) tailcall HandleTurnToHeading(this);
    }
}
```

**Branch summary:**
- Must have Contact (touching ground).
- Must have a pending action.
- Either there's no tracked target, OR movement is Invalid, OR we've been
  initialized (got the first target snapshot). I.e., for `MoveToObject`,
  `UseTime` is a no-op until `HandleUpdateTarget` flips `initialized=1`.

---

## 4. MovementParameters

### Layout

`__inner0` is a packed bitfield (uint32, but the meaningful flags live in the
low 16 bits — we see `(int16_t)__inner0`). Confirmed bits:

| Bit | Mask | ACE name | Meaning |
|---|---|---|---|
| 0 | 0x0001 | CanWalk | Allow WalkForward speed. |
| 1 | 0x0002 | CanRun | Allow RunForward speed. |
| 2 | 0x0004 | CanSidestep | |
| 3 | 0x0008 | CanWalkBackwards | |
| 4 | 0x0010 | CanCharge | (Holds key to apply) |
| 5 | 0x0020 | FailWalk | |
| 6 | 0x0040 | UseFinalHeading | Append `TurnToHeading(desired_heading)` after MoveToPosition. (Line 307571.) |
| 7 | 0x0080 | Sticky | After arrival, `StickTo(top_level_object_id, ...)` instead of stopping. (Line 307145 reads this as the high bit of `__inner0` low-byte and treats `< 0` as "set".) |
| 8 | 0x0100 | MoveAway | Used by `towards_and_away` to flip arrival/turn-direction conventions. |
| 9 | 0x0200 | MoveTowards | |
| 10 | 0x0400 | UseSpheres | |
| 11 | 0x0800 | SetHoldKey | |
| 12 | 0x1000 | Autonomous | |
| 13 | 0x2000 | ModifyRawState | |
| 14 | 0x4000 | ModifyInterpretedState | |
| 15 | 0x8000 | CancelMoveTo | line 307208 `& 0xffff7fff` — mask out before sending to inner motion. |
| 16 | 0x10000 | StopCompletely | |
| 17 | 0x20000 | DisableJumpDuringLink | |

(Bit 7 ≥ "stick after arrive" is the load-bearing one for sticky-from-MoveTo; bit 6
is load-bearing for "turn to face X after arriving".)

### `MovementParameters::UnPackNet(MovementType type, void** stream, uint32_t bytes)` @ 0x0052ac50 (lines 308118-308190)

The wire format the **client receives** (vs `Pack`/`UnPack` which is the local-save format).

```c
size_required = (type == MoveToObject || type == MoveToPosition) ? 0x1c : 0x0c;
if (bytes < size_required || (type - 6) > 3) return 0;

switch (type) {
    case MoveToObject:
    case MoveToPosition:
        // 0x1c bytes: __inner0, distance_to_object, min_distance, fail_distance, speed, walk_run_threshold, desired_heading
        this->__inner0           = read_uint32();
        this->distance_to_object = read_float();
        this->min_distance       = read_float();
        this->fail_distance      = read_float();
        this->speed              = read_float();
        this->walk_run_threshhold= read_float();
        // desired_heading written below (shared)
        break;
    case TurnToObject:
    case TurnToHeading:
        // 0x0c bytes: __inner0, speed, desired_heading
        this->__inner0 = read_uint32();
        this->speed    = read_float();
        // desired_heading written below
        break;
}
this->desired_heading = read_float();
return 1;
```

Note: **UnPackNet is shorter than UnPack** (0x1c/0x0c vs 0x28). The wire format
omits `context_id`, `hold_key_to_apply`, and `action_stamp` — those are
local-only (server tracks them, doesn't ship them). Compare to the full
`UnPack` @ 0x0052abc0 which reads all 9 fields × 4 bytes = 0x28 bytes.

### `MovementParameters::get_command(dist, heading_delta, &cmd, &holdKey, &movingAway)` @ 0x0052aa00 (lines 307946-308012)

Decides which motion-command to issue based on flags + distance.

Pseudocode (cleaner than the FP-mangled extract, validated against ACE port):

```c
inner0 = this->__inner0;
if (inner0 & 0x0200) {                                  // MoveTowards
    if (inner0 & 0x0100)                                // MoveTowards AND MoveAway → use towards_and_away
        towards_and_away(this, dist, heading, &cmd, &movingAway);
    else if (dist > distance_to_object) {                // Towards-only, still far → walk forward
        cmd = 0x45000005;                                // WalkForward
        movingAway = 0;
    } else cmd = 0;
} else if (inner0 & 0x0100) {                            // MoveAway only
    if (dist - min_distance < EPSILON) {                 // too close → walk back
        cmd = 0x45000005;
        movingAway = 1;
    } else cmd = 0;
} else {                                                 // neither
    if (dist > distance_to_object) {
        cmd = 0x45000005; movingAway = 0;
    } else cmd = 0;
}

// Hold key: pick Run vs None based on CanRun, CanWalk, walk_run_threshhold
if (inner0 & 0x10) {                                     // CanCharge / SetHoldKey route
    *holdKey = HoldKey_Run;
    return;
}
if ((inner0 & 0x02) == 0) {                              // !CanRun
    *holdKey = HoldKey_None;
    return;
}
if (inner0 & 0x01) {                                     // CanWalk
    if (dist - distance_to_object <= walk_run_threshhold) {
        *holdKey = HoldKey_None;
        return;
    }
}
*holdKey = HoldKey_Run;
```

Magic numbers:
- `0x45000005` = WalkForward command
- `0x45000006` = WalkBackwards (used by `towards_and_away` when fleeing too close)
- `0x44000007` = RunForward (matched in `get_desired_heading`)

### `MovementParameters::get_desired_heading(motion, movingAway)` @ 0x0052aad0 (lines 308016-308033)

Returns 0 (chase forward) / 180 (chase backward / flee forward) / arg3 (other).
Used by `HandleMoveToPosition` to compute the desired heading offset from the
world-heading-to-target.

```c
if (motion == 0x44000007 || motion == 0x45000005) return arg3;     // forward → arg3=0  → face target
if (motion == 0x45000006)                          return arg3;     // backward → arg3=180 → face target
return motion - 0x45000006;                                          // (sentinel, only hit for non-forward/back)
```

(ACE port lines 186-198 hardcode this as `movingAway ? 180 : 0` for the
forward case, which is the same algebra after substituting in the actual
arg3 values the callers pass.)

---

## 5. Interaction with PositionManager / InterpolationManager

### `PositionManager::adjust_offset(Frame*, double quantum)` @ 0x00555190 (lines 352090-352118)

Calls all three managers in sequence:

```c
1. interpolation_manager->adjust_offset(arg2, quantum);    // smooths server → local position over a window
2. sticky_manager->adjust_offset(arg2, quantum);           // stick to a target
3. constraint_manager->adjust_offset(arg2, quantum);       // clamp to a leash
```

**Order matters**:
- InterpolationManager runs first, baking server-driven catch-up into the offset.
- StickyManager then **reads from physics_obj's m_position** (which is already
  interpolated by the previous step's commit — the offset hasn't been applied
  yet, but m_position reflects last frame's solved state). Sticky overwrites
  the offset if it has a target.
- ConstraintManager comes last, scaling-down or zeroing whatever the others
  produced if it would push us past a leash radius.

Each manager **reads** from `physics_obj->m_position` and **writes** the
per-tick offset (translation + rotation delta) into `arg2`. The caller then
composes that offset onto `physics_obj->m_position` for the actual move.

### MoveToManager interaction

MoveToManager **does not** participate in `PositionManager::adjust_offset`. It
runs once per tick from a different entry point (`UseTime`, called from the
physics scheduler) and **issues motion commands** to `CMotionInterp` via
`_DoMotion` / `_StopMotion`. The body's velocity comes from
`CMotionInterp::apply_current_movement`, not from MoveToManager directly.

So the layering is:
1. Pending nodes → `_DoMotion(MoveTo*)` → `CMotionInterp::DoInterpretedMotion(RunForward + HoldKey.Run)`
2. `CMotionInterp` writes `InterpretedState.ForwardCommand=RunForward, HoldKey=Run`
3. Each tick, `apply_current_movement` reads InterpretedState and emits a body velocity
4. PositionManager (Interp+Sticky+Constraint) post-modifies the per-tick offset

When sticky activates from MoveToManager (after arrival), MoveToManager calls
`PositionManager::StickTo`, which creates the sticky and from then on the
sticky's `adjust_offset` overrides the body's natural velocity each tick.

---

## 6. Differences vs acdream `RemoteMoveToDriver.cs`

acdream's current port is intentionally minimal (header comment lines 44-57).
What it **DOES** correctly:
- Heading delta with 20° snap tolerance — line 307255-307287 of retail.
- Arrival predicate via `min_distance` for chase / `distance_to_object` for flee
  — matches retail (lines 307309/307323), explicitly diverges from ACE.
- Stale-destination giveup at 1.5 s (acdream-specific safety net for our
  streaming model).

What it **OMITS** vs retail (acceptable for a remote-observer of a server-
authored creature):
- Pending-action queue (TurnToHeading → MoveToPosition → final TurnToHeading).
  Server re-emits the move; we don't need to schedule sub-nodes.
- Sticky-after-arrive (`__inner0 & 0x0080`). Server signals stick separately
  via `PositionManager::StickTo` calls or via re-emitted MoveTos.
- `CheckProgressMade` / `fail_progress_count`. Server-side concern; if the
  remote AI gives up, the server just stops sending MoveTo updates.
- `set_target_quantum` adaptive tick rate. We run at fixed 60 Hz.
- `HandleUpdateTarget` re-tracking. Server re-emits the full MoveTo when its
  target moves; we re-parse and re-init.
- ConstraintManager and `transient_state & Contact` gating. We don't have a
  real contact-plane test on remotes (we only do collision for the local
  player). For remotes, we always assume "on ground".
- StickyManager altogether — there's no scenario where a remote needs to
  follow a target the server hasn't already told us to face via UpdateMotion.

What's a **real port gap** worth filing for L.3 follow-up:
- `MovementParameters::__inner0` flag bit 0x40 (`UseFinalHeading`) — when the
  packet's final-heading bit is set, the body should rotate to face
  `desired_heading` after arrival. We currently ignore this and the remote
  ends in whatever heading the last steering tick produced.
- `MovementParameters::__inner0` flag bit 0x80 (Sticky-after-arrive) — when
  set, we should latch onto `top_level_object_id` instead of going idle. For
  an adventuring monster following the player, this matters: today we'd let
  the remote stand still for ~1 s (server's MoveTo re-emit cadence) then
  steer again, instead of locking on smoothly. Worth filing as `#L.X`.
- `transient_state & 1` (Contact) gating. If a remote is mid-air (knocked
  back, jumping), `RemoteMoveToDriver` shouldn't drive horizontal motion
  toward the destination. We currently steer regardless of grounded state.

---

## Appendix: full retail line-citation index (this doc only)

| Function | Address | Pseudo-C lines |
|---|---|---|
| StickyManager::Create | 0x00555800 | 352620-352633 |
| StickyManager::Destroy | 0x00555650 | 352521-352540 |
| StickyManager::SetPhysicsObject | 0x005556e0 | 352544-352555 |
| StickyManager::StickTo | 0x00555710 | 352559-352578 |
| StickyManager::HandleUpdateTarget | 0x00555780 | 352582-352607 |
| StickyManager::UnStick | 0x00555400 | 352335-352346 |
| StickyManager::adjust_offset | 0x00555430 | 352351-352494 |
| StickyManager::UseTime | 0x00555610 | 352498-352517 |
| StickyManager::~StickyManager | 0x005557e0 | 352611-352616 |
| ConstraintManager::Create | 0x00556110 | 353442-353474 |
| ConstraintManager::SetPhysicsObject | 0x00556090 | 353388-353401 |
| ConstraintManager::ConstrainTo | 0x00556240 | 353528-353537 |
| ConstraintManager::UnConstrain | 0x005560c0 | 353405-353409 |
| ConstraintManager::IsFullyConstrained | 0x005560d0 | 353413-353427 |
| ConstraintManager::adjust_offset | 0x00556180 | 353479-353524 |
| ConstraintManager::~ConstraintManager | 0x005560f0 | 353431-353438 |
| MoveToManager::MoveToManager | 0x005293b0 | 306554-306593 |
| MoveToManager::Create | 0x00529470 | 306597-306614 |
| MoveToManager::Destroy | 0x005294b0 | 306618-306663 |
| MoveToManager::InitializeLocalVariables | 0x00529250 | 306490-306534 |
| MoveToManager::PerformMovement | 0x0052a900 | 307871-307904 |
| MoveToManager::MoveToObject | 0x00529680 | 306756-306817 |
| MoveToManager::TurnToObject | 0x005297d0 | 306820-306882 |
| MoveToManager::MoveToPosition | 0x0052a240 | 307521-307593 |
| MoveToManager::TurnToHeading | 0x0052a630 | 307706-307772 |
| MoveToManager::MoveToObject_Internal | 0x0052a400 | 307597-307663 |
| MoveToManager::TurnToObject_Internal | 0x0052a550 | 307667-307702 |
| MoveToManager::AddTurnToHeadingNode | 0x00529530 | 306667-306685 |
| MoveToManager::AddMoveToPositionNode | 0x00529580 | 306689-306706 |
| MoveToManager::RemovePendingActionsHead | 0x00529380 | 306538-306550 |
| MoveToManager::CleanUp | 0x005295c0 | 306710-306736 |
| MoveToManager::CleanUpAndCallWeenie | 0x00529650 | 306740-306752 |
| MoveToManager::CancelMoveTo | 0x00529930 | 306886-306940 |
| MoveToManager::~MoveToManager | 0x005299d0 | 306945-306953 |
| MoveToManager::BeginMoveForward | 0x00529a00 | 306957-307042 |
| MoveToManager::BeginTurnToHeading | 0x00529b90 | 307046-307120 |
| MoveToManager::BeginNextNode | 0x00529cb0 | 307123-307171 |
| MoveToManager::HitGround | 0x00529d70 | 307175-307183 |
| MoveToManager::HandleMoveToPosition | 0x00529d80 | 307187-307438 |
| MoveToManager::HandleTurnToHeading | 0x0052a0c0 | 307442-307517 |
| MoveToManager::UseTime | 0x0052a780 | 307776-307798 |
| MoveToManager::HandleUpdateTarget | 0x0052a7d0 | 307802-307867 |
| MoveToManager::CheckProgressMade | 0x005290f0 | 306385-306431 |
| MoveToManager::GetCurrentDistance | 0x005291b0 | 306435-306460 |
| MoveToManager::is_moving_to | 0x00529220 | 306464-306470 |
| MoveToManager::_DoMotion | 0x00529010 | 306351-306364 |
| MoveToManager::_StopMotion | 0x00529080 | 306368-306381 |
| MovementParameters::towards_and_away | 0x0052a9a0 | 307917-307942 |
| MovementParameters::get_command | 0x0052aa00 | 307946-308012 |
| MovementParameters::get_desired_heading | 0x0052aad0 | 308016-308033 |
| MovementParameters::Pack | 0x0052ab20 | 308037-308074 |
| MovementParameters::UnPack | 0x0052abc0 | 308078-308114 |
| MovementParameters::UnPackNet | 0x0052ac50 | 308118-308190 |
