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