diff --git a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md new file mode 100644 index 00000000..d646e873 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md @@ -0,0 +1,326 @@ +# 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) 2–5s 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 4–6** (invisible Option-B completeness) — unrelated background work.