acdream/docs/superpowers/specs/2026-06-13-dungeon-support-design.md
Erik 70c559c1ba docs(G.3): gate correction — G.3a core landed; #95 CONFIRMED LIVE (not superseded)
The G.3a visual gate ran a real PlayerTeleport into the 0x0007 dungeon. The core
hold+place worked (grounded on the dungeon floor, no ocean) and Bug A (landblock-
prefix mis-stamp) is fixed (2ce5e5c). But the gate proved #95 (portal-graph
visibility blowup, ~9.1M instances/frame) is LIVE under the current pipeline — my
plan's "likely superseded / conditional G.3b" premise was wrong. Spec §2.5/§3.2 +
ISSUES #133/#95 updated: G.3b (grab_visible_cells stab_list bounding) is REQUIRED,
needs its own grounding/brainstorm. Also noted: the render-only hydration decouple
was reverted (e7058ca) for making the player invisible at Holtburg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:30:43 +02:00

455 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase G.3 — Dungeon Support (Design Spec)
> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`.
> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5
> exit-gate. M2 (CombatMath) stays deferred until this lands.
> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) +
> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed
> below).
> **Supersedes** the §12 port-plan of
> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md):
> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility
> BFS, multi-cell transit) already shipped and power the building/cellar demo.
> r09 stays the **retail contract reference** for the wire formats, the
> EnvCell/CellPortal layout, and the recall taxonomy.
---
## 0. TL;DR
Dungeons don't work because of **one timing+placement gap on one code path**,
not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the
Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock`
present, all-zero heights) + 71 EnvCells + no buildings — it already streams,
renders, and collides through the existing pipeline. The teleport-arrival
handler snaps the player **before** that landblock has streamed in, so Resolve
falls back to the resident Holtburg blocks and lands the player in ocean.
The fix is retail's own shape: **hold the player in portal space until the
destination cell is hydrated, then place into the EnvCell** — reusing the
#107/#111 login machinery — and then layer retail's portal-tunnel visual
(`TeleportAnimState`) on top. We ship it in four installments, gated by one
visual acceptance test.
---
## 1. Corrected root cause (verified)
### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified)
A prior research pass assumed dungeon landblocks have no `LandBlock` record, so
`LandblockLoader.Load` returns null and the whole streaming/render/physics
pipeline needs terrain-less support. **A direct dat probe
(`DungeonLandblockDatProbeTests`, committed) refutes that:**
```
0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat)
LandBlockInfo: NumCells=71, Buildings=0, Objects=0
EnvCells 0x0100.. present (the 71 dungeon rooms)
0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114
```
A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain
height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load`
returns a valid flat landblock; the terrain mesh builds a flat plane;
`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing
pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon`
(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the
single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon
landblocks without a teleport — so "multi-landblock dungeon LOD" is moot).
### 1.2 The real blocker: teleport TIMING + PLACEMENT
`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`)
detects teleport arrival as **any** player position update while in PortalSpace
(correct, per #107), then **unconditionally**:
1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`).
2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap
the player (`:4927-4931`) — **before the destination landblock has streamed in**.
3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends
`LoginComplete` (`:4953-4959`).
Because the dungeon landblock isn't resident yet, Resolve can't find the
destination cell, falls back to an **outdoor scan against the still-resident
Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local
`(30,60)` maps into the block south of the A9B4 spawn). Streaming then shifts
the frame out from under the player → they slide south into ocean. ACE logs the
matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …`
chain (captured in `launch-dungeon-diag.log`).
**There is no hold-until-hydration on the teleport-arrival path.** The #107
*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate;
the teleport path doesn't.
---
## 2. Grounded seam facts (the design rests on these)
All five verified against current code this session (high confidence).
### 2.1 Teleport-arrival + PortalSpace FSM
- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)`
sets `_playerController.State = PlayerState.PortalSpace`, freezing movement.
- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns
a zero-movement result while `State == PortalSpace` — **PortalSpace already
doubles as the input-freeze.** It can equally serve as the hydration-wait gate.
- Exit is **only** via the arrival detection in `OnLivePositionUpdated`
(`:4880`). No timeout, no cell-hydration gate today.
### 2.2 #107/#111 login machinery (directly reusable)
- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor
(`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId)
is not null` (the cell's physics BSP has hydrated).
- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat
`LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is
`>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast
(distinguishes a bogus claim from a not-yet-streamed one).
- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for
terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`).
No timeout today (login can afford to wait forever; teleport cannot — see §5).
- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when
`snapDiag (zero-delta) && adjustedFound && indoor`, place via
`WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own
physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642),
cell-local, nearest to the reference Z. Returns `null` if the cell isn't
hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**).
- **The teleport-arrival Resolve call is already the same shape as login entry.**
The gate only needs to sit in front of it; no change to Resolve or
WalkableFloorZNearest. (Both already key on the full prefixed cell id +
indoor/outdoor.)
### 2.3 Streaming far recenter (works as-is)
- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the
near/far Chebyshev window **from scratch** around the new center — a 42 km jump
is treated identically to a 1-step move. No incremental-movement assumption.
- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4)
results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU
upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` +
EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate
a 5×5 near window; physics ready +1-2 frames.
- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2
demote, FarRadius+2 unload), so the player isn't instantly stranded.
- **New code needed:** reuse the #107 login-gate **terrain-ready signal**
`_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination
terrain landblock has applied) — no separate "landblock applied" query is
required. Plus dest-coord validation (reject out-of-world coords — a malformed
portal dest would otherwise leave the player in an invisible, unloadable
landblock).
### 2.4 EnvCell hydration coupling (latent landmine — decouple)
- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both
`BuildLoadedCell` (the portal-visibility node) **and**
`_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render
guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty
(`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys)
silently gets **no visibility node and no collision**, even if it has walkable
physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null
→ continue` (silently skips it) → fall-through-floor.
- A normal dungeon *room* has textured walls → non-empty submeshes → the guard
passes, so this is **probably not the meeting-hall blocker** — but it is a real
correctness landmine for any geometry-less collision cell, and decoupling is
cheap and retail-correct (physics/visibility do not depend on visible geometry).
**Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and
`BuildLoadedCell` on `cellStruct != null`, independent of the render submesh
count. (`CacheCellStruct` already early-returns on null BSP internally —
`PhysicsDataCache.cs:172` — so moving it out is safe.)
### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded)
- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)**
trace, `visibleCells` per cell exploded to 135-145 with spurious cells from
landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB
`EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache.
- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4:
"per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current
flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera
cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has
**enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are
ever processed"). Since AC dungeons are single-landblock, that confinement is
*correct*, and the cross-landblock 135-cell blowup **structurally cannot
reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for
the meeting hall).
- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted
path; the current pipeline looked bounded. Treated #95 as likely superseded.
- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual
gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The
core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean),
but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per
frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx`
interior re-requests → the dungeon renders as "thin air." **#95 reproduces under
the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the
`0x0007` dungeon (the grounding agent's "still live" verdict was correct; this
doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The
retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878)
stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`.
---
## 3. The plan (Approach C — phased full-G.3)
Each installment lands a **complete retail behavior** (the BR-2 half-port
lesson). The visual gate sits as early as possible, right after the core.
### 3.1 G.3a — Core teleport-into-dungeon (the blocker)
**Goal:** teleporting into the meeting-hall dungeon lands the player standing in
the dungeon cell, on the floor, with walls blocking — no ocean, no ACE
`failed transition` spam.
**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`):
- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival`
`(destPos, destCellId, deadline)`.
- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the
god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and
calls its per-frame `Tick`; `GameWindow` keeps only the wiring.
- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve).
**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):**
1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are
in-world; recenter streaming + prioritize-load the dest landblock (existing
path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete`
immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for
assets to send it).
2. Each frame in `Holding`, evaluate the **readiness predicate**:
- `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place
via the safety-net demote (loud log), exit PortalSpace.
- `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud
log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.)
- `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))`
→ ready: go to 3.
- else stay frozen, retry next frame.
3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`.
Because the cell is now hydrated, Resolve takes the #111 validated-claim branch
`WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity
+ controller (existing `:4935-4939` code), exit PortalSpace, resume input.
**Readiness predicate — reuse the #107 login triplet (no new query).** The
hold gates on exactly the three checks the login auto-entry gate already uses
(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos,
destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is
not null` (destination terrain applied) ∧ (outdoor cell OR
`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits
an impossible claim to immediate placement. This reuses proven, validated code
rather than introducing a parallel "landblock applied" query.
**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose
`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport
hold rather than recenter to a phantom block.
**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of
the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate
each on its own non-null precondition.
**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles
#95 (does the flood blow up?) and the hydration coupling (does collision work?).
### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13)
**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this
is the next blocker, not a conditional follow-up. The dungeon will not render
until the portal-visibility flood is bounded to the dungeon's own cell adjacency.
**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with
`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent
EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a
radius / never the whole resident cell set. This is a render-pipeline change in
`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own
grounding + brainstorm before implementation (verify the dat carries the stab_list
and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read;
decide how it composes with the outdoor-root look-in floods). **NOT a wing-it
inline fix.**
**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed
(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y
coordinate frame may cause the per-tick membership/landblock resolution to drift
(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it
persists; if so, fold the dungeon-coordinate membership handling into G.3b's
grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95).
### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`)
**Goal:** the retail portal-space transition, ported faithfully (user decision
2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the
named-retail decomp where this FSM actually lives.
**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line
218888) + the per-frame FSM (`219405-219774`). States:
`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE →
TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a
`UIElement_Viewport` rendering the tunnel scene (creature-mode objects +
`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the
`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`).
**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold
state's exit gates on the same readiness predicate as G.3a** — retail's loading
visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the
visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it
in the tunnel viewport + the fade FSM, exit-gated identically.
**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM →
pseudocode (durations, fade math, viewport scene construction) → port → test.
Detail deferred to the G.3c implementation phase; this spec fixes the design
(states, transitions, the readiness-gated hold) + the oracle pointers.
### 3.4 G.3d — Recall game-actions
Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone
0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`,
`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends
the request; the server validates, plays the recall animation, then drives the
**same** `PlayerTeleport → UpdatePosition` arrival flow.
Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a
teleport with no portal-click choreography; (2) completes the recall UX (keybinds
exist; the wire sends + return handling did not). Wire through the existing
command bus.
---
## 4. Data flow (the teleport happy path)
```
1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input
[G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)]
2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb
→ prioritize-load dest lb → re-send LoginComplete
3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace):
ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))
- not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE]
- impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log
- timeout → force-snap + loud log + leave PortalSpace
4. READY → Resolve(destPos, destCell) → #111 validated-claim branch
→ WalkableFloorZNearest places on the EnvCell floor
→ SetPosition(entity + controller) → exit PortalSpace, resume input
[G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off]
```
(ACE server send-order, for reference — `Player_Location.Teleport:686`:
`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) →
`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real
`UpdatePosition``OnTeleportComplete` after `CreateWorldObjectsCompleted`.)
---
## 5. Error handling
| Failure | Handling | No-workaround rationale |
|---|---|---|
| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking |
| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row |
| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change |
The timeout is the one judgment call: holding forever on a never-hydrating
landblock would soft-lock the client. The chosen behavior **fails loudly and
visibly** (force-snap + log), which is the opposite of a symptom-masking grace
period — it makes a broken teleport obvious rather than hiding it. It is recorded
as a deliberate adaptation (retail loads synchronously; async streaming has no
direct analog).
---
## 6. Testing & acceptance
### 6.1 Headless / unit
- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path;
impossible-claim immediate reject; timeout force-snap; ready-predicate gating
(fake `IsLandblockApplied` / `IsSpawnCellReady`).
- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty
physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`.
- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller
phase transitions + input-gate flips.
- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells.
- G.3c: `TeleportAnimState` FSM transition test (state sequence + the
readiness-gated `TAS_TUNNEL` hold-exit).
- G.3d: recall-builder byte tests (opcode + empty payload, per builder).
### 6.2 Visual gate (the acceptance test — after G.3a)
Teleport into the meeting-hall dungeon via the portal:
- Player stands **in the dungeon cell**, on the floor (not ocean, not falling).
- The dungeon renders; navigate **35 rooms**; **walls block** movement.
- **No ocean / no ACE `failed transition` spam.**
- (Implicitly) the portal flood does **not** blow up (#95 check) and collision
works in every room (hydration-coupling check).
`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the
always-on `[snap]` / `live: teleport` lines capture the chain (the
`launch-dungeon-diag.log` protocol from this session).
### 6.3 Per-installment build/test gates
Each installment: `dotnet build` green + `dotnet test` green
(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the
visual gate.
---
## 7. Retail divergence register impact
- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no
synchronous-retail analog; retail loads the cell set synchronously before
`SetPositionInternal`).
- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics
coupling; restores retail-correct independence).
- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel
viewport scene) and a documented courtesy substitute is shipped.
- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only.
---
## 8. Component boundaries (what each unit does / depends on)
| Unit | Location | Does | Depends on |
|---|---|---|---|
| `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state |
| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` |
| hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` |
| `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate |
| recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus |
`AcDream.Core` gains no GL/window dependency. The controller + FSM live in
`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core`
(pure), its streaming half in `AcDream.App`.
---
## 9. References cited
- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival),
~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748
(`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150
(`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`),
626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570
(Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`);
`StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131
(lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip);
`PhysicsDataCache.cs` 172 (null-BSP early-return).
- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) +
the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport
218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list).
- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport).
- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575`
(`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall
handlers + `Portal.ActOnUse`/`AdjustDungeon`.
- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell
/ CellPortal wire layout, recall taxonomy, the retail contract).
- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md).
- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`,
`project_physics_collision_digest`.
---
## 10. Open questions (resolved here; revisit only if the gate disagrees)
1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user
decision). Unified with the G.3a hold (the tunnel IS the hold's visual).
2. **Hold timeout/failure?** Reject impossible claims instantly
(`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling;
on timeout force-snap + loud log (fail visibly, never freeze).
3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only
dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new
streaming query).
4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell
path (identical to the cellar path that already works); the flat terrain
renders below. The gate guarantees the cell is hydrated before Resolve runs.
5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed
`stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows
the #95 blowup.