acdream/docs/research/2026-05-04-l3-port/12-hard-teleport-branch.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

33 KiB
Raw Blame History

Hard-Teleport (Branch A) + Sequence-Number Plumbing — 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, references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs, references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/PositionPack.generated.cs Companion: 03-up-routing.md (the standard tri-state router)

This note drills into Branch A (hard-teleport) of MoveOrTeleport, the newer_event 16-bit-wrap helper, the update_times[] slot map, the SmartBox::HandleReceivedPosition instance-stamp gate, the SetPosition/SetPositionInternal flag-bit decoder used by Branch A, the wire layout of the four PositionPack u16 stamps, and acdream's current end-to-end gap.


1. CPhysicsObj::newer_event — the 16-bit-wrap stamp comparator

File line: 90712 — 0x00451b10

Verbatim retail (decompiler abs-delta noise replaced with the arithmetic that's actually emitted):

90712  int32_t __thiscall CPhysicsObj::newer_event(
90712      class CPhysicsObj* this,
90712      enum  PhysicsTimeStamp arg2,    // slot index 0..8
90712      uint16_t arg3)                   // wire-side new stamp
90712  {
90716      esi = this->update_times[arg2];          // stored stamp
90718      edi = arg3;                              // received stamp
90721      // signed delta:  diff = (int32)((uint32)edi - (uint32)esi)
90722      // abs(diff)  → eax_4
90723      eax_4 = abs(diff);
90726      if (eax_4 > 0x7fff)
90727          c = (edi < esi);     // wrapped: received is OLDER
90728      else
90729          c = (esi < edi);     // not wrapped: received is NEWER if c
90731      if (diff == 0)
90732          return 0;            // EQUAL → not newer
90734      this->update_times[arg2] = edi;   // STORE new value
90735      return 1;                          // NEWER → 1
90712  }

Decoder note. The Binary Ninja output expands the abs(delta) into HIGHD/LOWD ^ - - salad (lines 9072190723) and the early-return at 90731 reads as -((eax_4 - eax_4)) == 0, which the optimizer collapses into if (diff == 0) return 0;. The stripped semantic equivalent (matches ACE WorldObject_Networking.cs::is_newer_event, PhysicsObj.cs::CheckIsNewer):

static bool IsNewer16(ushort prev, ushort received) {
    int diff = (ushort)(received - prev);     // unsigned 16-bit subtract
    if (diff == 0) return false;              // equal → not newer
    return diff < 0x8000;                     // <32k forward → newer; ≥32k → older
}

Side effect. This is NOT pure — the function writes update_times[arg2] = edi when it returns 1. So the four newer_event calls inside HandleReceivedPosition / MoveOrTeleport simultaneously test and commit the new stamp. Any port must preserve "test-and-set" semantics or the next UP will re-fire as if it were the first.


2. update_times[9] slot map

Header: acclient.h:6084 (verbatim):

enum PhysicsTimeStamp
{
  POSITION_TS               = 0x0,
  MOVEMENT_TS               = 0x1,
  STATE_TS                  = 0x2,
  VECTOR_TS                 = 0x3,
  TELEPORT_TS               = 0x4,
  SERVER_CONTROLLED_MOVE_TS = 0x5,
  FORCE_POSITION_TS         = 0x6,
  OBJDESC_TS                = 0x7,
  INSTANCE_TS               = 0x8,
  NUM_PHYSICS_TS            = 0x9,
};

CPhysicsObj carries unsigned __int16 update_times[9] (acclient.h:30738). The four PositionPack u16s map onto four of these slots:

Wire field (PositionPack order) Slot Slot index Used in MoveOrTeleport?
instance_timestamp INSTANCE_TS 8 gate in UnpackPositionEvent (must EQUAL)
position_timestamp POSITION_TS 0 gate in HandleReceivedPosition (must be newer)
teleport_timestamp TELEPORT_TS 4 Branch A trigger in MoveOrTeleport
force_position_timestamp FORCE_POSITION_TS 6 local-player BlipPlayer trigger

The remaining slots (MOVEMENT_TS, STATE_TS, VECTOR_TS, SERVER_CONTROLLED_MOVE_TS, OBJDESC_TS) are stamped by separate opcodes — UpdateMotion (0xF74C), VectorUpdate (0xF74E), ObjDesc, etc. — not by 0xF748.


3. SmartBox::HandleReceivedPosition (full) — staleness gates around Branch A

File line: 92896 — 0x00453fd0

Verbatim retail (de-noised from line numbers preserved):

92896  void __thiscall SmartBox::HandleReceivedPosition(
92896      class SmartBox*  this,
92896      class CPhysicsObj* arg2,                // target object
92896      class Position const* arg3,             // received position
92896      uint32_t        arg4,                   // placement_id
92896      int32_t         arg5,                   // has_contact (0=air, !=0 grounded)
92896      class AC1Legacy::Vector3 const* arg6,   // velocity
92896      uint16_t        arg7,                   // POSITION_TS (position_timestamp)
92896      uint16_t        arg8,                   // TELEPORT_TS (teleport_timestamp / move-seq)
92896      uint16_t        arg9)                   // FORCE_POSITION_TS
92898  {
92901      objcell_id   = arg3->objcell_id;
92902      var_48       = 0x796910;          // Position vtable
92904      Frame::operator=(&var_40, &arg3->frame);    // local copy of frame
92905      player       = this->player;

           // ───────── (1) LOCAL PLAYER force-position blip ─────────
92907      if (arg2 == player && newer_event(player, FORCE_POSITION_TS, arg9) != 0) {
92910          ebp = player->update_times[TELEPORT_TS];
                // peek-only: is the teleport_ts EQUAL to ours?
92923          if (signed_delta_is_zero(ebp, arg8)) {
92925              CPhysicsObj::get_heading(player);
92927              Frame::set_heading(&var_40, currentHeading);
92928              SmartBox::BlipPlayer(this, &var_48);                 // server forced our pos
92929              player->update_times[POSITION_TS] = arg7;
92931              cmdinterp->vtable->SendPositionEvent(cmdinterp);
92932              return;
              }
       }

           // ───────── (2) PEEK ebp = current POSITION_TS, run TEST-AND-SET ─────────
92936      ebp = arg2->update_times[POSITION_TS];           // save old stamp
92938      if (newer_event(arg2, POSITION_TS, arg7) == 0) {
              // not newer — STALE position; nothing to do unless the teleport
              // stamp is somehow different (logging only).
92941          esi = arg2->update_times[TELEPORT_TS];
92954          if (signed_delta_nonzero(arg8, esi))
92955              ++error_count;
92957          return;                                       // ← stale UP: no body change
       }

           // ───────── (3) TELEPORT_TS sanity vs received arg8 ─────────
92961      ecx_4 = arg2->update_times[TELEPORT_TS];
92974      if (signed_delta_nonzero(ecx_4, arg8)) {
              // received teleport_ts is OLDER than recorded — rewind position
              // stamp & bail (this branch is safety, not a normal path).
92976          arg2->update_times[POSITION_TS] = ebp;
92977          return;
       }

           // ───────── (4) Detach from any parent + re-place ─────────
92982      parent = arg2->parent;
92982      if (parent != 0 && parent->id != this->player_id) {
92984          weenie = CObjectMaint::GetWeenieObject(arg2->id);
92986          if (weenie != 0)
92987              weenie->vtable->SetParentedState(weenie, 0);
       }
92990      CPhysicsObj::unset_parent(arg2);
92992      if (CPhysicsObj::HasAnims(arg2) == 0)
92993          CPhysicsObj::SetPlacementFrame(arg2, arg4, 1);

           // ───────── (5) REMOTE OBJECT branch (the L.3 target) ─────────
92995      if (arg2 != this->player) {
92997          if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0) {
                  // … ConstrainTo with start/max constraint distances …
93007              CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, );
              }
93010          return;
       }

           // ───────── (6) LOCAL PLAYER teleport branch ─────────
93013      if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0) {
93015          SmartBox::TeleportPlayer(this, &var_48);
              // ConstrainTo + zero velocity
93024          CPhysicsObj::ConstrainTo(arg2, &var_48, );
93029          CPhysicsObj::set_velocity(player, &zero, 1);
93030          return;
       }

           // ───────── (7) LOCAL PLAYER soft-correct ─────────
93041      CPhysicsObj::ConstrainTo(this->player, &var_48, );
93044      if (cmdinterp->UsePositionFromServer() != 0 && arg5 != 0) {
93047          autonomyLevel = cmdinterp->GetAutonomyLevel();
93049          CPhysicsObj::InterpolateTo(arg2, &var_48, autonomyLevel != 0);
       }
92896  }

Critical: the order of stamp updates.

  1. INSTANCE_TS is gated equality-only in UnpackPositionEvent (line 93081) — never stored as "newer", just verified equal.
  2. POSITION_TS is test-and-set at line 92938 by newer_event(POSITION_TS, arg7).
  3. TELEPORT_TS is peeked at line 92974 (sanity), then test-and-set at line 93013 (player branch) or line 284325 (remote branch via MoveOrTeleport).
  4. FORCE_POSITION_TS is test-and-set at line 92907.

So a single 0xF748 stamps up to 3 slots: POSITION_TS always (if newer), and either TELEPORT_TS (if teleport advanced) or FORCE_POSITION_TS (if force advanced) but generally not both.


4. SmartBox::UnpackPositionEvent — INSTANCE_TS staleness gate

File line: 93055 — 0x004542c0

Already covered in 03-up-routing.md Section 2. The salient points re-stated:

93055  enum NetBlobProcessedStatus SmartBox::UnpackPositionEvent(...)
93055  {
93059      PositionPack::PositionPack(&var_68);
93060      PositionPack::UnPack(&var_68, arg3, arg4);     // ← reads bytes
93061      eax_1 = CObjectMaint::GetObjectA(this->m_pObjMaint, arg2);
93063      if (eax_1 != 0) {
93065          ecx_4 = eax_1->update_times[INSTANCE_TS=8];
93081          if (signed_delta_is_zero(received_inst, ecx_4)) {
              // ★ EQUAL ⇒ proceed; UN-EQUAL is the loggedout / queue path
93083              if (ecx_4 != received_inst)
93084                  return NETBLOB_LOGGED_OUT;     // (= 2)
93092              SmartBox::HandleReceivedPosition(this, eax_1,
93092                  &recvPos, placement_id, has_contact, &velocity,
93092                  position_ts, teleport_ts, force_position_ts);
93093              return NETBLOB_PROCESSED_OK;       // (= 1)
              }
93095      }
93097      return NETBLOB_QUEUED;                    // (= 4)
93055  }

The instance gate is "equality-or-drop." Reasoning: instance stamp counts character logins. If the server has bumped it (player relogged) the client's still-cached object is the OLD instance — defer or drop. UnPack consumed the bytes, so the buffer pointer advanced either way; only the side effect on the body is gated.

The 16-bit-wrap math is identical to newer_event's, but without the test-and-set side effect — INSTANCE_TS is bumped elsewhere (CreateObject path).


5. CPhysicsObj::MoveOrTeleport — Branch A dissection

File line: 284304 — 0x00516330 (full extract in 03-up-routing.md)

Branch A specifically:

284321  if (signed_delta_is_zero(this_1->update_times[TELEPORT_TS], arg3))   // (a) TELEPORT_TS sanity
284321  {
284325      eax_8 = CPhysicsObj::newer_event(this_1, TELEPORT_TS, arg3);    // (b) test-and-set
284325      //                                       ↑
284325      //                       writes update_times[4]=arg3 if newer
284327      if (eax_8 != 0 || this_1->cell == 0)                            // (c) Branch A predicate
284327      {
              // ───── BRANCH A: HARD TELEPORT ─────
284329          int32_t var_70_3 = 1;                                       // unused local
284330          CPhysicsObj::teleport_hook(this_1, edx_2);                  // (d) teleport_hook
284331          SetPositionStruct sps;
284332          SetPositionStruct::SetPositionStruct(&sps);
284333          SetPositionStruct::SetPosition(&sps, arg2);                 // copy received Position
284334          SetPositionStruct::SetFlags(&sps, 0x1012);                  // (e) Slide+Placement+SendPositionEvent
284335          CPhysicsObj::SetPosition(this_1, &sps);                     // run CTransition + place
284336          SetPositionStruct::~SetPositionStruct(&sps);
284337          return 1;
          }
          /* …Branch B / Branch C (see 03-up-routing.md) … */
   }

Important nuance — sanity vs trigger. The signed_delta_is_zero at line 284321 is a sanity gate — it actually means "abs(delta) == 0", i.e. "the recorded TELEPORT_TS minus the received arg3 is zero, OR the abs-equality compare path was taken". In retail this path is taken when the wire stamp is equal to or newer than recorded (the OLDER case is filtered via the signed_delta_nonzero at 92974 in HandleReceivedPosition before MoveOrTeleport is called). Inside MoveOrTeleport, line 284325's newer_event is the actual "is-newer" decision, with side-effect.

So in the abstract:

Wire arg3 vs recorded update_times[TELEPORT_TS] Path
OLDER (wrap-aware) Already filtered at 92974 — never reaches MoveOrTeleport
EQUAL newer_event returns 0 ⇒ Branch A predicate evaluates 0 || (cell==0) — Branch A ONLY if cell unset; otherwise fall through to Branch B/C
NEWER newer_event returns 1, stores arg3 ⇒ Branch A predicate true ⇒ Branch A fires

And cell == 0 is the bootstrap case — a CPhysicsObj that has been allocated but not yet placed (e.g., we got an UP for an entity mid-teleport, before its initial cell entry). Forces a hard placement even with equal stamp, because there's nowhere else to put it.

teleport_hook (line 283115 — 0x00514ed0)

283115  void CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2)
283115  {
283118      if (this->movement_manager != 0)
283124          MovementManager::CancelMoveTo(this->movement_manager, ctx=0x3c);
283129      if (this->position_manager != 0)
283130          PositionManager::UnStick(this->position_manager);
283134      if (this->position_manager != 0)
283135          PositionManager::StopInterpolating(this->position_manager);   // ← drops queue
283139      if (this->position_manager != 0)
283140          PositionManager::UnConstrain(this->position_manager);
283144      if (this->target_manager != 0) {
283146          TargetManager::ClearTarget(this->target_manager);
283147          TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus);
       }
283150      CPhysicsObj::report_collision_end(this, 1);
283115  }

What teleport_hook does (for the port):

  1. Cancels any in-progress MoveTo route (the AI's "go to door" command).
  2. UnStick: clear the "stuck against wall" recovery state.
  3. StopInterpolating: clear the position queue — required before the hard-snap so no stale waypoint pulls the body away.
  4. UnConstrain: clear distance-from-anchor constraints.
  5. Clear target lock; notify nearby observers we teleported.
  6. Report a "collision end" so adjacent collision listeners stop tracking us.

Steps 3 and 6 are the L.3-relevant ones for acdream. Steps 1, 2, 4, 5 are tangential to position routing.


6. SetPosition / SetPositionInternal flag bit decoder (0x1012)

Outer SetPosition (line 284137 — 0x005160c0) builds a CTransition over the body's spheres and forwards to the flag-decoder SetPositionInternal.

The flag-decoder SetPositionInternal at line 284117 — 0x00516040:

284117  enum SetPositionError SetPositionInternal(class SetPositionStruct const* arg2,
284117                                              class CTransition* arg3)
284117  {
284120      if ((arg2->flags & 0x0200) != 0)                       // bit 9 = SCATTER
284121          return SetScatterPositionInternal(this, arg2, arg3);

284123      objcell_id = arg2->pos.objcell_id;
284124      var_48 = 0x796910;
284126      Frame::operator=(&var_40, &arg2->pos.frame);
284127      result = SetPositionInternal(this, &recvPos, arg2, arg3);  // run normal path

284129      if (result != OK_SPE && (arg2->flags & 0x0100) != 0)   // bit 8 = ALLOW_SCATTER_FALLBACK
284130          return SetScatterPositionInternal(this, arg2, arg3);

284132      return result;
284117  }

The middle dispatcher SetPositionInternal(this, Position*, sps, trans) (line 283892) runs AdjustPosition then dispatches based on sps->flags & 0x20 and other bits, and ultimately calls the inner SetPositionInternal(this, CTransition*) (line 283399) to commit the body state.

Inside CheckPositionInternal (line 280070 — 0x00511e90) and the middle dispatcher we see the bit checks:

Bit Hex Used at Semantic (cross-checked with ACE SetPositionFlags.cs)
0 0x0001 284129 ALLOW_SCATTER_FALLBACK — if the precise placement fails, retry as a scatter (±xrad/yrad) placement.
1 0x0002 284120 SCATTER — go straight to scatter placement (used by SetScatterPositionInternal).
4 0x0010 280075, 280080 PLACEMENT_ALLOW_SLIDING — the sphere will slide along walls during the placement search instead of being rejected on first contact. Set in MoveOrTeleport's 0x1012.
5 0x0020 283929 DO_NOT_LOAD_CELLS — the cell array is left as "do_not_load_cells = 1"; used when streaming hasn't committed the cell yet.
8 0x0100 284129 (see bit 0 — same word, different reading)
9 0x0200 284120 (see bit 1 — same word, different reading)
11 0x0800 IS_PORTAL_TRAVEL (per ACE) — not seen on the MoveOrTeleport paths.
12 0x1000 always present in MoveOrTeleport flags SEND_POSITION_EVENT — after placement, fire the position-event broadcast back through cmdinterp. Set in MoveOrTeleport's 0x1012 and SetPositionSimple's 0x1002/0x1012.

Note that ACE's SetPositionFlags.cs and the older WorldBuilder references use different symbolic names; the bit assignments above match what's actually decoded in the retail pseudo-C against SetPositionStruct::flags field (a uint32_t).

Decoded 0x1012 = 0x1000 | 0x0010 | 0x0002. Hmm — bit 1 (0x0002) is set too. Re-checking: the OR is 0x1000 + 0x0010 + 0x0002 = 0x1012. This means MoveOrTeleport's Branch A also sets the SCATTER bit. That's the inverse of what prior research note (03-up-routing.md Section 5) recorded — let's re-derive from the source.

Re-parse:

  • 0x1012 = binary 0001 0000 0001 0010. Set bits: 1, 4, 12.
  • Bit 1 = SCATTER (line 284120 takes the scatter path on & 0x0200).
  • Wait — 0x0200 is bit 9, not bit 1. Let me reread:
284120  if ((*(uint8_t*)((char*)((int16_t)arg2->flags))[1] & 2) != 0)

The decompiler is reading byte 1 (bits 8-15) of flags and ANDing that byte with 2 — meaning it's testing bit 9 of flags, not bit 1. So 0x0200 is the SCATTER bit. With 0x1012 = bits 1, 4, 12, the bit-1 (0x0002) is NOT the scatter bit — it's something else entirely.

Updated table:

Decimal bit Hex Semantic
0 0x0001 (unknown — possibly SLIDE; see ACE)
1 0x0002 PLACEMENT — the position is a fresh placement (vs. continuation of motion).
4 0x0010 PLACEMENT_ALLOW_SLIDING — sphere slides during placement search (line 280075).
8 0x0100 ALLOW_SCATTER_FALLBACK — retry scatter on placement failure (line 284129).
9 0x0200 SCATTER — initial scatter placement (line 284120).
12 0x1000 SEND_POSITION_EVENT — broadcast position after place.

So 0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT. This is consistent with retail's SetPositionSimple(slide=1) which emits 0x1012 and the prior 03-up-routing.md characterization (Slide + Placement + SendPositionEvent).

0x1002 (used by SetPositionSimple(slide=0)) = SEND_POSITION_EVENT | PLACEMENTwithout the PLACEMENT_ALLOW_SLIDING bit, so the sphere must fit at the exact spot or fail.

0x0011 (used by enter_world line 284208) = PLACEMENT | PLACEMENT_ALLOW_SLIDING — enter without broadcasting an event.

0x0001 (used by enter_world line 284205 default) = (unknown) or possibly SLIDE — only one bit set; behaves as the bare-minimum placement.

Cross-reference recommendation: ACE's Source/ACE.Server/Physics/SetPositionFlags.cs should be the ground truth for symbolic names. The retail PDB decompile shows the bit positions but not the names — Turbine compiled the enum out.


7. Wire format — PositionPack on 0xF748

ACE Source/ACE.Server/Network/Structure/PositionPack.cs:90-115:

public static void Write(this BinaryWriter writer, PositionPack position)
{
    writer.Write((uint)position.Flags);            // u32 PositionFlags
    writer.Write(position.Origin);                 // u32 cellId + Vector3 pos
    if ((flags & OrientationHasNoW) == 0) writer.Write(Rotation.W);
    if ((flags & OrientationHasNoX) == 0) writer.Write(Rotation.X);
    if ((flags & OrientationHasNoY) == 0) writer.Write(Rotation.Y);
    if ((flags & OrientationHasNoZ) == 0) writer.Write(Rotation.Z);
    if ((flags & HasVelocity) != 0)       writer.Write(position.Velocity); // 3xf32
    if ((flags & HasPlacementID) != 0)    writer.Write((uint)position.PlacementID);

    writer.Write(position.InstanceSequence);       // u16
    writer.Write(position.PositionSequence);       // u16
    writer.Write(position.TeleportSequence);       // u16
    writer.Write(position.ForcePositionSequence);  // u16
}

Chorizite generated parser (PositionPack.generated.cs:65-91) matches. The wire order at the end is:

... ObjectInstanceSequence  u16
... ObjectPositionSequence  u16
... ObjectTeleportSequence  u16
... ObjectForcePositionSequence  u16

ACE's TeleportSequence advance logic (PositionPack.cs:46-54):

InstanceSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectInstance);
PositionSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectPosition);  // ← always advanced
if (adminMove)
    TeleportSequence = wo.Sequences.GetNextSequence(SequenceType.ObjectTeleport);
else
    TeleportSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectTeleport);
ForcePositionSequence = wo.Sequences.GetCurrentSequence(SequenceType.ObjectForcePosition);

Critical: TeleportSequence advances ONLY when adminMove=true.

adminMove is a parameter of WorldObject.SendUpdatePosition(bool adminMove = false) (WorldObject_Networking.cs:430). Searching the ACE codebase for SendUpdatePosition(true) returns 0 hits (verified via grep). The parameter is documented as "only used if admin is teleporting a non-player object" (line 429 comment) — i.e., GM @teleto a creature.

For player teleport (portal travel, recall, lifestone, death respawn): ACE sends the dedicated GameMessagePlayerTeleport (opcode 0xF751) which carries only the next ObjectTeleport stamp (GameMessagePlayerTeleport.cs:10):

Writer.Write(player.Sequences.GetNextSequence(Sequence.SequenceType.ObjectTeleport));

…then immediately follows with SendUpdatePosition() carrying the new (already-advanced) TeleportSequence (Player_Location.cs:686-694).

Net effect for remote-observer ports (acdream's case): the TeleportSequence in 0xF748 advances when:

  1. The remote was a player who teleported (PlayerTeleport advanced their own seq, then their next UP carries the new value).
  2. A GM @teleto-ed a creature (admin code path sets adminMove=true).

The TeleportSequence does NOT advance for:

  • Normal walking / running movement
  • Normal AI patrol
  • Mob-hunt path updates
  • Position-only correction broadcasts
  • Force-position blips (those use ForcePositionSequence)

So Branch A in retail fires on remotes specifically when a player just portal-jumped, GM-teleported, lifestone-recalled, or respawned. Test cases for the L.3 port:

  1. Cast a portal recall spell while a remote observer watches you.
  2. Step into a portal while another character is nearby.
  3. Die — respawn at lifestone with a remote watching.
  4. @teleto your character via GM command while another's nearby.

8. Cross-check: acdream's current sequence-number plumbing

8a. Inbound parser

src/AcDream.Core.Net/Messages/UpdatePosition.cs:68-70, 152-159:

public readonly record struct Parsed(
    uint Guid,
    CreateObject.ServerPosition Position,
    System.Numerics.Vector3? Velocity,
    uint? PlacementId,
    bool IsGrounded,
    ushort InstanceSequence = 0,
    ushort TeleportSequence = 0,
    ushort ForcePositionSequence = 0);

The four u16s are read at parse (file lines 152-159). PositionSequence is consumed for buffer alignment but not stored (comment: "not tracked by movement").

8b. WorldSession dispatch

src/AcDream.Core.Net/WorldSession.cs:701-717:

var posUpdate = UpdatePosition.TryParse(body);
if (posUpdate is not null)
{
    // Update sequence counters from the player's own position updates.
    if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
    {
        _instanceSequence      = posUpdate.Value.InstanceSequence;
        _teleportSequence      = posUpdate.Value.TeleportSequence;     // OUR seq; for outbound use
        _forcePositionSequence = posUpdate.Value.ForcePositionSequence;
    }

    PositionUpdated?.Invoke(new EntityPositionUpdate(
        posUpdate.Value.Guid,
        posUpdate.Value.Position,
        posUpdate.Value.Velocity,
        posUpdate.Value.IsGrounded));    // ← stamps DROPPED HERE
}

The four sequence numbers DO arrive. For the local player they're copied into _teleportSequence/_forcePositionSequence for OUTBOUND use only (used at GameWindow.cs:5294, 5312, 5329 to stamp our 0xF61C MoveToState packets). For remote players / NPCs the stamps never leave WorldSession — the EntityPositionUpdate record defined at line 110-114 has only Guid, Position, Velocity, IsGrounded.

8c. Downstream effect

src/AcDream.App/Rendering/GameWindow.cs::OnLivePositionUpdated (line 3312) has no awareness that the inbound UP carried a teleport stamp at all. The L.3 environment-variable path (lines 3508-3625) implements Branches B (in-bubble Interpolate), C (out-of-bubble snap), "first UP seed", and air no-op — but Branch A is never separately taken. A player teleport that hits a remote observer just falls through whichever of B/C/seed/air path the position happens to hit:

  • if airborne (e.g. portal exit at high altitude): air no-op ⇒ body keeps falling locally, NEVER moves to the new portal-exit position until the remote lands.
  • if grounded and within 96m: enqueue the new position, then chase it at walking speed across however far the teleport went — visible "teleport-creep" of up to many meters.
  • if grounded and beyond 96m: snap (this is correct by accident, because the teleport sent us > 96m).

8d. What's needed for the port

Plumb the four u16 stamps from WorldSession.EntityPositionUpdate into OnLivePositionUpdated's RemoteMotionState, then on every UP:

  1. INSTANCE_TS: equality check (already implicit via the GUID matching the live entity).
  2. POSITION_TS: drop the UP if not newer-by-wrap. (Currently acdream applies every UP, even out-of-order ones.)
  3. TELEPORT_TS: test-and-set with the wrap-aware comparator. If newer, fire Branch A:
    • Equivalent of teleport_hook: clear rmState.Interp queue, call report_collision_end on adjacent listeners (likely a no-op in current acdream — the collision broadcaster doesn't yet exist), nuke any in-flight MoveTo (likely none for remotes).
    • Hard-snap rmState.Body.Position = worldPos, rmState.Body.Orientation = rot (already done).
    • Force rmState.CellId = p.LandblockId (already done).
  4. FORCE_POSITION_TS: only relevant for our local player (handled via the BlipPlayer-equivalent in PlayerMovementController, not through the remote path).

The change is small: extend EntityPositionUpdate with the three trailing u16s, store the per-remote TeleportTimestamp on RemoteMotionState, and gate Branch A on its advance.


9. Answers to the cross-questions

Q1. What sequence numbers does ACE actually broadcast in 0xF748 packets?

A. Four u16s in this order: InstanceSequence, PositionSequence, TeleportSequence, ForcePositionSequence. PositionSequence advances on every SendUpdatePosition call (always next). InstanceSequence and ForcePositionSequence stay constant in normal motion (current). The TeleportSequence advances ONLY when adminMove=true, which in practice means "GM teleported this non-player object" or — for the local player — when ACE chains a GameMessagePlayerTeleport (0xF751) before the SendUpdatePosition, advancing the player's own ObjectTeleport seq so the next 0xF748 carries the new value. (Player_Location.cs:686-694.)

Q2. Does TELEPORT_TS only advance on actual teleports, or every position update?

A. Only on actual teleports. ACE: adminMove ? GetNextSequence(ObjectTeleport) : GetCurrentSequence(ObjectTeleport). Retail's MoveOrTeleport is consequently the standard "normal-motion" path 99% of the time and only triggers Branch A on genuine teleport events. This is why decompilers historically named the field "teleport_timestamp" — it's a teleport flag, not a tick.

Q3. Do we have a teleport_timestamp field anywhere in acdream that's already plumbed but unused?

A. Yes — partially. UpdatePosition.Parsed.TeleportSequence exists at Messages/UpdatePosition.cs:69 and is read at line 157. It's then used for the local player's outbound packet stamping (WorldSession._teleportSequence ⇒ MoveToState builders). For remote entities, the stamp is dropped at the PositionUpdated?.Invoke(new EntityPositionUpdate(...)) boundary (WorldSession.cs:712-716) — EntityPositionUpdate has no TeleportSequence field. The L.3 follow-up needs to add that field and a per-RemoteMotionState TeleportTimestamp cache.

Q4. What test cases trigger Branch A in retail?

A.

  1. Player portal travel: another player walks into a portal next to you. Their character's update_times[TELEPORT_TS] advances via GameMessagePlayerTeleport (0xF751) server-side; the immediately-following 0xF748 carries the new TeleportSequence.
  2. Recall spells (Lifestone Recall, Primary Portal Recall, etc.): same path as #1.
  3. Death/Lifestone respawn: PlayerTeleport→UpdatePosition pair.
  4. GM @teleto of a non-player object (creature, item): server-side SendUpdatePosition(adminMove: true).
  5. First UP on a freshly-attached remote with cell == 0: the cell == 0 clause in MoveOrTeleport line 284327 forces Branch A for the bootstrap placement, even with stamp equality. This is acdream's "first-UP seed" case — already handled correctly by the LastServerPosTime > 0 predicate at GameWindow.cs:3563, but the rationale matches retail.

Appendix A — additional symbols

Function / Type Address Line in retail decomp
CPhysicsObj::newer_event 0x00451b10 90712
CPhysicsObj::teleport_hook 0x00514ed0 283115
SmartBox::HandleReceivedPosition 0x00453fd0 92896
SmartBox::UnpackPositionEvent 0x004542c0 93055
CPhysicsObj::SetPosition (outer) 0x005160c0 284137
CPhysicsObj::SetPositionInternal (flag-decode) 0x00516040 284117
CPhysicsObj::SetPositionInternal (middle) 0x00515bd0 283892
CPhysicsObj::SetPositionInternal (inner) 0x00515330 283399
CPhysicsObj::CheckPositionInternal 0x00511e90 280070
CPhysicsObj::SetScatterPositionInternal 0x00515f00 284059
enum PhysicsTimeStamp acclient.h:6084
struct PositionPack acclient.h:53280
struct SetPositionStruct acclient.h:52398

Appendix B — flag-bit summary card

SetPositionStruct.flags  (uint32)

bit 0  0x0001   ?            (single-bit enter_world default; likely SLIDE)
bit 1  0x0002   PLACEMENT    fresh placement vs. continuation
bit 4  0x0010   PLACEMENT_ALLOW_SLIDING   sphere slides during search
bit 5  0x0020   DO_NOT_LOAD_CELLS         keep cells unloaded
bit 8  0x0100   ALLOW_SCATTER_FALLBACK    retry scatter on failure
bit 9  0x0200   SCATTER                   initial scatter placement
bit 11 0x0800   IS_PORTAL_TRAVEL          (per ACE; not in MoveOrTeleport paths)
bit 12 0x1000   SEND_POSITION_EVENT       broadcast pos to cmdinterp

Combined values:
0x1012 = SEND_POSITION_EVENT | PLACEMENT_ALLOW_SLIDING | PLACEMENT
         used by MoveOrTeleport Branch A (teleport)  &  SetPositionSimple(slide=1)
0x1002 = SEND_POSITION_EVENT | PLACEMENT
         used by SetPositionSimple(slide=0) — non-MoveOrTeleport call sites
0x0011 = PLACEMENT_ALLOW_SLIDING | (bit 0)
         used by enter_world(arg2 != 0)  & reenter_visibility
0x0001 = (bit 0)
         used by enter_world(arg2 == 0) — bare entry