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

21 KiB
Raw Blame History

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:

    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.