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>
28 KiB
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):
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 sameUnpackPositionEvent, then falls through intoSetObjectMovementif 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. QueueBlobForObjectparks 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
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]isINSTANCE_TS(enum PhysicsTimeStamp::INSTANCE_TS = 0x8,acclient.h:6094). The full array isunsigned __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)
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:
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::IsBlippableand 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
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
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:
- If the new waypoint is within 5 cm of the body's current
position (
0.05 fliteral, line 352957),StopInterpolatingwipes the queue and the function returns. No queue change. - If
keep_headingis 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)
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 withteleport_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 callStopInterpolatingfirst to drop any queued waypoints (since they're no longer relevant — the visible position must immediately be the new one).
SetPosition (line 284137 — 0x005160c0)
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)
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)
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:
- Plumb
update.TeleportTimestampfrom the WorldSession message parser intoOnLivePositionUpdated. - On UP receipt, compare against
rmState.TeleportTimestampand on advance: clear queue, hard-snap body, runteleport_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):
- Plumb teleport_timestamp end-to-end and add the missing Branch A gate.
- Pass
IsMovingToproperly (currently hard-coded to false). - 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 |