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>
21 KiB
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:
- Visual cover = full retail TAS (fade → portal tunnel → fade), not a simplified fade.
- Readiness gate = hold outdoor until the physics landblock is loaded (folds Work item A / #145 residual in).
- Unification = all four entry points (login / logout / death / portal) this pass.
- Tunnel swirl = resolve the dat asset + build the literal 3D swirl this pass (net-new sub-viewport infra).
- 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:
- Camera floats to the destination — the old camera is snapped to a placed-but-unstreamed spot and the world materializes around it.
- Feels too long / wrong vs retail — there is no animation pacing the wait; it is a raw snap plus a visible streaming-in.
- 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 atTAS_TUNNEL(NOT thelogOffRequestedWORLD_FADE_OUTpath, which is logout-only — retail shares thelogOffRequestedcode between logout and self-destruct, but client-observed death arrives as a teleport). - Sounds:
Sound_UI_EnterPortalinBeginTeleportAnimation(218903);Sound_UI_ExitPortalatTAS_TUNNEL_FADE_OUT → WORLD_FADE_IN(219745). (The0x6A/0x6Bids 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::SetLockUIsetsoptions2_bit0x1000000for the whole animation;SetTeleportInProgress(1)also bumps a UI busy-count (IncrementBusyCount, 361733). - Exit: at
WORLD_FADE_INcompletion →SendLoginCompleteNotification(367516) (sends GameAction0xA1to 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
UpdateCamerakeeps 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. PerTick(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.worldReadyis supplied by the controller (the arrival hold). It emitsPlacewhen leavingTUNNEL(world loaded) andFireLoginCompleteatOFF. -
TeleportFlowController(AcDream.App.World). Owns oneTeleportAnimSequencer+ the existingTeleportArrivalController. Each frame: ask the arrival controller whether the destination is ready, feed that asworldReady, 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 onPlaceinvoke the existing placement (PlaceTeleportArrival), onFireLoginCompletesend GameAction0xA1. It is the single owner of "a teleport is happening"; the four entry points all call into it. -
TeleportFadeOverlay(AcDream.App.UI) — aUiRootchild (last, top-most among retail-UI) that fills the screen with(0,0,0,FadeAlpha)viaTextRenderer.DrawRectinside the existingUiHost.Drawpass (GameWindow.cs:8885). No new GL infra. -
PortalTunnelView(AcDream.App.Rendering) — net-new: an offscreen 3D sub-scene rendering the resolved swirlCPhysicsObjat 40 fps with the randomized camera spin, blitted to the screen duringShowTunnel. 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
TeleportFadeOverlayalpha-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 (bpCPhysicsObj::makeObject) to capture the concreteGetDIDByEnum(0x10000001,7)/(0x10000002,7)dat ids (§8). DuringShowTunnelthe world is not drawn (sequencer drives this), matching retailSmartBox::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). BecauseAddLandblockregisters terrain + collision cells + portals atomically (PhysicsEngine.cs:70),ContainsKey⟹ the collision mesh is present and resolvable. TeleportArrivalRules.Decidegains anoutdoorLandblockReadyinput; the outdoor branch returnsReadyiff the destination landblock is loaded, elseNotReady(hold). Indoor still gates onIsSpawnCellReady(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.Tickruns unconditionally with the observer pinned to the destination (GameWindow.cs:7420-7531), and the #145RemoveLandblockof the stale source clearsCurrCellso the dungeon-streaming gate ExitDungeonExpands to the destination (PhysicsEngine.cs:84-95). The gate flipsReadythe same frameAddLandblockfires (_teleportArrival.Tickruns 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.Countand confirms on a0xC98C…-style edge arrival that the gate flipsReadybefore placement (pair withACDREAM_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.ApplyDeltawritesYaw. - 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)
- Fade via alpha overlay, not near-plane collapse — adaptation; visually identical (§3.3).
GetAnimLevelease approximated as smoothstep — approximation; retire if the 1024-entry table is resolved by a cdb read (§8).- Tunnel-swirl camera/rotation parameters approximated — approximation; the exact
RandDoubleangular range + the frame-120 exit detail were medium-confidence in the decomp (§8); tune to match a live trace. - 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:
TeleportAnimSequenceragainst §2.1 golden timings — each transition fires at the right elapsed time; per-entry start states;worldReadygating ofTUNNEL;MAX_CONTINUEforce; the event sequence (Enter sound → Place → Exit sound → LoginComplete). - Readiness conformance:
TeleportArrivalRules.Decidetruth table incl. the newoutdoorLandblockReadyaxis (outdoor-not-loaded →NotReady; outdoor-loaded →Ready; indoor unchanged; impossible →Impossible). - Apparatus (task #4): confirm the gate flips
Readybefore placement on an edge arrival (§3.4); re-run thelaunch5.log0xC98Cscenario — 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)
TeleportAnimSequencer(pure Core) + unit tests — no wiring yet.- Readiness gate (
IsLandblockLoaded+Decidechange) + apparatus probe + thelaunch5.logre-test. (Lands the #145 residual / Work item A fix independently of the visuals.) TeleportFlowController+TeleportFadeOverlay— wire portal through the sequencer with the fade (tunnel states show black until slice 6). Kills the float + input float for portals.- Input/camera yaw freeze (§3.6) + sounds (§3.7) — resolve Wave ids first.
- Unify login + death + logout (§3.5, §4) — de-dup readiness/recenter; logout command +
0xF653. 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::makeObjecton a live retail teleport to capture the resolvedGetDIDByEnum(0x10000001,7)/(0x10000002,7)ids. Gates slice 6. - EnterPortal / ExitPortal Wave ids — from the dat SoundTable (the handoff's
0x6A/0x6Bare creature sounds in our enum). Gates slice 4's audio. GetAnimLevelcurve — 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.