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

43 KiB
Raw Blame History

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

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."

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.

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:
      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_EPSILONdelta = 0.
    • If delta < -F_EPSILONdelta += 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)

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

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)

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)

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:

// 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:

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 GetCurrentDistancevar_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_distanceCancelMoveTo 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.0CPhysicsObj::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.

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)

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:

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)

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

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).

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):

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.

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:

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