acdream/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md
Erik aab9ddaf6b docs(teleport-flow): design — unified retail TeleportAnimState flow (Work item B keystone)
Brainstormed + decomp-researched the retail teleport flow, the keystone of
the #138/#145 teleport cluster. Five oracle calls locked: full retail TAS;
hold-until-landblock-loaded readiness (folds in the #145 residual / Work
item A); unify all four entry points (login/logout/death/portal); build the
literal portal swirl this pass; logout = animation + 0xF653 + disconnect
(char-select UI deferred).

Grounded in a verified 5-agent decomp pass (workflow wf_f0c07c93-7aa): the
7-state TeleportAnimState machine + golden constants (FADE=1s, MIN/MAX
CONTINUE=2/5s, FPS=40), the teleport_in_progress hold gate, per-entry start
states, sounds, input lock, exit (LoginComplete 0xA1). Load-bearing acdream
facts re-verified personally: AddLandblock atomicity, streaming-progresses-
during-hold, outdoor place-immediately.

Spec: docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:03:10 +02:00

326 lines
21 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.

# Design — Retail teleport flow (the unified `TeleportAnimState` controller)
**Date:** 2026-06-21
**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the
teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long"
symptoms).
**Milestone:** M1.5 "Indoor world feels right" (far-town + dungeon round-trips).
**Status:** APPROVED design — ready for implementation plan.
**Decision authority:** user (retail oracle), 2026-06-21. Five binding calls:
1. Visual cover = **full retail TAS** (fade → portal tunnel → fade), not a simplified fade.
2. Readiness gate = **hold outdoor until the physics landblock is loaded** (folds Work item A / #145 residual in).
3. Unification = **all four** entry points (login / logout / death / portal) this pass.
4. Tunnel swirl = **resolve the dat asset + build the literal 3D swirl this pass** (net-new sub-viewport infra).
5. Logout = **animation + `0xF653` + clean disconnect now**; the char-select-return UI is deferred (§9).
**Relates to:** #145 (the readiness-gate fix subsumes the reopened residual / Work item A), #138 (teleport-OUT
of a dungeon rides the same hold-until-loaded path), Work item C (FPS leak — separate, not addressed here).
---
## 1. Problem
A teleport in acdream **places the player immediately** on the server-authoritative position, then lets the
world stream in around them. Three user-reported symptoms all trace to that one design choice:
1. **Camera floats** to the destination — the old camera is snapped to a placed-but-unstreamed spot and the
world materializes around it.
2. **Feels too long / wrong vs retail** — there is no animation pacing the wait; it is a raw snap plus a
visible streaming-in.
3. **Input is not locked** during the transition (yaw still moves — §3.6).
A fourth, deeper consequence is the **#145 residual / Work item A**: an immediate placement onto a
**not-yet-streamed** landblock near an edge lands the player on a `branch=NO-LANDBLOCK` empty world, where the
per-tick resolve marches the cell label and free-falls Z (the `0xC98C…` stuck-at-`21684` capture in
`launch5.log`).
Retail does none of this. It plays a **fade + portal-tunnel animation that COVERS the world-load wait**, holds
input and the camera, **waits until the world has actually loaded**, then places the player at a single exit
point. The same flow drives **login, logout, death, and portal** — it is the client's one "go somewhere"
primitive. Porting it faithfully fixes the float, the timing, the input-lock, **and** the #145 residual (the
hold removes the unstreamed-arrival gap entirely).
acdream already has the *skeleton*: `PlayerState.PortalSpace` (input freeze) + `TeleportArrivalController`
(hold-until-ready) + `TeleportArrivalRules` (the readiness verdict). What is missing is the **animation /
visual cover**, a **strong enough readiness gate**, and **unification** of the four entry points (which today
duplicate the readiness + recenter logic and, for logout/death, barely exist).
---
## 2. Retail reference (decomp-verified)
All citations are to `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line ≈ pseudo-C line) and
`acclient.h`, captured by the 5-agent research workflow `wf_f0c07c93-7aa` and spot-verified.
### 2.1 The `TeleportAnimState` machine
`enum TeleportAnimState` (`acclient.h:6871`) — 7 states. The machine lives in `gmSmartBoxUI::UseTime`
(pc 0x004d6e30, ≈219400); `BeginTeleportAnimation` (218888) enters it; `EndTeleportAnimation` (218994)
advances a running tunnel to `TUNNEL_CONTINUE`.
```
TAS_OFF(0)
→ TAS_WORLD_FADE_OUT(1) ~1s fade the world to black
→ TAS_TUNNEL_FADE_IN(2) ~1s fade the portal tunnel in
→ TAS_TUNNEL(3) HOLD wait for the world to load (the gate)
→ TAS_TUNNEL_CONTINUE(4) 25s min/max spin-hold after load
→ TAS_TUNNEL_FADE_OUT(5) ~1s fade the tunnel out
→ TAS_WORLD_FADE_IN(6) ~1s fade the (now-loaded) world in
→ TAS_OFF
```
**Golden constants** (read directly from the binary at the cited VAs):
| Constant | Value | VA | Role |
|---|---|---|---|
| `TELEPORT_ANIM_FADE_TIME` | **1.0 s** | 0x007BD278 | duration of each of the 4 fade segments |
| `TELEPORT_ANIM_MIN_CONTINUE_TIME` | **2.0 s** | 0x007BD268 | `TUNNEL_CONTINUE` floor before `FADE_OUT` |
| `TELEPORT_ANIM_MAX_CONTINUE_TIME` | **5.0 s** | 0x007BD270 | forces `FADE_OUT` even if not loaded |
| `TELEPORT_ANIM_FPS` | **40.0** | 0x007BD280 | swirl `CPhysicsObj` animation rate |
| `TRANSITION_VIEW_PLANE_DISTANCE` | **0.001** | 0x007BD260 | the near-plane value that reads as "black" |
**Timing budget that results (this is "faithful", a floor we cannot shave):**
- Portal / login / **death** (enter at `TUNNEL`): `TUNNEL`(load) + `CONTINUE`(≥2s) + `FADE_OUT`(1s) +
`WORLD_FADE_IN`(1s) ≈ **4 s + load**.
- **Logout** (enters at `WORLD_FADE_OUT`): + `WORLD_FADE_OUT`(1s) + `TUNNEL_FADE_IN`(1s) on the front ≈ **6 s + load**.
### 2.2 The fade mechanism
The fade is **not** an alpha quad in retail — it collapses the camera near-plane via
`Render::set_vdst` (≈342121) to `TRANSITION_VIEW_PLANE_DISTANCE` (0.001), which renders the scene black.
Interp: `vdst = lerp(gameVDist, 0.001, GetAnimLevel(t)/1024)` on fade-out and the reverse on fade-in, with
`t = clamp(elapsed / FADE_TIME, 0, 1)`. `UIGlobals::GetAnimLevel` is a table-driven ease curve mapping
`t∈[0,1]``int∈[0,1024]` (exact curve unread — see §8). The **visible result is identical to a fade to
black**, which is what acdream will reproduce (§3.3).
### 2.3 The tunnel swirl
During the four `TUNNEL*` states the world is hidden (`SmartBox::Hide`) and a **portal viewport is shown**:
a `UIElement_Viewport` (Type 0xD, child id `0x10000436`) hosting a `CPhysicsObj` made from
`GetDIDByEnum(0x10000001, 7)` (the portal setup object), with a distant light, camera at `(0.24, 2.7, 0.88)`,
playing animation `GetDIDByEnum(0x10000002, 7)` at **40 fps**, plus a **randomized camera-direction rotation**
each segment (`CreatureMode::SetCameraDirection_Degrees`, new random end-angle + duration via `RandDouble`).
The "In Portal Space - Please Wait..." string is (re)displayed each time the rotation segment restarts.
The `GetDIDByEnum` ids are runtime EnumIDMap lookups — the concrete dat ids must be resolved by a runtime
trace (§8, the swirl slice's prerequisite).
### 2.4 The "world has loaded" signal (the hold gate)
`SmartBox::teleport_in_progress` (pc 90815) `= (player != 0) && (position_update_complete == 0)`.
`TAS_TUNNEL` holds while this is true. `position_update_complete` is **cleared** on receipt of a server
teleport (`SmartBox::HandlePlayerTeleport`, 91200, sets `waiting_for_teleport = 1`); it is **set to 1** in
`SmartBox::UseTime` (94184) once `waiting_for_teleport == 0` — which clears when the player's physics state
settles (`DoSetState`, state bit `0x40` cleared). i.e. retail advances on **state-settled**, not a timer. The
acdream analogue is "the destination collision landblock is resident" (§3.4) — the async-streaming equivalent
of retail's synchronous load.
### 2.5 Per-entry start state, sounds, lock, exit
| Entry | Start state | Trigger (decomp) |
|---|---|---|
| **Portal** | `TAS_TUNNEL` (skips world-fade-out) | `teleport_in_progress` 0→1 from the server teleport message (219440) |
| **Login** | `TAS_TUNNEL` | same predicate at first tick; exits fast (`waiting_for_teleport==0`) (85, 219440) |
| **Logout** | `TAS_WORLD_FADE_OUT` (full fade-out first) | `logOffRequested && cur_time ≥ logOffRequestTime` (219415) |
- **Death is a server teleport**, not a client flag: ACE teleports the corpse-owner to the sanctuary after the
death anim (`Player_Death.cs:247`, `Player_Location.cs:686`) — the client sees an ordinary teleport. So death
rides the **portal** path, entering at `TAS_TUNNEL` (NOT the `logOffRequested` `WORLD_FADE_OUT` path, which is
logout-only — retail shares the `logOffRequested` *code* between logout and self-destruct, but client-observed
death arrives as a teleport).
- **Sounds:** `Sound_UI_EnterPortal` in `BeginTeleportAnimation` (218903); `Sound_UI_ExitPortal` at
`TAS_TUNNEL_FADE_OUT → WORLD_FADE_IN` (219745). (The `0x6A/0x6B` ids in the handoff are **wrong** for our
enum — they map to creature sounds; the real Wave ids come from the dat SoundTable — §8.)
- **Input lock:** `PlayerModule::SetLockUI` sets `options2_` bit `0x1000000` for the whole animation;
`SetTeleportInProgress(1)` also bumps a UI busy-count (`IncrementBusyCount`, 361733).
- **Exit:** at `WORLD_FADE_IN` completion → `SendLoginCompleteNotification` (367516) (sends GameAction `0xA1`
to the server) — fired on **both** initial login and after **every** portal (holtburger confirms:
`messages.rs:464,480`). Gated on the player weenie + `AllContainedObjectsExist`.
- **Camera is NOT frozen by the TAS machine** — the regular `UpdateCamera` keeps running; the "freeze" is just
the world not being drawn during the tunnel.
---
## 3. acdream end-state design
### 3.1 Components (boundaries)
Two pure pieces + thin App wiring, mirroring the existing `TeleportArrivalController` (pure hold) +
`TeleportArrivalRules` (pure verdict) split.
- **`TeleportAnimSequencer`** (`AcDream.Core`, pure, no GL/dat/net — **unit-tested against §2.1 golden
timings**). The 7-state machine. Per `Tick(dt, worldReady, …)` it returns an immutable snapshot:
```csharp
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
float FadeAlpha, // 0 = clear world, 1 = full black
bool ShowTunnel, // true during TUNNEL_FADE_IN..TUNNEL_FADE_OUT
bool ShowPleaseWait); // true during TUNNEL_CONTINUE
public enum TeleportAnimEvent { None, PlayEnterSound, PlayExitSound, Place, FireLoginComplete }
```
`Begin(TeleportEntryKind kind)` sets the start state per §2.5. The sequencer owns the durations/transitions;
it knows nothing about *how* the fade or tunnel are drawn. `worldReady` is supplied by the controller (the
arrival hold). It emits `Place` when leaving `TUNNEL` (world loaded) and `FireLoginComplete` at `OFF`.
- **`TeleportFlowController`** (`AcDream.App.World`). Owns one `TeleportAnimSequencer` + the existing
`TeleportArrivalController`. Each frame: ask the arrival controller whether the destination is ready, feed
that as `worldReady`, tick the sequencer, then apply the snapshot/events: drive the fade overlay alpha,
show/hide the tunnel viewport, play sounds, hold/clear the input lock, and on `Place` invoke the existing
placement (`PlaceTeleportArrival`), on `FireLoginComplete` send GameAction `0xA1`. It is the **single owner**
of "a teleport is happening"; the four entry points all call into it.
- **`TeleportFadeOverlay`** (`AcDream.App.UI`) — a `UiRoot` child (last, top-most among retail-UI) that fills
the screen with `(0,0,0,FadeAlpha)` via `TextRenderer.DrawRect` inside the existing `UiHost.Draw` pass
(`GameWindow.cs:8885`). No new GL infra.
- **`PortalTunnelView`** (`AcDream.App.Rendering`) — **net-new**: an offscreen 3D sub-scene rendering the
resolved swirl `CPhysicsObj` at 40 fps with the randomized camera spin, blitted to the screen during
`ShowTunnel`. This is the heaviest slice and is gated on the asset trace (§7, §8).
Relationship to the existing controller: `TeleportArrivalController` stays the **pure hold** ("is the world
ready, and place when so"); `TeleportFlowController` is the **visual/timing/lock state machine** wrapped
around it. The arrival controller's "ready" verdict is the sequencer's `worldReady` gate (the
`teleport_in_progress` analogue).
### 3.2 State machine
Port §2.1 verbatim: states, the 1.0/2.0/5.0 s constants, the per-entry start states (§2.5). `FadeAlpha` is
`lerp` per §2.2 with `t = clamp(elapsed/FADE_TIME, 0, 1)` and a **smoothstep** ease in place of retail's
`GetAnimLevel` table (§8 — upgrade to the real table if a cdb read resolves it; *register row*). The
`TUNNEL → TUNNEL_CONTINUE` edge fires when `worldReady` first becomes true (retail's `EndTeleportAnimation`);
`MAX_CONTINUE_TIME` forces progress if it never does (the safety net, replacing the current 600-frame timeout
semantics).
### 3.3 Visual cover
- **Fade:** the `TeleportFadeOverlay` alpha-black quad (§3.1). *Register row: black-fade via alpha overlay,
not retail's near-plane collapse — visually identical, no camera-projection surgery.*
- **Tunnel swirl:** `PortalTunnelView` (§3.1) rendering the resolved swirl object. Prerequisite: a cdb trace
on a live retail teleport (bp `CPhysicsObj::makeObject`) to capture the concrete `GetDIDByEnum(0x10000001,7)`
/ `(0x10000002,7)` dat ids (§8). During `ShowTunnel` the world is not drawn (sequencer drives this), matching
retail `SmartBox::Hide`.
- **"In Portal Space - Please Wait…"** text during `TUNNEL_CONTINUE` (`ShowPleaseWait`), drawn by the
retail-UI text layer.
### 3.4 Readiness gate — Call-2 (folds in Work item A / #145 residual)
Today `TeleportArrivalRules.Decide` returns `Ready` for **any outdoor** destination → immediate placement →
the NO-LANDBLOCK gap. The fix:
- Add `PhysicsEngine.IsLandblockLoaded(uint landblockId) => _landblocks.ContainsKey(landblockId)`. Because
`AddLandblock` registers terrain + collision cells + portals **atomically** (`PhysicsEngine.cs:70`),
`ContainsKey` ⟹ the collision mesh is present and resolvable.
- `TeleportArrivalRules.Decide` gains an `outdoorLandblockReady` input; the outdoor branch returns `Ready`
**iff** the destination landblock is loaded, else `NotReady` (hold). Indoor still gates on
`IsSpawnCellReady` (unchanged). `Impossible` (un-hydratable claim) and the timeout safety-net are unchanged.
- **Why this is safe and self-resolving:** streaming **progresses during the hold** — `_streamingController.Tick`
runs unconditionally with the observer pinned to the destination (`GameWindow.cs:7420-7531`), and the #145
`RemoveLandblock` of the stale source clears `CurrCell` so the dungeon-streaming gate ExitDungeonExpands to
the destination (`PhysicsEngine.cs:84-95`). The gate flips `Ready` the same frame `AddLandblock` fires
(`_teleportArrival.Tick` runs after the streaming drain — `GameWindow.cs:7551`). The player is therefore
**never** placed onto an empty world → no cell-march, no Z free-fall. The 10 s timeout remains a loud net.
- **Verified-by-apparatus first** (task #4): before relying on this, add a probe that logs the readiness verdict
transition + `_landblocks.Count` and confirms on a `0xC98C…`-style edge arrival that the gate flips `Ready`
*before* placement (pair with `ACDREAM_PROBE_RESOLVE` / `ACDREAM_CAPTURE_RESOLVE`). No guess-patch (the
original #145 burned 5 attempts).
This is the single change that subsumes Work item A. After it lands, re-test the `launch5.log` scenario.
### 3.5 Unification + de-dup
The login auto-entry readiness (`GameWindow.cs:1036-1064`) and the teleport readiness
(`TeleportArrivalReadiness`, `:5419-5438`) are **verbatim duplicates** (the code comment says so). Extract a
single `ComputeArrivalReadiness(destPos, destCell)` used by both sites. The two `_liveCenter` recenters
(login `:2874-2882`, teleport `:5364-5371`) stay in their respective handlers but share one helper. This kills
the drift hazard where #135/#145-class bugs lived.
### 3.6 Input / camera lock
PortalSpace already freezes keyboard turns (the `Update` early-return precedes the yaw block). Two yaw leaks
remain (the handoff gotcha), both in the Silk mouse-move handler, bypassing the `Update` guard:
- **MMB mouse-look** (`GameWindow.cs:1098`) → `_mouseLook.ApplyDelta` writes `Yaw`.
- **RMB orbit** (`GameWindow.cs:1120`) → writes `_retailChaseCamera.YawOffset` / `_chaseCamera.YawOffset`.
Fix: a `State == PortalSpace` early-return at the top of each branch. (No change to PortalSpace logic itself.)
### 3.7 Sounds
Resolve the EnterPortal / ExitPortal Wave ids from the dat SoundTable (§8) and play via
`OpenAlAudioEngine.PlayUiWave` (the UI-pool path; the `IAudioEngine.PlayUi(SoundId)` stub is a no-op — do not
use it). Enter on `PlayEnterSound`, exit on `PlayExitSound`.
---
## 4. The four entry points
| Entry | Start | Trigger in acdream | Scope this pass |
|---|---|---|---|
| **Portal** | `TUNNEL` | `OnTeleportStarted` (0xF751) **or** the large-jump branch of `OnLivePositionUpdated` | Full TAS — the keystone |
| **Death** | `TUNNEL` | server teleport-to-lifestone (same path as portal) | Free — verify it routes through the controller (§8 confirms the trigger) |
| **Login** | `TUNNEL` | the #107 auto-entry place (`EnterPlayerModeNow`) | Unify: route through the controller (tunnel→fade-in on spawn) + the §3.5 de-dup. **Regression-gated: login must stay FPS-steady.** |
| **Logout** | `WORLD_FADE_OUT` | a new "log off" command | Animation + `0xF653` + clean disconnect. **Char-select-return UI deferred (§9).** |
The controller keys off both the `0xF751` start **and** the existing large-position-jump detection, so a
teleport that arrives only as an `UpdatePosition` (no `0xF751`) still triggers the flow — more robust than
relying on the opcode alone.
---
## 5. Divergence-register rows (added in the implementing commits)
1. **Fade via alpha overlay, not near-plane collapse** — adaptation; visually identical (§3.3).
2. **`GetAnimLevel` ease approximated as smoothstep** — approximation; retire if the 1024-entry table is
resolved by a cdb read (§8).
3. **Tunnel-swirl camera/rotation parameters approximated** — approximation; the exact `RandDouble` angular
range + the frame-120 exit detail were medium-confidence in the decomp (§8); tune to match a live trace.
4. **Logout returns to disconnect/window-close, not a char-select screen** — stopgap; retired when the
char-select / re-login UI is built (§9).
(Any row that a later port retires is deleted in the same commit, per the register rules.)
---
## 6. Testing & verification
- **Unit:** `TeleportAnimSequencer` against §2.1 golden timings — each transition fires at the right elapsed
time; per-entry start states; `worldReady` gating of `TUNNEL`; `MAX_CONTINUE` force; the event sequence
(Enter sound → Place → Exit sound → LoginComplete).
- **Readiness conformance:** `TeleportArrivalRules.Decide` truth table incl. the new `outdoorLandblockReady`
axis (outdoor-not-loaded → `NotReady`; outdoor-loaded → `Ready`; indoor unchanged; impossible → `Impossible`).
- **Apparatus (task #4):** confirm the gate flips `Ready` before placement on an edge arrival (§3.4); re-run
the `launch5.log` `0xC98C` scenario — expect no march, no Z free-fall.
- **Build/test green**, then **visual verification by the user** (the one mandatory stop): portal feel, the
swirl, login spawn, death-to-lifestone, logout fade — does it match retail?
## 7. Implementation slicing (ordered; writing-plans details these)
1. **`TeleportAnimSequencer`** (pure Core) + unit tests — no wiring yet.
2. **Readiness gate** (`IsLandblockLoaded` + `Decide` change) + apparatus probe + the `launch5.log` re-test.
*(Lands the #145 residual / Work item A fix independently of the visuals.)*
3. **`TeleportFlowController` + `TeleportFadeOverlay`** — wire portal through the sequencer with the fade
(tunnel states show black until slice 6). Kills the float + input float for portals.
4. **Input/camera yaw freeze** (§3.6) + sounds (§3.7) — resolve Wave ids first.
5. **Unify login + death + logout** (§3.5, §4) — de-dup readiness/recenter; logout command + `0xF653`.
6. **`PortalTunnelView`** (the swirl sub-viewport) — **prerequisite: the asset-id cdb trace (§8).** Slot it
into the tunnel states. Heaviest, asset-dependent; last so everything else ships without it.
## 8. Open prerequisites / questions (resolve during build, not blocking the spec)
- **Swirl dat asset ids** — cdb-trace `CPhysicsObj::makeObject` on a live retail teleport to capture the
resolved `GetDIDByEnum(0x10000001,7)` / `(0x10000002,7)` ids. Gates slice 6.
- **EnterPortal / ExitPortal Wave ids** — from the dat SoundTable (the handoff's `0x6A/0x6B` are creature
sounds in our enum). Gates slice 4's audio.
- **`GetAnimLevel` curve** — cdb read of the 1024-entry table, else smoothstep (register row 2).
- **Death trigger confirmation** — confirm ACE sends a teleport (`0xF751`/position-jump) the controller already
catches on death; the large-jump fallback covers it regardless.
- **Tunnel exit detail** — the frame-120 + min/max-continue compound exit was medium-confidence; tune to a live
trace (register row 3).
## 9. Out of scope (deferred)
- **Char-select / re-login UI.** acdream has no login or character-select screen — login is a synchronous
auto-select at startup; the only exit today is window-close. Faithful logout-to-char-select requires building
that screen + session-teardown-and-restart + re-entry. This pass builds the logout **animation + `0xF653` +
clean disconnect** only; the return-to-char-select UI is its own future feature (register row 4 tracks the
stopgap).
- **Work item C** (FPS leak after teleports) — separate perf investigation, unaffected by this design.
- **#145 Slices 46** (invisible Option-B completeness) — unrelated background work.