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

28 KiB
Raw Blame History

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 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

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)

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 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

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:

  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)

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)

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 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