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>
497 lines
24 KiB
Markdown
497 lines
24 KiB
Markdown
# 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 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 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`.
|