acdream/docs/research/2026-05-04-l3-port/04-interp-manager.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

24 KiB
Raw Blame History

InterpolationManager — full retail port reference

Date: 2026-05-04 Source: docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR PDB-named decomp). Cross-check: src/AcDream.Core/Physics/InterpolationManager.cs (current port), docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md (cdb-traced).


1. Class layout (verbatim from acclient.h)

struct __cppobj LList<InterpolationNode> : LListBase {};

struct __cppobj InterpolationManager
{
  LList<InterpolationNode> position_queue;   // 0x00 (head_, tail_) — singly-linked
  CPhysicsObj             *physics_obj;       // 0x08
  int                      keep_heading;      // 0x0C
  unsigned int             frame_counter;     // 0x10
  float                    original_distance; // 0x14
  float                    progress_quantum;  // 0x18
  int                      node_fail_counter; // 0x1C
  Position                 blipto_position;   // 0x20 ... 0x68 (size 0x68 total per `operator new(0x68)`)
};

InterpolationNode (size 0x60, allocated via operator new(0x60) in InterpolateTo):

Offset Field Notes
0x00 llist_next LListBase next-pointer
0x04 type 1 = position waypoint, 2/3 = velocity-bearing nodes (rare). UseTime velocity-snap reads tail+0x50 / +0x54 / +0x58 (Vector3) for type 2/3.
0x08 vtable / sentinel 0x79285c written before delete
0x0C objcell_id uint32 cell ID
0x10 frame (begin) Position::frame, 0x44 bytes (qw,qx,qy,qz,Origin{x,y,z}, cache)
... (Position internals)
0x50..0x58 velocity Vector3 only meaningful for type 2/3

Queue is FIFO singly-linked. head_ is the node we walk toward first; tail_ is the most-recent enqueue (the latest server-reported position).


2. Constructor / Destroy / StopInterpolating

InterpolationManager::InterpolationManager @ 0x005558d0

this->original_distance = 999999f;     // sentinel — first window has no baseline
this->position_queue.head_ = nullptr;
this->position_queue.tail_ = nullptr;
this->frame_counter   = 0;
this->progress_quantum = 0f;
this->node_fail_counter = 0;
// blipto_position initialised to identity Frame (qw=1, origin=0)
this->blipto_position.objcell_id = 0;
Frame::cache(&this->blipto_position.frame);
this->physics_obj = arg2;

InterpolationManager::Destroy @ 0x00555af0

Free every node in position_queue:

while (head_ = this->position_queue.head_; head_ != 0) {
    LListData *next = head_->llist_next;
    this->position_queue.head_ = next;
    if (next == 0) this->position_queue.tail_ = next;
    head_->llist_next = 0;
    *(uint32_t*)((char*)head_ + 8) = 0x79285c;   // overwrite vtable
    operator delete(head_);
}

InterpolationManager::StopInterpolating @ 0x00555950

Same node-drain loop as Destroy, then resets all stall state:

this->frame_counter      = 0;
this->progress_quantum   = 0f;
this->node_fail_counter  = 0;
this->original_distance  = 999999f;

The 999999f sentinel matters: the first 5-frame window after a fresh Stop will compute cumulative_progress = 999999 - currentDist, which is huge and passes the stall check. This is retail's intended way of suppressing first-window false-positives. Our port replaces this with an explicit _haveBaselineDistance boolean — equivalent.


3. InterpolationManager::InterpolateTo (AppendNode) @ 0x00555b20

Signature: void InterpolateTo(InterpolationManager *this, Position const *arg2, int32_t arg3)

  • arg2 = new server-authoritative target position.
  • arg3 = keep_heading flag (1 = preserve current physics heading instead of using the wire heading).

Branching:

  1. Compute dist: from either (a) the tail's stored position if tail exists AND tail->type == 1, otherwise (b) the physics object's current m_position. Then:

    dist = Position::distance(reference, arg2);
    blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj);
    
  2. Far branchdist > GetAutonomyBlipDistance() (100m outdoor / 20m indoor for creatures):

    • Allocate InterpolationNode (new(0x60) + InterpolationNode::InterpolationNode).
    • node->type = 1; copy objcell_id; copy frame.
    • If keep_heading, overwrite frame heading with physics_obj->get_heading().
    • Append to tail (the canonical AppendNode op).
    • this->node_fail_counter = 4;important: forces an immediate blip-to-tail on the next UseTime (4 > 3 threshold). This is retail's "we're so far out of sync, just teleport" reflex.
    • Return.
  3. Near & already-very-close branchdist <= blip AND Position::distance(physics_obj->m_position, arg2) <= 0.05 (DESIRED_DISTANCE):

    • If arg3 == 0, set heading directly: physics_obj->set_heading(arg2->frame.get_heading(), 1).
    • StopInterpolating(this);
    • Return. (No node enqueued — body is already where it needs to be.)
  4. Near & not-yet-close branchdist <= blip:

    • Tail-prune duplicates: while tail->type == 1 AND Position::distance(tail, arg2) <= 0.05, LListBase::RemoveTail and delete it.
    • Cap at 20: walk the list counting nodes; if count >= 0x14 (20), drop the head. (Loop 00555c7300555cb1.)
    • Set this->keep_heading = arg3;
    • Allocate, fill (type=1, copy objcell+frame, optionally override heading), append to tail.

Answer: AppendNode behavior

There is no separate AppendNode symbol — the logic is inlined in InterpolateTo. The retail behaviors that matter for our port:

  • Duplicate-prune is a tail-walking loop, not a single tail-comparison. (Multiple stale tail entries within 0.05 m of the new target all collapse.)
  • Cap eviction is at the HEAD when reaching 20 entries.
  • Far enqueue forces node_fail_counter = 4 to trigger immediate tail-blip next tick.

Our current port does duplicate-prune against only the last entry (_queue.Last), drops head on cap — functionally equivalent because the tail-walk converges to the same result given a single new arg2. It does NOT replicate the node_fail_counter = 4 "force blip" on far enqueue — see § 7 gap analysis.


4. InterpolationManager::adjust_offset @ 0x00555d30

Signature: void adjust_offset(InterpolationManager *this, Frame *arg2, double arg3)

  • arg2 = output Frame (already-zeroed identity Frame from caller CPhysicsObj::UpdatePartsInternal @ 0x00512c3c).
  • arg3 = frame dt in seconds (double).

Critical answer: arg2 is MUTATED IN PLACE. Not a delta-return.

The last meaningful action (line 353253) is:

00555f10      Frame::operator=(arg2, &__return);

where __return is a Frame whose m_fOrigin was just scaled to the per-frame step, and whose rotation is 0 (or kept-heading) (line 353251). The caller composes this into the world position with:

00512d22      Frame::combine(arg3 /*world out*/, &this->m_position.frame, &var_40 /*= arg2*/);

So adjust_offset writes a translation-only Frame — the caller treats it as an offset Frame to combine with the body's current position. Our acdream port returns a Vector3 delta, which the caller adds to the body — equivalent semantics.

Step-by-step retail flow:

LListData *head_ = this->position_queue.head_;
if (head_ == 0) return;                              // empty queue → no-op

CPhysicsObj *po = this->physics_obj;
if (po == 0) return;

// ---- GATE on transient_state bit 0 ----
if ((po->transient_state & 1) == 0) return;          // line 353080

int type = head_->type;
if (type == 2 || type == 3) return;                  // velocity nodes → skip

// ---- Distance to head ----
float dist = Position::distance(&po->m_position, &head_[2 /* node->p Position */]);
if (dist <= DESIRED_DISTANCE /* 0.05 */) {           // line 353089
    NodeCompleted(this, 1);                           // pop head, advance
    return;
}

// ---- Catch-up speed ----
float catchUp;
if (po->minterp() != 0) {
    float maxSpd = fUseAdjustedSpeed_
        ? CMotionInterp::get_adjusted_max_speed(po->minterp())
        : CMotionInterp::get_max_speed(po->minterp());
    catchUp = maxSpd * MAX_INTERPOLATED_VELOCITY_MOD;   // 2.0
} else {
    catchUp = 0f;
}
// F_EPSILON test: if catchUp < 0.0002 → fallback to MAX_INTERPOLATED_VELOCITY (7.5)
if (catchUp < 0.000199999995f) catchUp = 7.5f;       // line 353128 / 0x40f00000

// ---- Accumulate progress + frame counter ----
this->progress_quantum += (float)arg3;               // ← see note below
this->frame_counter    += 1;

// ---- 5-frame stall window ----
if (this->frame_counter >= 5) {
    float cumulative = this->original_distance - dist;     // line 353150
    if (CPhysicsObj::get_sticky_object_id(po) == 0) {
        bool primary_pass   = cumulative >= MIN_DISTANCE_TO_REACH_POSITION; // 0.20
        bool secondary_pass = cumulative > F_EPSILON
            && (cumulative / progress_quantum / arg3) >= CREATURE_FAILED_INTERPOLATION_PERCENTAGE; // 0.30
        // EITHER pass → window is good. NEITHER pass:
        if (!primary_pass && !secondary_pass) {
            this->node_fail_counter += 1;
            NodeCompleted(this, 0);                  // re-baseline, do NOT stop
            return;
        }
    }
    this->frame_counter      = 0;
    this->progress_quantum   = 0f;
    this->original_distance  = dist;                 // re-baseline window
}

// ---- Compute step Frame ----
Vector3 toHead;
Position::subtract2(&head_[2], &delta_frame, &po->m_position);   // delta_frame.origin = head - here (cell-aware)
toHead = delta_frame.m_fOrigin;

float step = catchUp * (float)arg3;                  // catchUp m/s * dt s = step m

float toHead_mag = AC1Legacy::Vector3::magnitude(&toHead);

// ---- Reach test (different threshold!) ----
if (toHead_mag <= DESIRED_DISTANCE /* 0.05 */)       // line 353222 (note: tested INSIDE this branch too)
    NodeCompleted(this, 1);

// ---- No-overshoot scale ----
if (step < toHead_mag) {
    float scale = step / toHead_mag;
    Vector3::operator*=(&toHead, scale);            // shrink toHead to length=step
}
// else: leave toHead at full magnitude (step would overshoot — clamped to dist)

// ---- Heading override ----
if (this->keep_heading != 0) {
    Frame::set_heading(&delta_frame, 0f);            // zero rotation in the offset Frame
}

// ---- Output ----
Frame::operator=(arg2, &delta_frame);                // OUT: arg2 = translation-only Frame

NOTE on progress_quantum

Look at lines 353139353143 again carefully:

00555e01      /* fld qword [esp+0x60] */;            ; load arg3 (dt as double, 8 bytes)
00555e05      /* fadd dword [esi+0x18] */;           ; add this->progress_quantum (float)
00555e08      uint32_t edx_3 = (this->frame_counter + 1);
00555e0e      this->progress_quantum = ((float)/* fstp dword [esi+0x18] */);

The accumulator is progress_quantum += dt (sum of frame deltas), NOT progress_quantum += step. This contradicts the current acdream port (_progressQuantum += step; line 289 of InterpolationManager.cs). This is a real bug.

Then at the secondary-stall test (line 353169-353171):

00555e68      /* fld dword [esp+0x14] */;            ; cumulative
00555e6c      /* fdiv dword [esi+0x18] */;           ; / progress_quantum   (= sum_dt)
00555e6f      /* fdiv dword [esp+0xc] */;            ; / arg3 (current dt)
00555e73      compare against CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30)

So the secondary fail check is cumulative / sum_dt / current_dt < 0.30. Equivalently: average velocity over the window divided by current dt < 0.30, which has units of 1/seconds. This is a numerically odd formula and feels like a Turbine bug or x87-stack misread by Binary Ninja, but we must port it verbatim.

Constants table

Constant Symbol Value Cited line
MAX_PHYSICS_DISTANCE gate in MoveOrTeleport 96.0 m (cdb-confirmed; not in adjust_offset itself)
CREATURE_OUTSIDE_BLIP_DISTANCE GetAutonomyBlipDistance 100.0 m (cdb)
CREATURE_INSIDE_BLIP_DISTANCE GetAutonomyBlipDistance 20.0 m (cdb)
MAX_INTERPOLATED_VELOCITY_MOD maxSpd × this 2.0 implicit * 2f line 353122
MAX_INTERPOLATED_VELOCITY fallback m/s 7.5 0x40f00000 line 353137
MIN_DISTANCE_TO_REACH_POSITION primary stall thresh 0.20 m line 353185 (cited as &MIN_DISTANCE_TO_REACH_POSITION)
DESIRED_DISTANCE reach + prune 0.05 m line 353222 (cited as &DESIRED_DISTANCE)
CREATURE_FAILED_INTERPOLATION_PERCENTAGE secondary stall ratio 0.30 line 353172 (cited as &CREATURE_FAILED_INTERPOLATION_PERCENTAGE)
F_EPSILON catchUp / cumulative test 0.0002 lines 353127, 353156 (cited as &F_EPSILON)
StallCheckFrameInterval window length 5 line 353146 (>= 5)
StallFailCountThreshold tail-blip trigger 3 line 353270 (> 3 in UseTime)
fUseAdjustedSpeed_ static toggle 1 line 1102675

Question: what does physics_obj->transient_state & 1 gate?

Bit 0 of transient_state. From cross-references in the larger codebase, transient_state & 1 is set when the body is in a state where its position should be advancing (i.e., it has been initialized into the world AND is not in a frozen / parked / teleporting state). When the bit is clear, retail short-circuits adjust_offset and returns without consuming any window time. This avoids the body interpolating during portal transitions, fades, etc.

Our acdream port has no equivalent gate. For the L.3 work this is probably safe (PhysicsBody is always "live" once spawned) but worth filing as a follow-up if we ever see an entity interpolating across a teleport.


5. InterpolationManager::NodeCompleted @ 0x005559a0

Signature: void NodeCompleted(InterpolationManager *this, int32_t arg2)

  • arg2 == 1 → "real" completion (head reached target). If queue empties, also calls StopInterpolating.
  • arg2 == 0 → "stall" completion (re-baseline only; do NOT clear queue).
if (this->physics_obj == 0) return;

LListData *old_head = this->position_queue.head_;
this->frame_counter    = 0;
this->progress_quantum = 0f;
LListData *popped = nullptr;

// Pop old head off the front of the singly-linked list
if (old_head != 0) {
    LListData *next = old_head->llist_next;
    this->position_queue.head_ = next;
    if (next == 0) this->position_queue.tail_ = nullptr;
    old_head->llist_next = 0;
    popped = old_head;
}

LListData *new_head = this->position_queue.head_;
if (new_head == 0) {
    this->original_distance = 999999f;       // empty queue → reset baseline sentinel
    if (arg2 != 0) {                          // real completion
        StopInterpolating(this);
        goto FREE_POPPED;
    }
    if (popped != 0) {
        // arg2 == 0 (stall) AND queue empty AFTER pop:
        // copy popped node's position into blipto_position so that
        // UseTime can blip there if the fail counter trips.
        Position::operator=(&this->blipto_position, &popped->p);
    }
} else if (new_head->type != 1) {
    // Velocity node up next — don't re-baseline distance.
    if (arg2 != 0) goto FREE_POPPED;
    // arg2 == 0 (stall) AND non-position next: still snapshot the popped
    // position into blipto_position for tail-blip use.
    if (popped != 0)
        Position::operator=(&this->blipto_position, &popped->p);
} else {
    // Normal case: new head is a position node; rebaseline distance.
    this->original_distance = (float)Position::distance(
        &this->physics_obj->m_position, &new_head->p);
}

FREE_POPPED:
if (popped != 0) {
    *(int32_t*)((char*)popped + 8) = 0x79285c;
    operator delete(popped);
}

Queue ops: pop is HEAD (FIFO). Memory is freed. The popped node's position is snapshotted into blipto_position when the queue empties under stall — that's the blip-to-tail-on-stall target. (Calling it "blip-to-tail" is a slight misnomer; it's "blip to the position we last failed to reach" when the queue has emptied.)


6. InterpolationManager::UseTime @ 0x00555f20

Signature: void UseTime(InterpolationManager *this). No params. Called every physics tick from PositionManager::UseTime.

CPhysicsObj *po = this->physics_obj;
if (po == 0) return;

int fail = this->node_fail_counter;
if (fail > 3) goto BLIP_BRANCH;          // line 353270 — threshold check

// ---- Normal branch: process head node ----
LListData *head_ = this->position_queue.head_;
if (head_ != 0) {
    int type = head_->type;
    if (type == 3) {
        // Velocity node: write velocity, complete.
        CPhysicsObj::set_velocity(po, &head_[0x14 /*Vector3 at +0x50*/], 1);
        NodeCompleted(this, 1);
        return;
    }
    if (type == 2) {
        // Type-2: just complete (no velocity write).
        NodeCompleted(this, 1);
    }
    // type == 1 → no-op here; adjust_offset moves the body each frame.
} else if (fail > 0) {
    // No queue but a recent failure → fall through to BLIP_BRANCH
    goto BLIP_BRANCH;
}
return;

BLIP_BRANCH:
// ---- "Snap" branch ----
LListData *tail_ = this->position_queue.tail_;
Position target;
bool reapply_velocity = false;
Vector3 saved_vel;

if (tail_ == 0) {
    // No tail → blip to the snapshot stashed in blipto_position by NodeCompleted.
    target = this->blipto_position;
} else if (tail_->type == 2 || tail_->type == 3) {
    // Tail is a velocity node. Walk the list looking for the LAST type-1
    // (position) node before the tail. Save the tail's velocity.
    saved_vel = *(Vector3*)((char*)tail_ + 0x50);
    LListData *cur = this->position_queue.head_;
    bool found_pos = false;
    Position last_pos;
    while (cur != tail_) {
        if (cur->type == 1) {
            last_pos = cur->p;
            found_pos = true;
        }
        cur = cur->llist_next;
    }
    if (!found_pos) {
        // No position to blip to — fall back to blipto_position.
        target = this->blipto_position;
    } else {
        target = last_pos;
        reapply_velocity = true;
    }
} else {
    // Tail is a position node — blip to it directly.
    target = tail_->p;
}

// ---- The actual snap ----
if (CPhysicsObj::SetPositionSimple(po, &target, 1) == OK_SPE) {
    if (reapply_velocity) {
        CPhysicsObj::set_velocity(po, &saved_vel, 1);
    }
    StopInterpolating(this);
}

Answers to the critical questions

  • Does UseTime call SetPositionSimple for the blip? Yes — line 353282 (CPhysicsObj::SetPositionSimple(physics_obj, var_70_3, 1)).
  • Tail blip target is normally the queue's tail node (the most recent server position). When the tail is a velocity node, the algorithm walks back to the last position node. When the queue is empty, blipto_position (set by NodeCompleted on prior pop) is used.
  • StopInterpolating is called only on successful SetPositionSimple. If SetPositionSimple fails (cell transition rejected, etc.), state is preserved and we'll retry next tick.

7. Cross-check against current acdream port

src/AcDream.Core/Physics/InterpolationManager.cs:

Behavior Current port Retail Verdict
Queue is FIFO with cap 20 Yes (LinkedList, RemoveFirst on cap) Yes OK
Duplicate-prune on enqueue Compares to _queue.Last only Walks tail-prune loop Functional match for single enqueues; would diverge if multiple stale tail entries exist (rare in practice).
Enqueue "force blip" via node_fail_counter = 4 on far-distance Missing Line 352944 sets node_fail_counter = 4 Gap. Far enqueues should pre-arm an immediate blip on the next tick. Probably manifests as "remote drifts visibly toward a far target instead of teleporting" when a 100m+ desync is enqueued. Real-world rare; file as follow-up.
reach test against head dist < DesiredDistance → pop, return Vector3.Zero line 353089 dist <= DESIRED_DISTANCENodeCompleted(1), return OK
reach test #2 against toHead.magnitude Not separated line 353222 inside the step branch Both reach against the same scalar; equivalent
catchUp = max(maxSpd*2, fallback 7.5) OK (scaled > 1e-6f) OK (F_EPSILON 0.0002) Threshold tighter (1e-6 vs 2e-4); still functional.
Step clamp (no overshoot) step = min(step, dist) Same (else-branch leaves toHead full mag) OK
progress_quantum += step _progressQuantum += step; progress_quantum += dt; (line 353140) Bug. Retail accumulates time, not distance. The secondary check then divides by this time-sum AND by current dt. Our port computes a different ratio.
Secondary stall: cumulative / progress_quantum < 0.30 Yes Retail computes cumulative / sum_dt / cur_dt < 0.30 (units: 1/sec) Off by a /dt factor. Retail's formula is suspect but we should port verbatim and add a regression test.
frame_counter _framesSinceLastStallCheck Equivalent OK
First-window guard _haveBaselineDistance flag original_distance = 999999f sentinel Equivalent
Re-baseline at window end _distanceAtWindowStart = dist Same OK
Stall fail action Increments fail counter; only blips when threshold exceeded INSIDE adjust_offset Calls NodeCompleted(0) on stall and pops the head; UseTime does the actual blip Architectural gap. Retail's NodeCompleted(0) on stall pops the head node (advancing the queue) — useful when the head is unreachable but later nodes might be. Our port leaves the head in place. This means a single bad waypoint can cause repeated 5-frame failures rather than skipping past it.
Blip target on threshold _queue.Last.TargetPosition (tail) UseTime: tail OR blipto_position OR last-pos-before-velocity-tail Mostly OK for our use case (only type-1 nodes).
transient_state & 1 gate None Required Probably safe in our codebase; file as follow-up.
AdjustOffset return shape Vector3 delta In-place mutate Frame* (translation-only Frame) Functionally equivalent — caller composes with current position either way.

Concrete actionable changes

  1. Rename progressQuantum semantics: _progressQuantum += (float)dt; (NOT step).
  2. Rewrite the secondary check as (cumulative / sum_dt) / cur_dt < 0.30 to match retail verbatim.
  3. Add NodeCompleted(0) semantics: on stall, pop the head node, snapshot its position into a _blipToPosition field, but do NOT clear the queue and do NOT return a snap delta. The blip then fires only via the equivalent of UseTime when _failCount > 3.
  4. Split UseTime from AdjustOffset: retail's UseTime is what actually performs the snap. AdjustOffset only moves the body. Currently we conflate them — AdjustOffset returns the snap delta itself when fail count exceeds threshold. The two-phase split would let us call SetPositionSimple-equivalent (PhysicsBody.SetPositionSimple) once per tick and not mid-frame.
  5. On far-distance enqueue (dist > GetAutonomyBlipDistance() for the entity's cell type), set _failCount = StallFailCountThreshold + 1 so the next tick's UseTime triggers a blip to the freshly-enqueued tail.
  6. Optionally gate on a _isLive flag equivalent to transient_state & 1 once L.3 lands and we have callers that might enable interpolation across teleports.

8. position_queue data-structure operations summary

Op Implementation
Enqueue tail tail_->llist_next = new; tail_ = new; if head was null, head = new too. (lines 352942-352950, 353055-353065)
Pop head head_ = head_->llist_next; if new head null, tail_ = null; delete old head. (lines 352774-352782)
RemoveTail (LListBase::RemoveTail) Used inside InterpolateTo's tail-prune (line 352995). Retail external symbol.
Walk-and-count for cap Lines 353012-353017.
Walk-find for last position before velocity tail Lines 353312-353323 inside UseTime.
Drain (StopInterpolating / Destroy) Same loop, both functions.

The list is singly-linked, head + tail pointers, no prev. Insertion-at-tail and removal-at-head are O(1). RemoveTail and the find-last-position walks are O(N) but N ≤ 20.


9. Bibliography

  • Pseudo-C lines: 352695 (ctor) 353384 (dtor) of docs/research/named-retail/acclient_2013_pseudo_c.txt.
  • Struct layout: docs/research/named-retail/acclient.h line 31505.
  • fUseAdjustedSpeed_ static: line 1102675 of pseudo-C.
  • Caller CPhysicsObj::UpdatePartsInternal @ ~0x00512c30, where the Frame returned from adjust_offset is composed with m_position.frame via Frame::combine.
  • cdb live-trace results: docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md.
  • Existing port: src/AcDream.Core/Physics/InterpolationManager.cs.