# 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 284343–284349 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 3508–3625 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 284340–284362: `arg4==0` falls through to `return 0` | line 3570: `if (!update.IsGrounded) return;` ✓ | | 2. Teleport stamp gate | line 284325–284337: `newer_event(TELEPORT_TS, arg3) → SetPosition(0x1012)` | **MISSING** — no teleport stamp comparison | | 3. 96 m bubble | line 284343–284349: `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 |