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

745 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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):
```c
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`):
```csharp
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):
```c
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):
```c
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:
```c
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:
```c
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`)
```c
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`:
```c
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:
```c
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 | PLACEMENT`
**without** 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`:**
```csharp
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`):
```csharp
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`):
```csharp
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`:
```csharp
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`:
```csharp
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
```