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>
24 KiB
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
999999fsentinel matters: the first 5-frame window after a fresh Stop will computecumulative_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_haveBaselineDistanceboolean — 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_headingflag (1 = preserve current physics heading instead of using the wire heading).
Branching:
-
Compute
dist: from either (a) the tail's stored position if tail exists ANDtail->type == 1, otherwise (b) the physics object's currentm_position. Then:dist = Position::distance(reference, arg2); blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj); -
Far branch —
dist > 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 withphysics_obj->get_heading(). - Append to tail (the canonical AppendNode op).
this->node_fail_counter = 4;← important: forces an immediate blip-to-tail on the nextUseTime(4 > 3 threshold). This is retail's "we're so far out of sync, just teleport" reflex.- Return.
- Allocate
-
Near & already-very-close branch —
dist <= blipANDPosition::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.)
- If
-
Near & not-yet-close branch —
dist <= blip:- Tail-prune duplicates: while
tail->type == 1ANDPosition::distance(tail, arg2) <= 0.05,LListBase::RemoveTailand delete it. - Cap at 20: walk the list counting nodes; if count >=
0x14(20), drop the head. (Loop00555c73–00555cb1.) - Set
this->keep_heading = arg3; - Allocate, fill (type=1, copy objcell+frame, optionally override heading), append to tail.
- Tail-prune duplicates: while
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 = 4to 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 callerCPhysicsObj::UpdatePartsInternal@0x00512c3c).arg3= framedtin 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 353139–353143 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 callsStopInterpolating.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. StopInterpolatingis called only on successfulSetPositionSimple. 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_DISTANCE → NodeCompleted(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
- Rename
progressQuantumsemantics:_progressQuantum += (float)dt;(NOT step). - Rewrite the secondary check as
(cumulative / sum_dt) / cur_dt < 0.30to match retail verbatim. - Add
NodeCompleted(0)semantics: on stall, pop the head node, snapshot its position into a_blipToPositionfield, 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. - Split
UseTimefromAdjustOffset: retail's UseTime is what actually performs the snap. AdjustOffset only moves the body. Currently we conflate them —AdjustOffsetreturns 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. - On far-distance enqueue (
dist > GetAutonomyBlipDistance()for the entity's cell type), set_failCount = StallFailCountThreshold + 1so the next tick's UseTime triggers a blip to the freshly-enqueued tail. - Optionally gate on a
_isLiveflag equivalent totransient_state & 1once 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.hline 31505. fUseAdjustedSpeed_static: line 1102675 of pseudo-C.- Caller
CPhysicsObj::UpdatePartsInternal@ ~0x00512c30, where the Frame returned fromadjust_offsetis composed withm_position.frameviaFrame::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.