acdream/docs/research/2026-05-04-l3-port/03-up-routing.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

585 lines
28 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.

# UpdatePosition (0xF748) Routing Pipeline — Retail Pseudo-C Extract
**Date:** 2026-05-04
**Source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB-named pseudo-C)
**Cross-reference:** `docs/research/named-retail/acclient.h` (verbatim retail headers)
This document extracts the complete UpdatePosition routing tree from the
retail acclient — from the inbound F748 dispatcher down to the body's
position. Every branch is cited verbatim with the originating retail
line number. Cross-checked against acdream's
`OnLivePositionUpdated` in `src/AcDream.App/Rendering/GameWindow.cs:3425`
and `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`.
---
## 0. Pipeline overview (Mermaid)
```
Network blob (NetBlob, opcode 0xF748)
ACSmartBox::DispatchSmartBoxEvent (357117) ─── case 0xF748 ───┐
│ │
▼ │
SmartBox::UnpackPositionEvent (93055) ◄── reads PositionPack ─┘
│ PositionPack = { Position pos, Vec3 velocity, uint32 placement_id,
│ uint32 has_contact, uint16 instance_timestamp,
│ uint16 position_timestamp,
│ uint16 teleport_timestamp,
│ uint16 force_position_timestamp }
▼ if instance_timestamp == obj.update_times[INSTANCE_TS]
SmartBox::HandleReceivedPosition (92896)
args: arg2 = target CPhysicsObj
arg3 = Position* (objcell_id + Frame{origin, rotation})
arg4 = placement_id (uint32)
arg5 = has_contact (int32; 0 = airborne, !0 = grounded)
arg6 = velocity Vec3*
arg7 = position_timestamp (uint16) ← POSITION_TS
arg8 = teleport_timestamp (uint16) ← TELEPORT_TS / move-seq
arg9 = force_position_timestamp (uint16) ← FORCE_POSITION_TS
├─[ if arg2 == player AND newer_event(player, FORCE_POSITION_TS, arg9) ]
│ ► SmartBox::BlipPlayer (92928) — server forced our pos
│ ► return
├─[ if newer_event(arg2, POSITION_TS, arg7) == 0 ]
│ ► return — stale position update
├─[ if arg2 != player ]
│ ► CPhysicsObj::MoveOrTeleport(arg2, &recvPos, arg8, arg5, arg6) (92997)
│ │
│ └── (see Section 3 below)
│ ► if MoveOrTeleport returned 1: CPhysicsObj::ConstrainTo (93007)
│ ► return
└─[ if arg2 == player ]
├─[ if newer_event(player, TELEPORT_TS, arg8) ]
│ ► SmartBox::TeleportPlayer (93015)
│ ► CPhysicsObj::ConstrainTo (93024)
│ ► CPhysicsObj::set_velocity(player, 0) (93029)
│ ► return
└─[ else ]
► CPhysicsObj::ConstrainTo(player, …) (93041)
► if cmdinterp.UsePositionFromServer && arg5 != 0:
CPhysicsObj::InterpolateTo(arg2, &recvPos, …) (93049)
```
Key insight: the `arg2 != player` branch (remotes) is the one that fires
into `MoveOrTeleport`. That's the only place the routing decision tree
between hard-snap, slide-snap, and InterpolateTo lives. The
`arg2 == player` branches (server-corrected local) do their own thing
(BlipPlayer / TeleportPlayer / ConstrainTo + InterpolateTo).
---
## 1. The packet entry — ACSmartBox::DispatchSmartBoxEvent
**File line:** 357117 — `0x005595d0`
Verbatim retail (excerpt of `case 0xF748`):
```c
357181 case 0xf748:
357182 {
357183 ebp_1 = *(uint32_t*)(buf_ + 4); // object guid
357184 arg2 = &buf_[8]; // payload start
357185 result = SmartBox::UnpackPositionEvent(this, ebp_1, &arg2, bufSize_);
357187 if (result != NETBLOB_QUEUED)
357188 return result;
357190 SmartBox::QueueBlobForObject(this, ebp_1, ebx); // not yet known
357191 return result;
357192 }
```
**Notes**
- The opcode dispatch is a simple `switch (ecx)`; F748 is *only* the
generic UpdatePosition. F619 = "MoveObject" (player's own moves)
routes through the **same** `UnpackPositionEvent`, then falls through
into `SetObjectMovement` if the unpack succeeded — see lines 357138
357158. F74C is the server-controlled-move variant that drops in via
a different sequence-stamp check. F748 is the pure-position event.
- `QueueBlobForObject` parks the blob if the target object isn't yet
known to the client, so the position is replayed after CreateObject.
---
## 2. UnpackPositionEvent — gating on `instance_timestamp`
**File line:** 93055 — `0x004542c0`
```c
93055 enum NetBlobProcessedStatus
93055 SmartBox::UnpackPositionEvent(this, arg2 /*guid*/, arg3 /*payload**/, arg4 /*size*/)
93055 {
93059 PositionPack::PositionPack(&var_68);
93060 PositionPack::UnPack(&var_68, arg3, arg4);
93061 eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2);
93063 if (eax_1 != 0)
93063 {
93065 ecx_4 = eax_1->update_times[8]; // INSTANCE_TS
93081 if (newer-by-rolling-uint16(esi /*var_64*/, ecx_4) == 0) // EQUAL
93081 {
93083 if (ecx_4 != esi)
93084 return 2; // NETBLOB_LOGGED_OUT
93092 SmartBox::HandleReceivedPosition(
93092 this, eax_1, &recvPos, placement_id, has_contact,
93092 &velocity, position_ts, teleport_ts, force_position_ts);
93093 return 1; // NETBLOB_PROCESSED_OK
93081 }
93095 }
93097 return 4; // NETBLOB_QUEUED
93055 }
```
**Notes**
- `update_times[8]` is `INSTANCE_TS` (`enum PhysicsTimeStamp::INSTANCE_TS = 0x8`,
`acclient.h:6094`). The full array is `unsigned __int16 update_times[9]`
(`acclient.h:30738`).
- The instance check is "must equal the recorded instance stamp"
(after rolling-uint16 normalization). Mismatch returns NETBLOB_LOGGED_OUT;
unknown object returns NETBLOB_QUEUED.
### PositionPack contents (line 284585)
```c
284589 ebx = first byte of payload // flags byte
284591 Position::UnPackOrigin(&this->position, ) // 12 bytes float3 + uint cell
284593 if (ebx & 8) == 0: read qw (else 0)
284601 if (ebx & 0x10) == 0: read qx (else 0)
284609 if (ebx & 0x20) == 0: read qy (else 0)
284617 if (ebx & 0x40) == 0: read qz (else 0)
284625 Frame::cache(&position.frame); // recompute cached matrix
284628 if (ebx & 1) != 0: read velocity (12 bytes)
284646 if (ebx & 2) != 0: read placement_id (4 bytes)
284654 has_contact = (ebx >> 2) & 1;
284655 read uint16 instance_timestamp;
284660 read uint16 position_timestamp; // POSITION_TS
284664 read uint16 teleport_timestamp; // used as move-seq for arg8 below
284667 read uint16 force_position_timestamp; // FORCE_POSITION_TS
```
Observation: the wire field called "teleport_timestamp" is reused as
the **move-seq** that gets passed as `arg8 = arg3` into
`MoveOrTeleport`. It indexes `update_times[TELEPORT_TS=4]` in the
`newer_event(this, TELEPORT_TS, arg3)` check inside MoveOrTeleport
(284325). One stamp, two purposes — for remotes it acts as a generic
"move sequence"; for the local player it triggers the teleport branch
in HandleReceivedPosition (93013).
---
## 3. CPhysicsObj::MoveOrTeleport — the ROUTER
**File line:** 284304 — `0x00516330`
This is the function L.3 has to port faithfully. Verbatim:
```c
284304 int32_t __thiscall CPhysicsObj::MoveOrTeleport(
284304 class CPhysicsObj* this,
284304 class Position* arg2, // received position (objcell_id + Frame)
284304 uint16_t arg3, // move-seq (TELEPORT_TS slot value)
284304 int32_t arg4, // has_contact (0 = airborne, !0 = grounded)
284304 class AC1Legacy::Vector3 const* arg5) // velocity vector
284306 {
284307 class CPhysicsObj* this_1 = this;
284308 this = this_1->update_times[4]; // current TELEPORT_TS
284311 // rolling-uint16 compare: arg3 vs current update_times[4]
284321 if ( delta-from-rolling-uint16(this, arg3) == 0 ) // SAME-OR-NEWER
284321 {
284325 eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3);
284327 // └── writes update_times[4]=arg3 if newer ──┘
284327 if ( eax_8 != 0 || this_1->cell == 0 )
284327 {
// ───────── BRANCH A: HARD TELEPORT ─────────
284329 int32_t var_70_3 = 1;
284330 CPhysicsObj::teleport_hook(this_1, edx_2);
284332 SetPositionStruct sps;
284332 SetPositionStruct::SetPositionStruct(&sps);
284333 SetPositionStruct::SetPosition(&sps, arg2);
284334 SetPositionStruct::SetFlags(&sps, 0x1012); // ← TELEPORT FLAGS
284335 CPhysicsObj::SetPosition(this_1, &sps);
284336 SetPositionStruct::~SetPositionStruct(&sps);
284337 return 1;
}
284340 if ( arg4 != 0 ) // GROUNDED?
284340 {
// arg4 == has_contact != 0
284342 long double playerDist = this_1->player_distance; // float
284343 long double thresh96 = 96.0f; // ★
284347 if ( playerDist >= thresh96 ) // ★
284347 {
// ───────── BRANCH B: WITHIN BUBBLE → INTERPOLATE ─────────
284351 CPhysicsObj::InterpolateTo(this_1, arg2,
284351 CPhysicsObj::IsMovingTo(this_1));
284352 return 1;
284347 }
// ───────── BRANCH C: BEYOND BUBBLE → SLIDE-SNAP ─────────
284355 class PositionManager* position_manager = this_1->position_manager;
284357 if ( position_manager != 0 )
284358 PositionManager::StopInterpolating(position_manager);
284360 CPhysicsObj::SetPositionSimple(this_1, arg2, /*slide=*/1);
284361 return 1;
}
284362 }
284365 return 0; // STALE — ignore
284366 }
```
★ — the float comparison on lines 284343284349 is x87 nonsense in the
decomp output but the semantic is straightforward. The `if (!p)` branch
is taken when `playerDist < 96f`. (Inverted — the literal pseudo-C
reads "if not (PF set after compare)" which means "comparison was
ordered and not (less or equal)". Cross-checked against ACE
`PhysicsObj.cs::MoveOrTeleport` which reads
`if (player_distance >= MaxObjectTrackingDistance) InterpolateTo(...) else SetPositionSimple(...)`
— SO the labelling above (Branch B = within bubble = InterpolateTo) is
correct as written. Verify on port via cdb if uncertain.)
### The router's three exits
| Branch | Condition | Action |
|--------|-----------|--------|
| **A — Hard Teleport** | `newer_event(TELEPORT_TS, arg3) != 0` (move-seq advanced) **OR** `cell == 0` (object isn't placed yet) | `SetPosition` with flags **0x1012** — teleport-style placement (full sphere validation, `change_cell`, `AddShadowObject`). Position immediately becomes the received position. |
| **B — Interpolate** | grounded (`has_contact != 0`) AND **within view bubble** (`player_distance < 96 m`) | `InterpolateTo(recvPos, IsMovingTo)`**enqueues a waypoint**, body's m_position is NOT changed yet. |
| **C — Slide-snap** | grounded AND **beyond view bubble** (`player_distance >= 96 m`) | `StopInterpolating` (drop queue) + `SetPositionSimple(recvPos, slide=1)` — body snaps to received position, but with `0x1012` flag *omitted* (so this is a softer placement than teleport — see Section 5). |
Air branch (`has_contact == 0`): the function falls through to
`return 0`. **This is the "AIRBORNE NO-OP"** that acdream's
`OnLivePositionUpdated` mirrors at line 3570. The body keeps integrating
gravity locally; received position is discarded.
### Distance constants
- **MAX_PHYSICS_DISTANCE = 96 f** (line 284343) — the in-bubble vs
out-of-bubble threshold inside MoveOrTeleport. **This is the
hard-coded float in the retail binary**; no symbol name in the PDB.
- **CREATURE_OUTSIDE_BLIP_DISTANCE = 100 f** — used elsewhere
(`CMonsterMode::IsBlippable` and similar) to decide visibility blips.
NOT used by MoveOrTeleport.
- **CREATURE_INSIDE_BLIP_DISTANCE = 20 f** — blip threshold for indoor
cells. Also outside MoveOrTeleport.
Only the 96 f figure is on the routing path. The 100/20 figures are
display-only and live in the BlipPlayer / view-cone code.
---
## 4. CPhysicsObj::InterpolateTo — the QUEUE side
**File line:** 278344 — `0x005104f0`
```c
278344 void __thiscall CPhysicsObj::InterpolateTo(
278344 class CPhysicsObj* this,
278344 class Position const* arg2,
278344 int32_t arg3 /* IsMovingTo? — the object is following an MTP route */)
278346 {
278347 CPhysicsObj::MakePositionManager(this);
278348 PositionManager::InterpolateTo(this->position_manager, arg2, arg3);
278349 }
```
This is two lines: ensure a PositionManager exists, then forward.
`PositionManager::InterpolateTo` (line 352136) creates an
`InterpolationManager` lazily and forwards again to
`InterpolationManager::InterpolateTo` (line 352892).
### InterpolationManager::InterpolateTo — what actually queues
```c
352892 void InterpolationManager::InterpolateTo(this, arg2 /*Position**/, arg3 /*isMovingTo*/)
352892 {
352899 tail_ = this->position_queue.tail_;
352902 // Compare new waypoint to the last queued one (or current m_position)
352908 dist = Position::distance( queueTailOrCurrentPos, arg2 );
352911 autonomyBlipDist = CPhysicsObj::GetAutonomyBlipDistance(physobj); // float
352918 if ( dist > autonomyBlipDist ) // ★ FAR
352918 {
// ── Far: enqueue a new InterpolationNode ──
352920 node = operator new(0x60);
352926 edi_1 = InterpolationNode::InterpolationNode(node);
352928 edi_1->kind = 1; // POSITION node
352929 edi_1->objcell_id = arg2->objcell_id;
352930 Frame::operator=(&edi_1->frame, &arg2->frame);
352932 if ( this->keep_heading )
352934 CPhysicsObj::get_heading(physobj); // overwrite heading w/ current
352935 Frame::set_heading(&edi_1->frame, currentHeading);
node_fail_counter = 4; // 4 retry slots
352942 // append to tail (or set head+tail if empty)
352945 return;
352918 }
// ── Near: dist <= AutonomyBlipDistance ──
352956 dist2 = Position::distance(&physobj->m_position, arg2);
352962 if ( dist2 <= 0.05 f ) // 5 cm
352962 {
352964 if ( arg3 == 0 ) // not following MTP route
352968 CPhysicsObj::set_heading(physobj, frame.get_heading(), 1);
352973 InterpolationManager::StopInterpolating(this); // wipe queue
352974 return;
352962 }
// ── Mid-distance: collapse adjacent waypoints into ours ──
352977 while ( queue.tail kind==1 AND Position::distance(tail, arg2) <= 0.05f )
352977 remove tail;
352986 // …then enqueue our waypoint at the end (loop @ 353004 follows)
352892 }
```
**Critical answer to the cross-question — does the body's CURRENT
position change immediately on InterpolateTo?**
**No.** InterpolateTo only manipulates `position_queue`. The body's
`m_position` is advanced by `InterpolationManager::adjust_offset` /
`UpdateInterpolation`, which runs from
`CPhysicsObj::UpdatePhysicsInternal` each tick. The queue is a
sequence of waypoints; the body chases them at the natural movement
speed driven by `MoveToManager` and `RawMotionState`.
There are two clear early-exits:
1. If the new waypoint is within **5 cm** of the body's current
position (`0.05 f` literal, line 352957), `StopInterpolating`
wipes the queue and the function returns. No queue change.
2. If `keep_heading` is set, the queued waypoint inherits the
physobj's CURRENT heading (line 352934) — meaning the queued frame's
rotation is overwritten before insertion. This is how retail
prevents a "snap to face north on UP" on creatures that are mid-
strafe.
---
## 5. CPhysicsObj::SetPosition / SetPositionSimple — the SNAP side
### SetPositionSimple (line 284276 — `0x005162b0`)
```c
284276 enum SetPositionError CPhysicsObj::SetPositionSimple(
284276 class CPhysicsObj* this,
284276 class Position const* arg2,
284276 int32_t arg3 /* slide flag */)
284278 {
284279 uint32_t flags = 0x1002; // base: place-collide+place-no-onwalkable?
284281 if ( arg3 != 0 )
284282 flags = 0x1012; // + slide flag (0x10 = SCATTER?)
284284 SetPositionStruct sps;
284285 SetPositionStruct::SetPositionStruct(&sps);
284286 SetPositionStruct::SetPosition(&sps, arg2);
284287 SetPositionStruct::SetFlags(&sps, flags);
284288 result = CPhysicsObj::SetPosition(this, &sps);
284290 return result;
284291 }
```
Compare with the BRANCH-A teleport in MoveOrTeleport (284334) — it also
uses **0x1012**. So Branch C (slide-snap) and Branch A (teleport)
produce the **same** SetPositionStruct flag. The difference is purely
the conditional path that got us here:
- Branch A: `arg3 != 0`, fires when teleport_timestamp advanced OR cell
was nil. Wraps with `teleport_hook(this_1, …)`.
- Branch C: fires when `arg3 == 0` (move-seq UNCHANGED) and we're
beyond the 96 m bubble. No teleport_hook, but **does** call
`StopInterpolating` first to drop any queued waypoints (since they're
no longer relevant — the visible position must immediately be the new
one).
### SetPosition (line 284137 — `0x005160c0`)
```c
284137 enum SetPositionError CPhysicsObj::SetPosition(this, SetPositionStruct* arg2)
284139 {
284141 eax = CTransition::makeTransition();
284143 if ( eax == 0 ) return 1; // OK / fail-soft
284146 CTransition::init_object(eax, this, 0);
// …gather sphere(s) from this->part_array…
284190 CTransition::init_sphere(eax, num_sphere, sphere*, m_scale);
284191 result = CPhysicsObj::SetPositionInternal(this, arg2, eax);
284192 CTransition::cleanupTransition(eax);
284193 return result;
284137 }
```
The internals: `SetPositionInternal` is the place where the body's
`m_position` actually gets written, after running `CTransition` to
validate the move (collision, walkable-floor, change_cell, etc.).
**By the time MoveOrTeleport returns 1 on Branch A or C, the body's
m_position equals the received position.** This is in stark contrast
to Branch B (InterpolateTo), where m_position is unchanged.
### SetPositionStruct flags reference
The PDB doesn't expose individual flag-bit names, but cross-referenced
against ACE (`PhysicsObj.cs`/`SetPositionStruct.cs`):
| Bit | ACE name | Used here? |
|-----|----------|------------|
| 0x0001 | `Placement` | yes (in 0x1012 / 0x1002) |
| 0x0002 | `Sliding` | sometimes |
| 0x0010 | `Slide` | yes (the +0x10 in SetPositionSimple's `arg3 != 0`) |
| 0x1000 | `SendPositionEvent` | yes (always set in MoveOrTeleport branches) |
So **0x1012 = Slide + Placement + SendPositionEvent**. 0x1002 (the
`arg3 == 0` SetPositionSimple branch, used for non-slide simple
placement, NOT MoveOrTeleport) is just `Placement | SendPositionEvent`.
---
## 6. CPhysicsObj::IsMovingTo (line 276430 — `0x0050eb10`)
```c
276430 int32_t CPhysicsObj::IsMovingTo(class CPhysicsObj const* this)
276432 {
276433 class MovementManager* mm = this->movement_manager;
276435 if ( mm != 0 && MovementManager::IsMovingTo(mm) != 0 )
276436 return 1;
276438 return 0;
276430 }
```
Tells the caller whether the object is currently following a
goal-position via the `MoveToManager`'s scripted-motion machine
(MoveToObject, MoveToPosition, "go to the door" type orders).
This is **not** the same as "is moving" generally — a creature
running a movement style (running cycle) but with no fixed
destination returns false here.
This is the third arg passed into InterpolateTo (line 284351). When
`true`, `InterpolationManager::InterpolateTo`'s near-distance branch
**skips** the `set_heading` correction (line 352964) — the rationale
being that the MoveToManager already handles heading.
---
## 7. Position::distance (line 438258 — `0x005a94b0`)
```c
438258 AC1Legacy::Vector3* Position::distance(
438258 class Position const* this,
438258 class Position const* arg2)
438260 {
438262 result = Position::get_offset(this, &__return, arg2);
438266 return result; // …but the function name is misleading:
// it returns a Vector3 by value via __return
438258 }
```
`get_offset` does the cross-cell offset math (objcell_id-aware) and
fills a Vector3 with the world-space delta. `distance` then uses this
as a Vector3 (its caller calls `.x`/`.y`/`.z` and dot-products), or the
result is cast to a `float` length elsewhere. (Yes, the function name
is wrong — Turbine's joke.)
---
## 8. Move-seq vs teleport-seq logic — the EXACT semantics
Combining the dispatcher, UnpackPositionEvent, HandleReceivedPosition,
and MoveOrTeleport:
| Stamp | Wire field | `update_times` slot | Meaning |
|-------|------------|---------------------|---------|
| `instance_timestamp` | uint16, 5th in PositionPack | `update_times[INSTANCE_TS=8]` | Object generation. UnpackPositionEvent rejects unless equal. |
| `position_timestamp` | uint16, 6th | `update_times[POSITION_TS=0]` | Generic "version" of *this* UP. HandleReceivedPosition drops if not newer. |
| `teleport_timestamp` | uint16, 7th | `update_times[TELEPORT_TS=4]` | **Doubles as move-seq** for remotes. MoveOrTeleport hard-snaps if newer than recorded. For local player → triggers SmartBox::TeleportPlayer. |
| `force_position_timestamp` | uint16, 8th | `update_times[FORCE_POSITION_TS=6]` | Server-forced relocation of OUR character. Triggers BlipPlayer (camera fixup, etc.) when newer. |
The decision: **teleport_timestamp advanced** ⇒ hard-snap (Branch A).
**teleport_timestamp same** ⇒ soft branches (B/C). The 96 m bubble
selects B vs C only on the soft path.
In wire terms:
- A normal "I'm running, server broadcasts my new position" UP has
the **same** teleport_ts as last time, so → InterpolateTo (Branch B).
- A "you got teleported by a portal / GM `@teleto` / death respawn"
UP advances teleport_ts by 1, so → SetPosition w/ teleport flags
(Branch A).
### How this maps to acdream today
`OnLivePositionUpdated` does NOT currently look at the
teleport_timestamp. The L.3 environment-variable port (lines 35083625
of GameWindow.cs) already mirrors the air-no-op (line 3570) and the
96 m bubble (line 3606), but the **teleport_timestamp gate is
missing** — Branch A is never taken explicitly. Teleports today rely
on the WorldSession's own teleport pathway, which short-circuits the
UP routing. The L.3 follow-up should:
1. Plumb `update.TeleportTimestamp` from the WorldSession message
parser into `OnLivePositionUpdated`.
2. On UP receipt, compare against `rmState.TeleportTimestamp` and on
advance: clear queue, hard-snap body, run `teleport_hook`-equivalent.
---
## 9. Orientation handling
A clean answer to the cross-question:
**Orientation is NOT queued separately.** It rides with the Position
struct (which carries `Frame { Vec3 origin; Quat (qw, qx, qy, qz) }`).
What happens to orientation depends on the branch:
| Branch | Position behavior | Orientation behavior |
|--------|------------------|---------------------|
| **A — Teleport** | hard-snapped to recvPos | hard-snapped (Frame.cache rebuilds matrix in SetPositionInternal) |
| **B — InterpolateTo** | queued | **queued in the same Frame**. If `keep_heading` is set on the InterpolationManager, the queued Frame's rotation is **overwritten with the physobj's current heading** (line 352935). Otherwise, the body slerps toward the queued rotation as it walks. |
| **C — SetPositionSimple slide** | hard-snapped | hard-snapped (same Frame.cache path) |
| **AIRBORNE no-op** | unchanged | unchanged |
acdream's current implementation in the env-var path **always
hard-snaps orientation immediately on UP receipt** (line 3516,
`rmState.Body.Orientation = rot;`) — this is a deliberate divergence
from retail (the `keep_heading` path) to keep the visual heading
in lock-step with the queue start, avoiding a one-frame lag
between body position and facing. Document this divergence in the
L.3 commit message; it is a known trade-off, not a bug.
---
## 10. Cross-check: acdream env-var path vs retail
| Step | Retail (MoveOrTeleport) | acdream env-var path (OnLivePositionUpdated, ACDREAM_INTERP_MANAGER=1) |
|------|------------------------|-----------------------|
| 1. Air check | line 284340284362: `arg4==0` falls through to `return 0` | line 3570: `if (!update.IsGrounded) return;` ✓ |
| 2. Teleport stamp gate | line 284325284337: `newer_event(TELEPORT_TS, arg3) → SetPosition(0x1012)` | **MISSING** — no teleport stamp comparison |
| 3. 96 m bubble | line 284343284349: `player_distance < 96f` → InterpolateTo | line 3606: `MaxPhysicsDistance = 96f` ✓ |
| 4. InterpolateTo (queue) | line 284351: `InterpolateTo(arg2, IsMovingTo)` — preserves heading via keep_heading | line 3623: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo: false)` ✓ (but always passes `isMovingTo:false` and pre-extracts yaw from quat — minor divergence) |
| 5. Slide-snap | line 284360: `StopInterpolating + SetPositionSimple(slide=1)` | line 3613: `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` ✓ |
| 6. Cell change | retail: `change_cell` runs inside SetPositionInternal — handles landblock crossing and AddShadowObject | acdream: `_physicsEngine.ShadowObjects.UpdatePosition(...)` already runs upstream at line 3463, before the routing. ✓ (slight ordering difference — retail does it inside the SetPosition flow) |
**Recommended L.3 follow-ups (not part of this research note):**
1. Plumb teleport_timestamp end-to-end and add the missing Branch A
gate.
2. Pass `IsMovingTo` properly (currently hard-coded to false).
3. Decide whether to honor `keep_heading` (acdream-side flag on the
sequencer) or keep the always-snap divergence — depends on whether
visible heading lag during MoveTo is acceptable.
---
## Appendix A — symbol map
| Function | Address | Line in retail decomp |
|----------|---------|----------------------|
| `ACSmartBox::DispatchSmartBoxEvent` | `0x005595d0` | 357117 |
| `SmartBox::UnpackPositionEvent` | `0x004542c0` | 93055 |
| `SmartBox::HandleReceivedPosition` | `0x00453fd0` | 92896 |
| `PositionPack::UnPack` | `0x00516740` | 284585 |
| `CPhysicsObj::MoveOrTeleport` | `0x00516330` | 284304 |
| `CPhysicsObj::SetPositionSimple` | `0x005162b0` | 284276 |
| `CPhysicsObj::SetPosition` | `0x005160c0` | 284137 |
| `CPhysicsObj::InterpolateTo` | `0x005104f0` | 278344 |
| `CPhysicsObj::IsMovingTo` | `0x0050eb10` | 276430 |
| `CPhysicsObj::newer_event` | `0x00451b10` | 90712 |
| `PositionManager::InterpolateTo` | `0x005551f0` | 352136 |
| `InterpolationManager::InterpolateTo` | `0x00555b20` | 352892 |
| `Position::distance` | `0x005a94b0` | 438258 |
| `enum PhysicsTimeStamp` | — | acclient.h:6084 |
| `struct SetPositionStruct` | — | acclient.h:52398 |