# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22) **Status:** approved design, pre-implementation. **Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic). **Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation hypothesis is **refuted** below by direct measurement). > The fix makes the teleport **foundation** fast and correct first, then covers the short transit with a > retail-style fade. This is explicitly NOT the reverted Slice-2 hold: that band-aided slow streaming; > this fixes the streaming-apply so the cover is polish over a fast load, not a crutch over a slow one. --- ## 1. The problem, as measured (not hypothesized) A live capture (`ACDREAM_PROBE_TELEPORT=1`, four teleports as `+Je`) plus the ACE server log produced hard numbers that overturn the handoff's central hypothesis. **The `tp-probe` timeline for one outdoor teleport (dest landblock `0xA9B4`):** | Event | Timestamp (ms) | Δ from placement | Note | |---|---|---|---| | `PLACED` | 961809984 | 0 | placed the SAME tick as arrival — on an **empty** world (`lbs=0`) | | `BUILD 0xA9B4FFFF` | 961810156 | **+172 ms** | worker built it almost immediately; **`waited=0ms`** (no lock contention) | | `APPLY 0xA9B4FFFF` | 961824328 | **+14,344 ms** | render thread took **14.3 s** to apply it | The second outdoor teleport (`0xC6A9`) showed the same shape: `BUILD +250 ms`, `APPLY +10.7 s`. Across the whole session the worst `_datLock` `waited` was **74 ms** (one login landblock); every teleport-destination build logged `waited=0ms`. **Three distinct root causes (NOT one):** 1. **`_datLock` starvation — REFUTED.** The worker is never meaningfully blocked (`waited≈0`). The bottleneck is the render-thread **APPLY**, not the worker BUILD. `BuildLandblockForStreaming` (`src/AcDream.App/Rendering/GameWindow.cs`, the `lock(_datLock)` at the build site) is fast and uncontended. 2. **APPLY throughput — the "long transition."** A built landblock sits in the streamer outbox while the render thread, saturated by the synchronous unbounded CreateObject flood (`WorldSession.Tick`, `src/AcDream.Core.Net/WorldSession.cs:598-607`, drains the entire inbound queue per frame) and metered to `MaxCompletionsPerFrame = 4` (`src/AcDream.App/Streaming/StreamingController.cs:90`, applied in `DrainAndApply`), crawls through it. That is the 10–14 s the user feels. 3. **Resolve-against-empty-world — the "dropped at the wrong position."** Outdoor teleports place immediately on an unloaded world (`TeleportArrivalRules.Decide` returns `Ready` for outdoor; `src/AcDream.App/World/TeleportArrivalController.cs:134-142`), so for the whole 10–14 s window the per-frame swept-transition cell-march runs against nothing and emits a `result.CellId` with the landblock-X byte **zeroed** (`0x00B40039` instead of `0xA9B40039`). The outbound encoder trusts that cell id verbatim (the #107 fix, `src/AcDream.App/Rendering/GameWindow.cs:7794-7807`), so ACE receives a move naming the wrong landblock and rejects **every** one (`MOVEMENT SPEED` → `failed transition` in the ACE log). The player is desynced for the entire window. 4. **`PortalSpace` input-freeze — the "stops at the portal" (separate).** `OnTeleportStarted` sets `PlayerState.PortalSpace` (`GameWindow.cs:5582`); `PlayerMovementController.Update` early-returns a zero-movement result (`src/AcDream.App/Input/PlayerMovementController.cs:862-876`), freezing both local prediction and the outbound send. Independent of streaming. **What retail actually does (verified — ACE `Player_Location.cs:679-760`):** place the position immediately, set `Hidden = true; IgnoreCollisions = true`, poll every 0.1 s until `CurrentLandblock.CreateWorldObjectsCompleted`, then clear both. No input freeze, no position hold — collision/visibility are suppressed until the destination is resident, then the player materializes. The retail *client* covers the same interval with the portal-tunnel animation while physics keeps ticking. **Out of scope here (FPS):** the user observed 30–70 fps outdoors. This was a **Debug** build, which is not representative. The general FPS question is deferred to its own measure-in-Release pass (see §6). --- ## 2. Target experience (approved) A **retail tunnel cover**: starting a teleport plays a cover animation that hides the (now-short) load; the player is "in transit" — not moving in the world — and pops out at the destination once it is resident. Fidelity for this spec is a **fade cover** (fade-out → hold → fade-in), reusing the dormant `TeleportAnimSequencer`. The authentic 3D portal-swirl is deferred to a later polish pass. This single model resolves all three teleport symptoms at once: - The transit state means **no movement resolve runs against an empty world** → the cell frame cannot corrupt (fixes the wrong-position desync). - The fade **covers** the transit → the awkward freeze-in-place is gone (fixes "stops at the portal"). - Materialization is gated on the destination being **resident**, which the foundation fix makes fast (fixes the "long transition"). --- ## 3. Architecture & new sequence Replaces "place-immediately + `PortalSpace` freeze." Phases: 1. **TRANSIT-IN** — `OnTeleportStarted` (`0xF751`) drives `TeleportAnimSequencer` into fade-out → cover. Input and outbound movement are suppressed *for the duration of transit*, but the fade covers it. No movement resolve runs. 2. **AIM** — `OnLivePositionUpdated` (destination `UpdatePosition`) drops the stale source landblock, recenters streaming on the destination, pre-collapses if the destination is a sealed dungeon, and begins the arrival hold. The destination load is enqueued by the normal streaming `Tick` (unchanged; the probe confirmed `ENQ` fires correctly during the hold). 3. **PRIORITY-APPLY** — when the destination landblock's completion lands in the streamer outbox, it is applied immediately (out-of-band of the 4/frame budget), so the player's cell becomes resident in ~hundreds of ms instead of 10–14 s. 4. **READY** — the arrival readiness flips when the player's **own** destination landblock terrain (+ the EnvCell struct for an indoor destination) is resident. Outdoor changes from "place immediately" to "hold-until-terrain-resident." 5. **MATERIALIZE** — `PlaceTeleportArrival` resolves against the now-resident destination (grounds correctly; never takes the `NO-LANDBLOCK` verbatim branch), seeds the body's (cell, local) frame, fades in, exits transit → `InWorld`, sends `LoginComplete`. ### Components - **A — Priority-apply** *(`src/AcDream.App/Streaming/StreamingController.cs`, small/surgical)* `StreamingController` gains a settable "priority landblock id" (the active teleport target). In `DrainAndApply`, when a priority id is set, its completion is located and applied first/out-of-band so the player materializes as soon as the worker finishes it (already ~170 ms). The id is cleared on materialize. No change to the inbound network drain. Apply remains render-thread-only. - **B — Arrival controller** *(`src/AcDream.App/World/TeleportArrivalController.cs` + `TeleportArrivalRules`, exist)* Outdoor readiness changes from unconditional `Ready` to **hold-until-terrain-resident**, using a **correctly-keyed** residency check (the `EncodeLandblockId` 0xFFFF form — the bug fixed at `c880973` and then reverted with Slice 2; do NOT reintroduce the `& 0xFFFF0000` raw-key mismatch). The controller drives the TAS transit and materializes on `Ready`, or loud-fails on `Impossible`/timeout. The ~10 s frame-count timeout is **retained as a loud safety net** (a real failure signal — worker crash / corrupt dat / OOB coords) but should now rarely fire because residency is fast. - **C — Fade cover** *(`src/AcDream.Core/World/TeleportAnimSequencer.cs`, exists, 29 tests + a render overlay)* Wire the dormant 7-state TAS to drive a full-screen fade overlay during transit, using its existing `ComputeFadeAlpha`. The overlay is a render-layer addition (a screen-space quad whose alpha the TAS drives). The authentic 3D swirl is explicitly NOT built here. - **D — Cell-march hardening** *(`src/AcDream.Core/Physics/` — the swept-transition cell-march)* The swept-transition path must **preserve the body's landblock id** (never emit `lbX=0`) when the swept position isn't covered by a resident landblock — return the body's seeded (last-known) cell rather than a partially-derived one. `ResolveCellId` (`PhysicsEngine.cs:330-351`) already preserves its fallback; the zeroing is in the swept `Transition`/`SpherePath` cell update, whose exact site is identified and fixed during implementation. Defense-in-depth: with the transit model the player never moves against an empty world on the normal path, but this also clips the #145 edge-arrival residual. --- ## 4. Why this is not the reverted band-aid The reverted Slice-2 hold (`docs/research/2026-06-21-teleport-foundation-handoff.md`) was a band-aid because (a) the foundation was slow, so it waited ~10 s, and (b) it force-placed on `NO-LANDBLOCK` after the timeout. This design is different on both counts: - **(a) The foundation is fixed.** Priority-apply makes the player's destination resident in ~hundreds of ms (the worker build is already ~170 ms; the only thing that was slow was the metered render-thread apply, which we now bypass for the player's own landblock). - **(b) Placement is on a real, grounded landblock.** Materialization resolves against a resident destination — never the `NO-LANDBLOCK` verbatim branch — so the cell frame is correct and outbound movement is accepted by ACE. The fade is the retail cover over a now-fast load, which is exactly what the handoff said is legitimate "(and only then) a retail-style visual cover is polish, not a crutch." --- ## 5. Error handling - **Destination never becomes resident** (worker crash / corrupt dat / OOB coords): the frame-count timeout fires, force-materializes, and logs loudly (it is a genuine failure, not normal flow). With component D, even a forced placement does not corrupt the outbound frame. - **Impossible indoor claim** (cell id outside `LandBlockInfo.NumCells`): the existing `IsSpawnClaimUnhydratable` → `ArrivalReadiness.Impossible` short-circuit is retained. - **Re-sent server position mid-transit**: `BeginArrival` is server-authoritative and resets the hold (existing behavior). --- ## 6. Scope — what this spec does NOT do - **CreateObject-flood timeslicing (the deferred "#2").** Even after the player materializes onto correct terrain, the town's *objects* (buildings, NPCs) keep flooding in synchronously, causing post-materialization pop-in and an FPS sag for a few seconds. Bounding `WorldSession.Tick` per frame is a real improvement that also helps general FPS, but it touches the inbound-drain path and is not strictly a *teleport* bug. Filed as a follow-up; **measure in Release first.** - **The authentic 3D portal-swirl** (fade cover ships now). - **General outdoor FPS** (30–70 in Debug — re-measure in Release before treating it as a regression/leak; the AP-48 unbounded-entity-map leak is a separate, already-registered concern). --- ## 7. Testing & acceptance **Unit (per layer):** - Priority-apply ordering — a priority id's completion is applied before the per-frame budget is spent (`tests/AcDream.Core.Tests/Streaming/`). - Outdoor + indoor readiness decision — outdoor now holds until terrain-resident; correctly-keyed lookup (`tests/AcDream.App.Tests/World/` + `tests/AcDream.Core.Tests/`). - Cell-march landblock preservation — a swept step whose position is over no resident landblock returns the seeded cell (correct `lbX`), never `lbX=0` (`tests/AcDream.Core.Tests/Physics/`). - The `TeleportAnimSequencer` keeps its 29 existing tests green. **Live acceptance (re-run the `tp-probe`):** - The destination-LB `APPLY` lands within ~1–2 frames of its `BUILD` (was +10–14 s). - The ACE log shows **no** `00B4…` / `MOVEMENT SPEED` / `failed transition` lines after a teleport. - Visual: a covered (faded) transition that pops out onto real, grounded terrain — no run-in-place stutter, no desync. - The death→lifestone building keeps its collision (component A makes the dat-static building's landblock resident before the player materializes; component D closes the cell-resolution residual). **Probe lifecycle:** the `tp-probe` (`PhysicsDiagnostics.ProbeTeleportEnabled` + the 5 log points) is the acceptance apparatus. It stays until the fix is verified, then is removed (it is tagged `REMOVABLE`). If it proves durably useful, promote it instead of removing — decide at verification time. --- ## 8. Divergence register impact - **Retire** the "outdoor teleport places immediately because streaming doesn't progress during a hold" divergence (the comment + behavior at `TeleportArrivalController.cs:134-142` / `GameWindow.cs:5519-5523`): the premise was measured false (streaming DOES progress; only APPLY was slow). The new transit-until-resident model is retail-faithful, so this retires a deviation rather than adding one. - **Add** a row for the **fade cover instead of the authentic 3D portal-swirl** (an approximation deviation, sibling to AP-49's fade-curve note). Cite `gmSmartBoxUI` tunnel rendering as the unported mechanism. - **Add** a row for the deferred CreateObject-flood timeslicing if post-materialization object pop-in is observed (it is an adaptation/stopgap until #2 lands). All register edits land in the same commits as the behavior they describe. --- ## 9. Retail / reference anchors - ACE teleport (place + `Hidden`/`IgnoreCollisions` + poll `CreateWorldObjectsCompleted`): `references/ACE/Source/ACE.Server/WorldObjects/Player_Location.cs:679-760`. - TAS decomp + the 7-state machine: `docs/research/2026-06-21-teleport-issues-handoff.md`, `src/AcDream.Core/World/TeleportAnimSequencer.cs`. - Outbound (cell, position) self-consistency (#107): `src/AcDream.App/Rendering/GameWindow.cs:7783-7807`. - Streaming threading + apply: `src/AcDream.App/Streaming/StreamingController.cs`, `src/AcDream.App/Streaming/LandblockStreamer.cs`.