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>
14 KiB
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):
-
_datLockstarvation — 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, thelock(_datLock)at the build site) is fast and uncontended. -
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 toMaxCompletionsPerFrame = 4(src/AcDream.App/Streaming/StreamingController.cs:90, applied inDrainAndApply), crawls through it. That is the 10–14 s the user feels. -
Resolve-against-empty-world — the "dropped at the wrong position." Outdoor teleports place immediately on an unloaded world (
TeleportArrivalRules.DecidereturnsReadyfor 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 aresult.CellIdwith the landblock-X byte zeroed (0x00B40039instead of0xA9B40039). 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 transitionin the ACE log). The player is desynced for the entire window. -
PortalSpaceinput-freeze — the "stops at the portal" (separate).OnTeleportStartedsetsPlayerState.PortalSpace(GameWindow.cs:5582);PlayerMovementController.Updateearly-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:
- TRANSIT-IN —
OnTeleportStarted(0xF751) drivesTeleportAnimSequencerinto fade-out → cover. Input and outbound movement are suppressed for the duration of transit, but the fade covers it. No movement resolve runs. - AIM —
OnLivePositionUpdated(destinationUpdatePosition) 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 streamingTick(unchanged; the probe confirmedENQfires correctly during the hold). - 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.
- 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."
- MATERIALIZE —
PlaceTeleportArrivalresolves against the now-resident destination (grounds correctly; never takes theNO-LANDBLOCKverbatim branch), seeds the body's (cell, local) frame, fades in, exits transit →InWorld, sendsLoginComplete.
Components
-
A — Priority-apply (
src/AcDream.App/Streaming/StreamingController.cs, small/surgical)StreamingControllergains a settable "priority landblock id" (the active teleport target). InDrainAndApply, 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 unconditionalReadyto hold-until-terrain-resident, using a correctly-keyed residency check (theEncodeLandblockId0xFFFF form — the bug fixed atc880973and then reverted with Slice 2; do NOT reintroduce the& 0xFFFF0000raw-key mismatch). The controller drives the TAS transit and materializes onReady, or loud-fails onImpossible/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 existingComputeFadeAlpha. 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 emitlbX=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 sweptTransition/SpherePathcell 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-LANDBLOCKverbatim 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 existingIsSpawnClaimUnhydratable→ArrivalReadiness.Impossibleshort-circuit is retained. - Re-sent server position mid-transit:
BeginArrivalis 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.Tickper 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), neverlbX=0(tests/AcDream.Core.Tests/Physics/). - The
TeleportAnimSequencerkeeps its 29 existing tests green.
Live acceptance (re-run the tp-probe):
- The destination-LB
APPLYlands within ~1–2 frames of itsBUILD(was +10–14 s). - The ACE log shows no
00B4…/MOVEMENT SPEED/failed transitionlines 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
gmSmartBoxUItunnel 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+ pollCreateWorldObjectsCompleted):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.