acdream/docs/research/2026-05-04-l3-port/07-sticky-constraint-moveto.md
Erik de129bc164 feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec
Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:56:42 +02:00

919 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |