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

497 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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`)
```c
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`
```c
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`:
```c
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**:
```c
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:
```c
dist = Position::distance(reference, arg2);
blip = CPhysicsObj::GetAutonomyBlipDistance(this->physics_obj);
```
2. **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 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 branch** — `dist <= 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 branch** — `dist <= 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 `00555c73``00555cb1`.)
- 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:
```c
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:
```c
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:
```c
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).
```c
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`.
```c
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_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
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`.