docs(teleport): spec — retail residency + fade-cover (refutes _datLock starvation)

Live tp-probe capture refuted the handoff's _datLock-starvation hypothesis:
worker BUILD is fast + uncontended (waited=0ms); the 10-14s 'long transition'
is render-thread APPLY latency, and 'dropped at wrong position' is the
per-frame resolve corrupting the outbound cell frame (lbX zeroed) while the
player sits on an empty world. Design: priority-apply the player's dest
landblock + hold-until-resident behind a retail fade cover (reuse the dormant
TeleportAnimSequencer) + cell-march landblock-id hardening. Foundation-first,
not a hold over slow streaming. Flood-timeslicing + 3D swirl deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 11:18:16 +02:00
parent f8cda7e86d
commit 31602c6a24

View file

@ -0,0 +1,227 @@
# 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 1014 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 1014 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 3070 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 1014 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** (3070 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 ~12 frames of its `BUILD` (was +1014 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`.