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>
43 KiB
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):
-
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 worldebp_1 = (eax != 0) ? &eax->m_position : &this->target_position— fall back to last-known position if target despawnedPosition::get_offset(edi_2, &__return, ebp_1)writes(target.world - me.world)intooffset.m_fOrigin.
-
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.
-
Compute cylinder distance minus 0.3 m sticky radius (lines 352375-352378):
target_radius = this->target_radiusvar_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. -
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.
-
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. Ifmax_speed < F_EPSILON→ useMAX_VELOCITYinstead (the global cap). - ACE port (line 112):
speed = minterp.get_max_speed() * 5.0fthenif speed < EPSILON → 15.0f. The retail* 5.0fconstant 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.
-
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.
- The branching (
-
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) whenmovement_params.__inner0has its high bit set. OutboundMoveToObjectpackets 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). Plusset_heading(rotation_delta)— the body turns to face the target each tick atset_headingrate (with the same kind of fudge as ACE'sset_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=Contactflag. ConstraintManager only fires when the body is touching the ground. Mid-air motion (jumps, falls) is unaffected. (This is the sametransient_statebit that motion code checks to decide whetherkill_velocityis allowed — see commita3f53c2.)- No rotation:
set_headingis never touched. Constraint is purely positional. - No timeout:
UseTimeis empty. Stays engaged untilUnconstrainor~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
- Sanity: physics_obj null → CancelMoveTo with err 8 (NotInitialized).
var_3c = GetCurrentDistance(this)→ distance to current target.- Compute heading-to-target delta in
var_40_1, normalize like inMoveToPosition. 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.- If
command == 0(already arrived):RemovePendingActionsHead, thenBeginNextNode(advance to next pending node — typically a final TurnToHeading). - Else:
_DoMotion(this, command, &local_movement_params_clone). On non-zero return →CancelMoveTo(this, retval). On success: stashcurrent_command = var_38,moving_away = var_30,movement_params.hold_key_to_apply = var_34, snapshotprevious_distance/_timeandoriginal_distance/_time.
MoveToManager::BeginTurnToHeading @ 0x00529b90 (lines 307046-307120) — opcode 9 dispatch
- Need head_ + physics_obj; otherwise CancelMoveTo err 8.
- If motions are pending in the body (
CPhysicsObj::motions_pending != 0), skip — wait for body to settle. - Get target heading from
head_->value(offset +0xc). st0 = heading_diff(target, current, 0x6500000d)— 0x6500000d is TurnLeft, soheading_diffreturns "how much left-turn from current to target".- If diff ≈ 180 (within EPSILON of being directly behind): pop head and
advance via
BeginNextNode. Avoids ambiguous turn direction. - If diff ≈ 0 (within EPSILON of already aligned): pop head and advance.
- Else decide direction:
edi = (st0 > 180) ? 0x6500000e (TurnRight) : 0x6500000d (TurnLeft). _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:
-
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_headingreturns 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°ORdelta ≥ 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 fromaux_command,_DoMotion(edi_1, ...)andaux_command = edi_1.
- Compute
-
Distance check (line 307289
GetCurrentDistance→var_88_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 whendist ≤ DistanceToObject(line 307323 —fcomp st0, [esi+0xe4]ismovement_params.distance_to_object). - Flee (
moving_away == 1): arrived whendist ≤ 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 ifdist_from_start > fail_distance→CancelMoveTowith err 0x3D (ObjectGone/ "fail-distance exceeded").
- Chase (
- If no progress: if not interpolating and no motions pending,
-
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 3074000.1const) — 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
- If
current_commandisn't TurnLeft/TurnRight, fall through toBeginTurnToHeading(re-pick direction). - 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.
- If yes: snap heading via
- 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.
- If
- 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,UseTimeis a no-op untilHandleUpdateTargetflipsinitialized=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 command0x45000006= WalkBackwards (used bytowards_and_awaywhen fleeing too close)0x44000007= RunForward (matched inget_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:
- Pending nodes →
_DoMotion(MoveTo*)→CMotionInterp::DoInterpretedMotion(RunForward + HoldKey.Run) CMotionInterpwritesInterpretedState.ForwardCommand=RunForward, HoldKey=Run- Each tick,
apply_current_movementreads InterpretedState and emits a body velocity - 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_distancefor chase /distance_to_objectfor 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 viaPositionManager::StickTocalls 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_quantumadaptive tick rate. We run at fixed 60 Hz.HandleUpdateTargetre-tracking. Server re-emits the full MoveTo when its target moves; we re-parse and re-init.- ConstraintManager and
transient_state & Contactgating. 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::__inner0flag bit 0x40 (UseFinalHeading) — when the packet's final-heading bit is set, the body should rotate to facedesired_headingafter arrival. We currently ignore this and the remote ends in whatever heading the last steering tick produced.MovementParameters::__inner0flag bit 0x80 (Sticky-after-arrive) — when set, we should latch ontotop_level_object_idinstead 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),RemoteMoveToDrivershouldn'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 |