merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)

main was 65 commits ahead of this branch's fork point. Only conflict was the
divergence register: both sides appended an 'AP-32' row. Resolved by keeping
main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon
streaming, point lights) and renumbering the importer's row to AP-37; AP header
count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds
0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 16:19:15 +02:00
commit 5ac9d8c19c
53 changed files with 6691 additions and 439 deletions

View file

@ -46,6 +46,356 @@ Copy this block when adding a new issue:
---
## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
**Status:** OPEN
**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
**Filed:** 2026-06-14
**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads
the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and
**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not
moving while my character is moving" (the avatar animates/advances but the player's
actual position / camera doesn't track, or vice-versa).
**Root cause / status (hypothesis — needs investigation):** very likely a gap in the
dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) +
`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after
exit; (c) the position desync = the player controller / streaming observer disagree on the
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap.
**Files:** `src/AcDream.App/Streaming/StreamingController.cs` (`ExitDungeonExpand`, the
collapse/expand hysteresis), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated`
teleport recenter ~4912, the streaming Tick gate ~6890, the PortalSpace observer branch),
`TeleportArrivalController`. Cross-check the post-exit shadow-object/collision registration.
**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery
present), collision works, and the player position tracks correctly (no avatar-vs-camera desync).
---
## #137 — Dungeon collision incorrect at doors and wall openings
**Status:** OPEN
**Severity:** MEDIUM (movement/collision correctness in dungeons)
**Filed:** 2026-06-14
**Component:** physics — EnvCell collision (doors, portal openings, cell geometry)
**Description (user):** collision is still wrong in dungeons — **doors** and **openings in
walls** in particular. (Symptoms not fully characterized yet: likely walking through
openings that should block / blocking at openings that should pass, and door collision not
matching the door's open/closed state.)
**Root cause / status (to investigate):** dungeon collision is EnvCell-based — the cell's
collision BSP + portal openings + per-cell static objects (doors). Candidates: door
apparatus collision in EnvCells (open/closed BSP swap) not fully ported; portal-opening
(wall gap) collision geometry handled differently from buildings; the per-cell
shadow-object registration (A6.P4, see the physics digest) for dungeon EnvCell statics.
Related families: #32 (edge-slide), #116 (slide-response), the door-collision saga
(see `feedback_dedup_keys_after_cardinality_change`, `feedback_retail_per_cell_shadow_list`).
Needs a targeted repro (which door / which opening, expected vs actual) before fixing —
oracle-first per the physics digest.
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls
block — matching retail, in the 0x0007 dungeon.
---
## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone +
green floor "petals" are gone, all real dungeon decorations still render. User-approved
frozen-phase fix.
**Severity:** LOW (cosmetic; one marker in one dungeon)
**Filed/Fixed:** 2026-06-14
**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
fix placed the player at the exact saved spawn next to it.
**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`,
`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`,
using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an
**editor-only placement marker**: its `DIDDegrade` table `0x11000118` =
`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the
WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance.
Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw`
0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's
render path is extracted from **WorldBuilder**, which — being an editor — renders every cell
static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in
`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT
a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red
herring; the marker is a separate `guid=0` dat static.)
**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker
pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell
static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`).
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are
untouched. 4 new `GfxObjDegradeResolver` unit tests.
**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers;
apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per-
pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap
vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked
under the #79/#93 A7 lighting umbrella.
---
## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
**Severity:** LOW (startup-only; self-corrects)
**Filed:** 2026-06-14
**Component:** streaming — first-frame bootstrap vs the dungeon collapse
**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED
dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window.
- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse`
early (idempotent), so the expensive ocean-grid neighbour window is never enqueued
(teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).
- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag
(the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn
interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.
- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport).
- Observer robustness: during a teleport `PortalSpace` hold the observer follows the
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now
filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift
the login-hold observer off the dungeon and trip `ExitDungeonExpand`.
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering).
**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before
settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped
up; when logging in I get like 10 then it slowly increases."
**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse);
login recenter `OnLiveEntitySpawnedLocked` ~2470.
**Acceptance:** Login into a dungeon reaches steady-state FPS within ~12 s (no full-window
neighbour load/unload churn).
---
## #134 — Player "lags downward" instead of gliding along a dungeon ramp edge
**Status:** OPEN
**Severity:** LOW-MEDIUM (movement feel; not a hard traversal block)
**Filed:** 2026-06-14
**Component:** physics — slope-walk / edge-slide response
**Description:** Running up or down against a dungeon ramp's edge, the player "sort of lags
downwards" instead of gliding/sliding ALONG the ramp surface (up when running up, down when
running down). Reported in the 0x0007 Town Network dungeon ramp after #133.
**Root cause / status:** Surfaced (not caused) by the #133 connector-cell physics
registration (`3e006d3`): the ramp connector cell's collision is now fully resident in the
physics graph, so the slope-walk / edge-slide response on it is exercised for the first time.
"Lag down" suggests the slide velocity is projected toward gravity rather than along the
contact plane (the slope tangent). Likely the retail edge-slide / slope-slide response is
incomplete — see #32 (retail edge-slide/cliff-slide/precipice-slide incomplete) and the
AP-6 / TS-1 / TS-4 slide rows in the divergence register. NO band-aid — port the retail
slide-response.
**Files:** `src/AcDream.Core/Physics/` (slide-response in TransitionTypes / BSPQuery); ramp
cell 0x0007014D + neighbours.
**Acceptance:** Running up a walkable ramp climbs it smoothly; running into the edge slides
along the slope (up/down per input direction), matching retail feel.
---
## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon
**Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal
space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision
2026-06-13: the indoor world isn't done while dungeons are broken; full
G.3 scope chosen). Spec: `docs/superpowers/specs/2026-06-13-dungeon-support-design.md`;
G.3a plan: `docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md`.
This is now an M1.5 exit-gate blocker, not deferred.
**PROGRESS (2026-06-13 PM — G.3a core LANDED + Bug A fixed; gate exposed #95):**
the teleport-timing root cause IS fixed. G.3a shipped the `TeleportArrivalController`
hold-until-hydration (`7947d7a`/`aca4b46`/`f22121b`) + the validated-claim
landblock-prefix fix (`2ce5e5c`, "Bug A"). Live gate proof: a real `PlayerTeleport`
into the `0x0007` dungeon held through the 46 km jump and grounded the player on the
dungeon's walkable floor (`[snap] claim=0x00070143 VALIDATED -> z=0.000`) — **no
ocean.** The "terrain-less landblock" framing was refuted earlier (dat probe: dungeon
= flat-terrain LandBlock + EnvCells). REMAINING blockers, both exposed at the gate:
(1) **#95 CONFIRMED LIVE** — the dungeon renders as "thin air" because WB-DIAG blows
up to ~9.1M instances/frame at `0x0007` (see #95); (2) **possible Bug C** — per-tick
membership may still drift in the dungeon's negative-local-Y frame (ACE `movement
pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render-only
EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the
player character invisible at Holtburg (it touched the shared building hydration
path); re-approach separately if a geometry-less collision cell ever needs it.
**NEW GAP (2026-06-13 PM — login-INTO-a-dungeon):** logging in while the saved
character is inside a far dungeon hangs at the auto-entry hold (player frozen,
no `[snap]`/`auto-entered player mode`, movement input ignored). Root: the
streaming center is set ONCE at startup to the default (`_liveCenterX/Y = centerX/
centerY`, `GameWindow.cs:1942` → "centered on 0xA9B4FFFF") and the login spawn never
recenters it; a dungeon spawn 46 km away never streams, so `IsSpawnCellReady(spawn
cell)` stays false and the #107 hold waits forever. The TELEPORT-arrival path
recenters (G.3a `TeleportArrivalController`); the LOGIN path does not. Fix shape =
recenter streaming onto the spawn landblock when the login spawn first arrives
(mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the
recenter). Pre-existing; only surfaces now that the test character can be saved in
a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon
server-side (ACE) before logging in. **FIXED 2026-06-13 (`47ae237`)** — the login
player-spawn path now recenters `_liveCenterX/Y` onto the spawn landblock (mirrors
the teleport-arrival recenter; no-op for a same-landblock Holtburg login). Verified
live: `live: login spawn — recentering streaming from (169,180) to (0,7)` → dungeon
streams → `auto-entered player mode` in the dungeon.
**✅ DUNGEON RENDERS — M1.5 milestone (2026-06-13 PM, autonomous /loop, objectively
verified).** With Bug A (`2ce5e5c`) + login-into-dungeon (`47ae237`), a live launch
into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0x00070143
VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed
transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000
(meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom).
User-confirmed: "no errors from ACE this time."
**✅ DUNGEON FPS FIXED + GREY BARRIER FIXED (2026-06-14, user-confirmed).** Two
separate causes, both resolved:
- **FPS (was 1430, now ~1000+):** AC dungeons sit adjacent in the "ocean" landblock
grid, so the 25×25 (farRadius=12) streaming window pulled ~129 neighbour dungeons +
their ~19k particle emitters / entities each frame. Fix = **collapse streaming to the
player's single dungeon landblock** when CurrCell is a sealed EnvCell (`!SeenOutside`),
with landblock-level hysteresis to stop collapse↔expand thrash. Confirmed against ACE
(`landblock.IsDungeon → return adjacents` with no neighbours): dungeons have no neighbour
landblocks, so collapsing to the one block is retail-faithful. Commits `5686050` (collapse)
+ `d9e7dd6` (hysteresis) + `2561918` (pin to CurrCell's landblock, not the position-derived
one — the negative cell-local-Y made `floor(pp.Y/192)` land one block off and unload the
REAL dungeon). Divergence register: AP-36.
- **GREY BARRIER (the "barrier above the ramp" / cellar-mouth grey):** portals-only
connector cells (ramp mouths, stair landings, cellar throats) build **0 drawable
sub-meshes**, and BOTH cell-registration gates (`BuildLoadedCell` → visibility
`_cellVisibility`, and `CacheCellStruct` → the physics cell graph) were gated on
`cellSubMeshes.Count > 0`. So a connector cell never registered → the portal flood
hit a **lookup-miss** at its opening (the un-flooded opening shows the clear/grey
colour) AND the camera eye-sweep couldn't transit through it. Fix = register EVERY
cell with a valid cellStruct for visibility + physics; only the *drawing* registration
stays gated on having sub-meshes. Commits `d90c538` (visibility) + `3e006d3` (physics
graph). The physics-graph half EXPOSED the ramp slide-response feel (now **#134**).
Three render-MATH theories (portal_side centroid, on-screen clip, near-eye projection)
were instrumented and REFUTED before the real lookup-miss cause was found — apparatus
discipline held. Render-pipeline digest updated.
Residual (filed separately): login FPS ramp **#135**; ramp slide-response **#134**; the
A7 per-vertex lighting bake (below) is the remaining "lighting off" work.
**✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The
"lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic
(`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed
(`UpdateSunFromSky`, `playerInsideCell` true) AND **2227 torch/point-lights register**. The
bug was the active-light SELECTION: `LightManager.Tick` dropped any light whose range didn't
reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 torches lit only the
~1 the player stood inside (`activeLights≈1`, rest at flat 0.2). Retail's D3D model picks the
8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader
(`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the
nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now
light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `rangeAdjust`,
`config_hardware_light` 0x0059adc, `a80061b`+) widened the pools. Ambient 0.20 is
retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring
(`CreatureMode` paperdoll renderer, not world cells).
**⚠️ REAL remaining cause — REVISED 2026-06-14 (the earlier "mis-read intensity" theory is
REFUTED).** `intensity=100` is the **REAL dat value** (raw-byte verified `00 00 C8 42` = 100.0f;
DatReaderWriter 2.1.7 parses it correctly; the garbage `cone` is MSVC `CD CD CD CD`
uninitialized fill Turbine baked into the dat — point lights never read it). **DO NOT `÷100`.**
The actual divergence is the **[HIGH] `no-static-light-burnin`**: retail bakes ALL of a cell's
reaching static lights **PER-VERTEX once** (`D3DPolyRender::SetStaticLightingVertexColors`
0x0059cfe0 → `calc_point_light` 0x0059c8b0, Gouraud-interpolated → uniform, never blown out via
the per-channel min-to-colour clamp), while we light **per-PIXEL with only the 8 nearest-to-
CAMERA lights** → bright pools near torches, dark between, and a crescent that slides as the
camera re-ranks the 8-slot list. Diagnosed via a 5-agent investigation + a clean Ghidra
decompile (the BN pseudo-C is x87-mangled). **LANDED:** the per-pixel `(1-dist/falloff_eff)`
shader ramp (`007e287`, necessary but NOT sufficient — it can't fix the per-vertex-vs-per-pixel
structure) + the GL-free `LightBake` Core (`3b93f91`: the verbatim `calc_point_light` port +
7 conformance tests). **REMAINING — the A7 integration:** add a per-vertex linear-RGB colour
attribute to the cell mesh + a bake driver keyed on `envCellId` (NOT the dedup `cellGeomId`
adjacent rooms share a geom but not their torches) + consume it in `mesh_modern.frag` for cell
draws; bound the bake's light set to the player dungeon (#133's FPS collapse already does this).
Belongs to the #79/#93 indoor-lighting umbrella; outdoor static objects + building shells still
use the per-pixel-8 path (the same spottiness — separate follow-up). **NOTE — dungeon FPS is
FIXED** (was 1430 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the
#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`).
**Severity:** HIGH (any far/dungeon teleport is unusable)
**Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal)
**Component:** physics/streaming — teleport-arrival snap vs async landblock hydration
**Symptom (user):** used the meeting-hall portal to a dungeon; "no
dungeon, just ocean (where the dungeon is placed)." ACE spams `failed
transition for +Acdream from 0x01250126 [30 -60 6.0] to 0xA9B0000E
[-32227 -26748 5.9]` … marching south through `0xA993/0xA97F/…/0xA969`
at Z≈0.9 (underwater) — the server keeps rejecting the client's bogus
outdoor movement.
**Root cause (confirmed against code + the diagnostic log
`launch-dungeon-diag.log`):** ACE correctly placed the player in the
meeting-hall dungeon cell `0x01250126` (landblock `0x0125` = (1,37)). The
acdream teleport-arrival handler (`GameWindow.cs:4877-4960`) DOES recenter
the streaming origin to (1,37) (`_liveCenterX/Y`, :4910-4912), but then
**immediately** calls `_physicsEngine.Resolve(pos=(30,-60,6.005),
cell=0x01250126)` to snap the player (:4928-4931) — BEFORE the dungeon
landblock has streamed in. The physics engine still has only the OLD
Holtburg landblocks resident (A9B4 + neighbours), so `Resolve` can't find
the dungeon cell and falls back to an OUTDOOR scan against the resident
landblocks: local (30,60) maps into A9B3 (the loaded block south of the
A9B4 spawn) → snaps to `0xA9B3000E`, terrainZ=94, indoor=False (the
`[snap]` line). The player is now at Holtburg's south edge; streaming then
shifts the frame out from under them and they slide south into ocean
(the `[cell-transit] A9B3→A9B2→…` chain mirrors ACE's failed-transition
sequence exactly).
**Fix shape (G.3):** on a far/different-landblock teleport, recenter +
HOLD the snap until the destination dungeon landblock/cell hydrates (reuse
the #107 `IsSpawnCellReady` spawn-ready gate, applied to the teleport-
arrival path instead of only login), then place into the indoor cell via
the validated-claim path (#107/#111 `SetPositionInternal` shape). Also
audit the streaming controller actually LOADS the far dungeon landblock on
recenter (the 5×5 Chebyshev window around the new center), and that the
old landblocks unload without stranding the player mid-frame-shift.
**Files:** `GameWindow.cs:4877-4960` (teleport arrival),
`PhysicsEngine.Resolve` (the outdoor fallback), the #107 `IsSpawnCellReady`
gate, `StreamingController` recenter.
**Acceptance:** teleport into the meeting-hall dungeon → the player stands
in the dungeon cell, the dungeon renders (3-5 rooms), walls block, no
ocean / no ACE `failed transition` spam.
**Apparatus:** `ACDREAM_PROBE_CELL=1` ([cell-transit]) + `ACDREAM_PROBE_VIEWER=1`
([viewer]) + `ACDREAM_WB_DIAG=1` + the always-on `[snap]`/`live: teleport`
lines capture the whole chain (`launch-dungeon-diag.log`, this session).
---
## #104 — Scene VFX particles not clipped to the PView visible cell set
**Status:** OPEN
@ -827,7 +1177,19 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
**Status:** OPEN — **explains user-observed "dungeons are broken"**
**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A
(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a
headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`,
`95d9dab`) measured `PortalVisibilityBuilder` visiting only **117 cells** per root —
already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is
the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list
bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at
the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly
resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A +
login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured
**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE
errors. The flood was never the bug. **Originally** also: explained user-observed
"dungeons are broken"
**Severity:** HIGH (blocks all dungeon navigation visually)
**Filed:** 2026-05-21
**Component:** rendering, visibility, EnvCell portal traversal
@ -3701,27 +4063,50 @@ Unverified. The likely culprits, ranked by suspected probability:
---
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [REOPENED 2026-06-11 · narrowed residual]
## #108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated]
**Status:** REOPENED (narrowed) — the broad symptom is GONE (T5 +
re-gate #2: "Yes, but…"), but a residual remains in ONE window: during
the cellar ASCENT, while the eye is still below ground level, the
upstairs exit-door opening is covered with grass — "like the ground
level rose to the top of the door … as soon as my head pops up it falls
back to ground level" (user, re-gate 2026-06-11). The original
BR-2-era diagnosis stands: grass-sweep frames render through the
OUTDOOR root (membership/viewer-cell flips outdoor mid-cellar), and the
#117 depth-gated punch then correctly refuses to punch the aperture
where terrain depth is NEARER than the door fan (eye below grade ⇒ the
visible front-facing terrain can sit between the eye and the door in
depth). The punch must STAY depth-gated (DO-NOT-RETRY) — the fix is on
the membership/viewer side (why is the root outdoor while the eye is in
the cellar stairwell below grade?). Apparatus shape: a vertical
cellar-ascent variant of the #118 exit-walk harness (drive the eye up
the stair path; log root resolution + the punch's mark-pass outcome per
step). Prior history below.
**Status:** CLOSED — user visual gate 2026-06-12 ("Yes it is fixed.")
after the terrain-backface-cull fix (`96a425a`). Root cause: terrain
drew double-sided; the grass was the grade sheet's underside seen from
a below-grade cellar eye. Membership/viewer EXONERATED by the vertical
cellar-ascent harness (`007af13`).
**ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was
the UNDERSIDE of the grade sheet.** Two steps:
1. The membership/viewer re-diagnosis below is **REFUTED** by the vertical
cellar-ascent harness (`Issue108CellarAscentViewerReplayTests`, dat-backed
A9B4 corner-building cellar 0x0174→0x0175→0x0171, production
FindCellList pick + the camera probe chain mirrored verbatim): 0
outdoor/null viewer resolutions while the eye is below grade, 0 sweep
failures, 0 fallback branches across boom distance {2.61, 5} × damping
lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the
head pops above grade (the stairwell portal sits at grade), matching the
user's wording. The root is INTERIOR the whole window.
2. Retail terrain is SINGLE-SIDED: `ACRender::landPolysDraw` (0x006b7040)
draws each land triangle ONLY when the camera is on the POSITIVE (upper)
side of its plane (`Plane::which_side2` vs `Render::FrameCurrent`). A
below-grade eye gets NO terrain — through the door retail shows sky.
WB renders the world with face culling DISABLED frame-globally (WB
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
set no cull state of its own → terrain drew double-sided. From a
below-grade eye every aperture sight-ray RISES, so the only "terrain" it
can see is the underside of the z≈94 grade sheet — which painted the
whole exit-door aperture (the landscape slice's 2D NDC clip planes
`(nx,ny,0,dw)` have no depth axis and cannot exclude it) and slid down
off the door exactly as the eye crossed grade.
**Fix: port the landPolysDraw eye-side gate as terrain backface culling**
`TerrainModernRenderer.Draw` now owns Enable(CullFace) + Cull(Back) +
FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL-
state rule). Pins: `LandblockMeshTests.Build_AllTriangles_WindCounter-
ClockwiseInWorldXY` (every emitted triangle CCW in world XY — cull-safe
winding) + `TerrainCullOrientationTests` (above-eye ⇒ CCW window winding
kept / below-eye ⇒ CW culled under the production camera convention).
**Gate:** climb out of the corner-building cellar — the grass window over
the exit door must be gone (sky/world through the door instead); plus a
general outdoor sanity glance (terrain intact from above — a wrong
FrontFace would blank it).
**Severity:** MEDIUM
**Component:** ~~render / indoor PView~~**physics / membership** (cellar-transition root flip)
**Component:** render / terrain (single-sidedness) — membership/viewer EXONERATED
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the
@ -3907,13 +4292,47 @@ retail's viewer-distance smoothing (update_viewer region) before touching.
## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop
**Status:** OPEN
**Status:** OPEN (narrowed) — one Ghidra-confirmed faithfulness fix
SHIPPED 2026-06-12; both reported shapes still need a runtime trace.
**Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no
walk-throughs; feel-level divergence at walls/doors)
**Filed:** 2026-06-11 (BR-7 / A6.P4 ship session)
**Component:** physics (slide response — `SlideSphere` degenerate-offset
guard + first-contact-frame behavior)
**GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a
second decompiler — Ghidra MCP, patchmem.gpr, full PDB):**
- **SHIPPED (faithfulness fix):** `CSphere::slide_sphere` (Ghidra
`0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON`
(= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON
<= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return
COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq`
(0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5`
obscured it). Fixed at `TransitionTypes.cs:3098,3105`; full physics
suite (612) + full Core (1443) green, no regression. Crease now needs
≥0.81° between normals (was 0.011°); the guard stops slides under
~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row
existed — it was an undocumented porting error; the fix matches retail).
⚠️ This does NOT fix either reported shape below.
- **Shape-1 RE-DIAGNOSED — our `cn=UnitZ` default is RETAIL-FAITHFUL.**
Ghidra `validate_transition` (`0x0050aa70`) does exactly our
`TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0)
set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the
faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our
`collision_normal_valid` was FALSE (→ UnitZ) where retail's was TRUE
(it had recorded the door-face normal `(0,+1,0)`). The bug is in the
COLLISION-RECORDING path (find_collisions / collide_with_environment),
not slide/validate. Next: replay tick-22760
(`DoorBugTrajectoryReplayTests`) instrumented to see where our
collision-normal recording drops the wall normal.
- **Shape-2 NARROWED — D4 stays skipped.** Ghidra confirms slide_sphere
applies the slide IN-FRAME (`add_offset_to_check_pos` → SLID_TS), so our
Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the
SUSPECT half. But the threshold fix did NOT change D4 (its offset is a
real slide, not degenerate), so whether retail's first airborne frame
REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb
trace of an airborne wall hit before flipping the assertion.
**Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left
them byte-identical):**
@ -3945,6 +4364,51 @@ them byte-identical):**
fixture as the acceptance pair. Do NOT patch the degenerate-offset guard
ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply.
**ORACLE DESK READ DONE (2026-06-12) — needs a LIVE cdb session to
finish.** Both sides quoted + verified against source (our
`CSphere::slide_sphere` port = `TransitionTypes.cs:3054-3133`; retail
`CSphere::slide_sphere` = decomp `0x00537440`, lines 321403-321532).
Three concrete leads, none safely fixable from the static BN decomp:
1. **Shape-1 re-attributed — it is NOT the degenerate-offset guard
threshold.** Retail's guard kills slides under ~1.4 cm (`|offset|² <
0.000199999995` at `0x537735`); the lost tick-22760 slide was 3.57 cm
(`X 0.0357`), well above it — retail would keep it too. The real
divergence is the COLLISION-NORMAL SOURCE: our harness recorded
`cn=(0,0,1)` (ground), live retail `cn=(0,+1,0)` (the door face).
Strong lead: `TransitionTypes.cs:3701-3702` — on a blocked move with
no valid collision normal we DEFAULT `cn = Vector3.UnitZ` ("push up");
that exact (0,0,1) is what the harness sees. Whether retail has an
equivalent default (vs keeping the wall normal) is a runtime question.
2. **Shape-2 — retail's slide_sphere applies the slide IN-FRAME**
(`add_offset_to_check_pos` @`0x53777e`, returns 4=SLID), so our
in-frame slide to Z=1.92 on frame 1 is likely retail-faithful and the
D4 frame-1 hard-stop pin (`BSPStepUpTests.D4_*`, expects Z=2.0) is the
STALE expectation. BUT retail always uses `contact_plane` OR
`last_known_contact_plane` (`0x53755a`); it has no "airborne wall-only,
no plane" third branch like ours (`TransitionTypes.cs:3080-3092`) — the
first-airborne-frame plane state needs a trace before flipping the pin.
3. **Candidate epsilon-squaring divergence (real, but explains neither
shape).** Retail compares SQUARED quantities (`|cross|²` @`0x5375a5`,
`|offset|²` @`0x537735`) against `0.000199999995` (≈0.0002, NON-squared);
our port compares against `EpsilonSq = 0.0002²` (line 3105 + the
`dirLenSq >= EpsilonSq` branch @3098) — potentially ~10⁴× too small.
DO NOT change this without cdb confirmation: the BN `test ah, 0x5`
branch polarity (lines 321466-321467/321484-321485) is the exact
undecodable construct the PosHitsSphere saga warned about, and the
register reuse garbles which quantity is squared. A wrong guess here
regresses ALL wall-slide behavior.
**Next (cdb session, well-scoped):** (a) `cdb -z uf
acclient!CSphere::slide_sphere` OR a live attach to disassemble
`0x00537440` and settle the two `test ah,5` branch signs + the
squared-vs-not threshold (prefer LIVE attach — prior lesson: static
`-z uf` misdecodes at OMAP boundaries); (b) live trace the tick-22760
door push to confirm whether the `cn=(0,0,1)` comes from our
`UnitZ`-default (lead 1) and what retail's normal is at that instant.
---
## #117 — Aperture-shaped see-through: doors/interiors visible through terrain hills and through nearer buildings — [DONE 2026-06-11 · 478c549, user re-gate "Yes solved"]
@ -4304,35 +4768,56 @@ of which draw list the building's shell left.
## #124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
**Status:** OPEN
**Status:** CLOSED (user-gated 2026-06-12 evening: "124, that one is solved")
**Severity:** MEDIUM
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue")
**Filed:** 2026-06-11 (re-gate; pre-existing — "still have that issue";
user 2026-06-12: "especially visible when I look out through a door
opening when inside a building")
**Component:** render — per-building look-in floods under INTERIOR roots
From inside a building, looking out through a door/window at ANOTHER
building that has an opening: the far building's back walls are
missing/transparent (see the world through it). **Lead (by read):** the
per-building look-in floods (`MergeNearbyBuildingFloods`) run ONLY for
outdoor roots — `RetailPViewDrawContext.NearbyBuildingCells` is
documented "Null for interior roots." So under an interior root the far
building's INTERIOR never floods: through its window you see the shell
only, and a shell has no interior back-wall faces → transparent.
Retail runs the building look-in inside `LScape::draw` (DrawBlock →
DrawPortal → ConstructView(CBldPortal)), which executes for ANY root
whose outside view is non-empty — including interior roots looking out
a doorway. Fix shape: provide the nearby-building gather + per-building
floods for interior roots too, with look-in apertures getting PUNCH
semantics (the `forceFarZ` selector currently keys on
`clipRoot.IsOutdoorNode`, which under-punches this case). Needs its own
focused pass — touches the gather, the merge, and the depth-mask
selector.
missing/transparent. The lead confirmed by decomp: retail runs the
look-in INSIDE the landscape stage for ANY root — `LScape::draw` is the
FIRST call of `PView::DrawCells`' outside-view branch (pc:432719),
strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
`ConstructView(CBldPortal)`'s GetClip runs under the INSTALLED view
(the doorway region), and all apertures far-Z punch (pass 1) before any
interior cell draws (pass 2).
**Fix (2026-06-12):**
- The per-building gather (frustum pre-gate on `Building.PortalBounds`)
now runs for interior roots too; the root's own doorway self-excludes
via the seed eye-side test.
- `BuildFromExterior` gained `seedRegion` — the port of retail's
installed-view clip: interior-root look-ins seed clipped against the
OutsideView (doorway) polygons, so a building not visible through the
doorway never floods. Outdoor roots keep the full-screen default.
- NEW `DrawBuildingLookIns` sub-pass inside the LANDSCAPE stage (before
the depth clear + seals): per building, punch ALL apertures
(`DrawLookInPortalPunch`, always far-Z), then draw the flooded cells'
shells + statics far→near. NOT merged into the main frame — a merged
cell would draw post-clear and z-fail against the root's seal.
- Look-in cells join the Prepare/partition set (shells get batches,
statics route to ByCell, consumed only by the sub-pass).
Pins: `Issue124LookInSeedRegionTests` (containing region floods ⊆
full-screen flood; disjoint region floods nothing; interior-side eye
never seeds its own exit door). Register: AP-33 (look-in statics drawn
whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
a far building stays invisible; both documented).
**Gate:** from inside a building, look out the door at another building
with an open door/window — its interior/back walls render through its
aperture instead of see-through to the world behind.
---
## #125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps
**Status:** ROOT CAUSE FIXED 2026-06-11 (`fcade06`, live-verified) —
remaining: the sticky-drop design debt (below).
**Status:** CLOSED 2026-06-12 — the GL root cause was fixed `fcade06`
(2026-06-11, live-verified); the remaining sticky-drop DESIGN DEBT is now
fixed too (bounded upload retry, below). No visual gate (robustness).
**RESOLVED (root cause):** the GL errors were the gpu_us QUERY RING's own
— a glGenQueries name isn't a query object until first glBeginQuery, and
@ -4347,11 +4832,28 @@ slot; read only begun queries. Live-verified in-tower: 0 [wb-error]
time under pview, meshMissing=0. **Normal runs (WB_DIAG off) never had
these errors — this mechanism is RETIRED for #119.**
**Remaining debt (keep open under this number):** UploadMeshData removes
the preparation task BEFORE uploading, so any genuinely-failed upload is
never retried — permanently invisible mesh with one [wb-error] line.
The trigger is gone but the design flaw isn't; add retry/re-prepare
semantics in a maintenance pass.
**Remaining debt — FIXED 2026-06-12 (bounded upload retry):** the exact
stick was the CPU-cache short-circuit, not just the early `TryRemove`: a
failed `UploadMeshData` (catch → null) consumed the staged item and left
`_renderData` empty while the prepared data lingered in `_cpuMeshCache`,
so `PrepareMeshDataAsync`'s cache-hit path (`ObjectMeshManager.cs:448-453`)
returned it WITHOUT re-staging → never re-uploaded until CPU-cache
eviction (effectively session-sticky under low cache pressure). Fix: the
Tick drain (`WbMeshAdapter.cs`) now re-stages a failed upload for the NEXT
frame via `ObjectMeshManager.UploadOrRequeue`, bounded by
`MaxUploadRetries` (3) using a counter on the `ObjectMeshData` object
(resets to 0 on re-prepare). Re-stages are collected and re-enqueued
AFTER the drain loop — never inside it — so a deterministic failure can't
spin the queue in one frame; past the cap it gives up with a loud
`[up-retry] … giving up` line (surfaces a genuine GL defect instead of
the old silent permanent drop). Retail loads synchronously and has no
such failure mode; this converges the async pipeline toward that
guarantee. Build + App.Tests (264) green; no GL-context test seam exists
for the upload path so the retry is verified by construction + the
regression suite. The uncaught `GenerateMipmaps` path (open-question c)
is INTENTIONALLY left to surface errors — adding a blanket catch there
would mask future real defects (no-workarounds rule); its trigger
(`fcade06`) is already retired.
**Filed:** 2026-06-11 (in-tower WB_DIAG launch, `tower-wbdiag3.log` — preserved in the worktree root)
**Component:** render — WB staged texture pipeline (ObjectMeshManager / ManagedGLTextureArray)
@ -4417,8 +4919,21 @@ not raw terrain. Note the snap line even shows a candidate it rejected
## #127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism)
**Status:** OPEN — HIGH (the live mechanism behind the tower roof/edge
flap; almost certainly #123 and related flap reports)
**Status:** CLOSED 2026-06-12 — user re-gate ("Seems to have been
fixed" — ran past distant buildings, no flicker/vanish) + desk
confirmation. The bistable-admission mechanism died with the **W=0
polyClipFinish clip port** (`987313a`, the #119/#120 work that
"kills the knife-edge class everywhere") plus the #120 containment-
rejection growth fix. NOTE the captured-pair evidence in
`tower-viewer-capture.log` predates all of those fixes — it was the
near-eye knife edge, the same class. Pins (both green at HEAD):
`Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable`
(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both
pre-gate states) + `DistantBuildingStrafe_NoAdmissionChurn` (the
regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m
× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY:
do not re-open the BuildFromExterior seed gates for flap symptoms
without a FRESH repro at HEAD — the captured-pair lead is dead.
**Filed:** 2026-06-11 (tower capture run)
**Component:** render — BuildFromExterior seed admission / per-building
flood stability
@ -4477,73 +4992,213 @@ staircase entity's per-frame draw decision.
## #129 — Doors/doorways leak through terrain and houses from over a landblock away
**Status:** OPEN
**Status:** FIX SHIPPED — awaiting user visual gate
**Severity:** MEDIUM (visible at distance during normal outdoor play)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — aperture depth punch at distance (#117 family)
**Component:** render — aperture depth punch at distance (#117 family, AD-18)
**Symptom (user):** "leakage of like doors and doorways through the
terrain and houses over a landblock" — door/doorway-shaped patches
visible THROUGH intervening terrain and nearer buildings when the
source building is roughly a landblock (~192 m) or more away.
**Leads:**
1. **The #117 stencil depth-gate bias at long range (top suspect).**
#117's fix (`478c549`) marks aperture pixels at biased true depth
(LEQUAL, bias 0.0005 NDC) then far-Z punches only marked pixels. With
a non-linear depth buffer, 0.0005 NDC at ~200 m spans many METERS of
view depth — the bias can exceed the separation between the aperture
and a hill/house in front of it, marking occluder pixels and punching
them → the occluder shows the interior/background behind. The #108
coverage constraint pulls the bias up; distance pulls it wrong —
re-derive the bias in eye-space (or scale by w) instead of constant
NDC.
2. Per-building look-in floods admitting distant buildings (the #127
churn family) — would gate WHICH buildings punch, not the
through-occluder leak itself.
**Root cause (lead 1 confirmed analytically, `Issue129PunchBiasTests`):**
the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
non-linear — a constant NDC bias `b` spans ≈ `b·d²/near` meters of eye
depth at distance `d`. With retail's znear 0.1 that is 0.125 m at 5 m
but **~190 m at a landblock**: every hill/house in front of a distant
aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
leak. Exactly AD-18's recorded "Risk if assumption breaks".
**Next:** capture at the spot (ACDREAM_PROBE_VIEWER=1 + a screenshot +
player/eye position from [snap]/[viewer]); confirm whether the leak
patch matches an aperture polygon of the distant building; then test
the eye-space-bias hypothesis headlessly (the #117 commit has the bias
math).
**Fix (2026-06-12):** cap the bias's EYE-SPACE span —
`biasNdc(d) = min(0.0005, 0.5 m × near / d²)`
(`PortalDepthMaskRenderer.MarkBiasNdc`, mirrored in the vertex shader).
Below the ~10 m crossover the constant term wins, bit-identical to the
T5-validated behavior (#108 grass coverage untouched); beyond it the
punch can never reach an occluder more than 0.5 m in front of the
aperture plane. Pins: `Issue129PunchBiasTests` (old form spans >100 m
at a landblock; capped form ≤0.5 m at all distances; close range
unchanged).
**Gate:** the original spot — distant building doors no longer show
through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep
stays gone up close. If a >10 m-range #108-class residue appears, the
cap constant (0.5 m) is the tuning knob — see AD-18.
---
## #130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
**Status:** OPEN
**Status:** FIX 2 SHIPPED — awaiting user visual re-gate
**Severity:** LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game)
**Filed:** 2026-06-12 (user report, post-#119-close session; "also NOW" —
possibly new since the W=0 clip port `987313a`)
**Component:** render — doorway aperture edge (seal/punch/OutsideView seam)
**Filed:** 2026-06-12 (user report, post-#119-close session)
**Component:** render — drawn-shell lift vs draw-space portal consumers (AP-32)
**Symptom (user):** standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening.
of the TOP of the doorway opening. Survived the scissor fix (`6c4b6d6`)
— user screenshot 2026-06-12 evening, "very subtle".
**Leads (capture first — plausibly a `987313a` regression):**
1. The W=0 port changed `ProjectToClip` (exact w>=0, no 1e-4 epsilon)
and DELETED the `EyeInsidePortalOpening` rescue — the OutsideView
region through a near doorway is computed slightly differently now.
If the OutsideView's top edge sits ~1 px BELOW the aperture's drawn
shell edge, terrain/outdoor geometry isn't drawn in that strip while
the interior seal/punch still cleared it → background color.
Suspects within the port: `MergeSubPixelVertices` shaving a top
vertex; the exact-w boundary vs the old epsilon shifting the
projected edge; the deleted rescue no longer substituting the full
view for an eye-pressed doorway.
2. The interior SEAL depth vs the shell top edge (the #118-era
machinery) — a 1-px mismatch between the seal polygon and the shell
aperture would show the clear color exactly at an edge.
**Root cause (the REAL strip, pinned by
`Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge`):
the +0.02 m shell render lift.** Cell shells DRAW 2 cm above the dat
origin (z-fight vs terrain, AP-32); since `f35cb8b` (the #119-residual
fix) the visibility graph deliberately uses the PHYSICS (unlifted)
transform — but the OutsideView color gate and the seal fans, which are
DRAW-space consumers, kept the unlifted polygons. The drawn lintel
therefore sits one lift-projection ABOVE the gate's top edge —
**6.7 px at a 2.4 m doorway** (measured) — and that band gets no
terrain/sky color while the seal also stamps 2 cm low. Regression from
`f35cb8b` (2026-06-11), NOT from the W=0 clip port. Vertical edges are
immune (the lift slides them along themselves) — top edge only, exactly
as reported.
**Next:** screenshot + [viewer]/[pv-dump] capture at a doorway showing
the strip; diff the OutsideView top edge NDC vs the aperture polygon's
projected top edge for that frame (the CornerFloodReplay harness
machinery can replay the frame headlessly once the eye/cell are
captured). If it reproduces at the same doorway with `987313a` reverted
locally, it's the port's edge math; fix the math, never re-add the
rescue.
**Fix 2:** draw-space consumers re-apply the lift —
`PortalVisibilityBuilder.Build(drawLiftZ:)` projects the exit-portal
OutsideView region with the lifted transform (flood admission, side
tests, CellViews stay physics-space per f35cb8b), and the seal/punch
fans lift their world verts. One shared constant
`PortalVisibilityBuilder.ShellDrawLiftZ` now feeds the shell
registration, the gate, and the fans. AP-32 register row added (the
lift had no row). Pins: the lifted gate covers the drawn aperture to
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
strip (sensitivity).
**Fix 1 (also real, sub-pixel): `6c4b6d6`** — the doorway-slice scissor
`Floor(origin)+Ceiling(size)` cut up to 1 px off the top/right edges;
now a conservative outer bound (`NdcScissorRect`, AD-17 doctrine).
The W=0 clip port `987313a` is exonerated (CPU pipeline sub-pixel exact
in like-for-like space).
**Gate:** stand inside, look out the door with the lintel on screen,
sweep the gaze — no background strip at the top edge at any alignment
or distance.
---
## #131 — Portal swirl invisible when viewed from inside a building through the doorway
**Status:** CLOSED (user-gated 2026-06-12 night: "Ok now it works" — fix 4, `d208002`)
**Severity:** MEDIUM (portals are landmark objects; the through-door view is common)
**Filed:** 2026-06-12 (user report, #124 gate session)
**Component:** render — UNATTACHED emitters have no pass under interior roots
**Symptom (user, axiom):** "the portal swirl is missing, when I look out
from inside a house. Appears when I walk out again."
**Root cause (confirmed by read + the [outstage] capture):** every
particle pass under an interior root is id-FILTERED: the landscape
slice's Scene pass and the cell/dynamics passes all require
`emitter.AttachedObjectId != 0` and membership in an owner set. An
UNATTACHED emitter (`AttachedObjectId == 0` — portal swirls, campfires,
ground effects anchored at a position) therefore draws NOWHERE when the
root is interior. The outdoor root has the dedicated T3 pass for
exactly this class (its own comment: "unattached ones had NO pass on
outdoor-node frames") — the identical hole on interior-root frames was
never plugged. Walk out → the T3 pass picks the swirl up → "appears
when I walk out again". The capture corroborated the rest of the chain
healthy: outside-stage routing + cone PASS for the dynamics, 57
attached emitters matched and drawn through the doorway.
**Fix (2026-06-12):** `DrawUnattachedSceneParticles` — invoked ONCE per
interior-root frame at the end of the landscape stage (pre-clear; drawn
later they would z-fail against the doorway seal), after the #124
look-ins so swirls blend over far interiors, NOT per slice (alpha
particles must not double-draw — the #121 lesson). Mutually exclusive
with the outdoor T3 pass by root kind. Residual (documented): unattached
INDOOR emitters now draw pre-clear and are overpainted by the room's
shells — same invisibility as before this fix; the proper per-emitter
cell classification is a future port.
**Apparatus (kept, env-gated):** `ACDREAM_PROBE_OUTSTAGE=1`
`[outstage]` (per-slice routing + cone verdicts) + `[outstage-pt]`
(slice id set, attached matched count, unattached count).
**FIX 1 INSUFFICIENT (user screenshots, same evening):** the swirl is
the portal's TRANSLUCENT MESH, not (only) unattached particles. The
real mechanism — shared with #132 — is the #124 look-in ordering: the
slice drew the portal mesh (and all scene particles) BEFORE the look-in
sub-pass; translucents write no depth, so the far building's interior
(drawn into its far-Z-punched aperture) overpainted them wherever a
look-in opening sat behind them on screen. Both screenshots show the
swirl exactly in front of the hall's doorway. Retail cannot have this
bug: all landscape-stage alpha draws are deferred into ONE flush after
LScape::draw (`D3DPolyRender::FlushAlphaList`, DrawCells pc:432722).
**FIX 2 (the FlushAlphaList deferral, same commit family as #124):**
the landscape stage is now TWO phases per frame — EARLY per slice: sky,
terrain, outdoor static meshes (the look-in punches need their depth, the
#117 lesson); then the #124 look-ins; then LATE per slice: outside-stage
dynamics' meshes + ALL attached scene particles + weather + the
unattached pass. (This FIXED #132 indoors but not the portal.)
**ROOT CAUSE (fix 4 — structurally forced; fixes 13 were
real-but-adjacent):** the teleport capture flipped `pCell` to
**0xA9B4017A — the hall's porch EnvCell** (the portal is a SERVER
object standing inside a look-in cell), and the headless replay of the
captured indoor frame proved the look-in flood ADMITS 0x017A (14 cells
incl. the porch — `Issue131SetupProbeTests.Diagnostic_LookInFlood_*`).
The partition routes server objects to the dynamics-last pass, where
(a) the viewcone has NO entries for look-in cells → culled, and (b)
even un-culled they would z-fail post-seal beyond the root's door plane
(the #118 lesson). This is exactly AP-33's recorded "look-in DYNAMICS
are not drawn (deferred)" — the deferred case was the town portal.
Outdoors the merge path puts the porch in the main cone → drawn →
"appears when I walk out."
**Fix 4:** look-in-cell DYNAMICS draw inside `DrawBuildingLookIns`
pass 2 (with the statics, whole — AP-33's over-include), and their
emitters ride the same `DrawCellParticles` call (fix 3). Retail
equivalent: the nested DrawCells draws the cell's objects
(`DrawObjCellForDummies` pc:432878+). No double-draw: dynamics-last
keeps culling them (cell absent from the main cone);
DrawDynamicsParticles only sees dynamics-last cone survivors.
**Gate:** stand inside, look out the doorway at the town portal — the
swirl renders through the door.
---
## #132 — Candle flame disappears when the through-opening background is behind it
**Status:** CLOSED (user-gated 2026-06-12: indoors "now the candle light is visible", outdoors "Candle works now")
**Severity:** LOW-MEDIUM
**Filed:** 2026-06-12 (user report, #124 gate session)
**Component:** render — slice particles drawn before the #124 look-ins
**Symptom (user, axiom):** "I have a candle, when I look at the candle
when a wall is behind it it shows, but if I turn a bit and the opening
through a house is behind it candle light disappears."
**Root cause (= #131's fix-2 mechanism):** the candle/lantern's flame
is an attached emitter drawn in the landscape slice's Scene-particle
pass, which ran BEFORE the #124 look-in sub-pass. Particles write no
depth; whenever a look-in opening ("the opening through a house") sat
behind the flame on screen, the far building's interior — drawn into
its far-Z-punched aperture — overpainted the flame. Against a plain
wall (no look-in aperture behind), nothing overdraws it → visible.
Background-dependence explained exactly.
**Fix:** the landscape stage's two-phase split (see #131 FIX 2): all
scene particles moved to the LATE phase, after the look-ins.
**Gate 1 result (user):** indoors FIXED ("now the candle light is
visible when I'm in the house when it is in front of the opening") —
but the OUTDOOR sibling surfaced ("when I go out it is not showing
unless I turn so the angle doesn't put it in front of the opening"):
under an OUTDOOR root the merged building interiors draw AFTER the
landscape stage, so a slice-drawn flame is overpainted by the punched
aperture's interior — the residual AP-34 had already recorded.
**Fix 2 (outdoor):** outdoor roots skip the slice Scene pass; attached
outdoor-static scene emitters draw in the POST-FRAME pass alongside the
T3 unattached pass (depth complete there — flames composite correctly
against interiors). The owner-id filter carries over; cell-pass and
dynamics-pass emitters keep their own passes (owners never in the
outdoor-static set → no double-draw).
**Gate:** both sides — indoors with the opening behind the candle, and
outdoors at the angle that previously erased it.
---

View file

@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 1. Intentional architecture (IA) — 15 rows
## 1. Intentional architecture (IA) — 14 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -55,7 +55,6 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` + `src/AcDream.App/UI/Layout/LayoutImporter.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is NOW DATA-DRIVEN from the dat: the `LayoutImporter` (gated `ACDREAM_RETAIL_UI_IMPORTER`) reads the real `LayoutDesc` for `0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
---
@ -64,7 +63,7 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) |
| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportArrivalReadiness` ~5012) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
@ -80,7 +79,7 @@ accepted-divergence entries (#96, #49, #50).
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with invented `PunchMarkDepthBias = 0.0005` NDC; retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders (hills, closer buildings), painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Bias is depth-dependent: an occluder within ~bias in front of a distant aperture gets punched through; door-plane-hugging geometry just beyond it re-occludes the aperture (a **#108**-class regression) | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw``DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
@ -93,7 +92,7 @@ accepted-divergence entries (#96, #49, #50).
---
## 3. Documented approximation (AP) — 32 rows
## 3. Documented approximation (AP) — 37 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -112,7 +111,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
| AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) |
| AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs |
| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-16 | Global nearest-8 viewer-distance light selection (own r13 design); retail bound D3D lights per object/cell. NO viewer-range candidacy filter — each light's range cutoff is applied per-surface in the shader (the earlier `Range²×1.1` slack filter was removed; it dropped torches the viewer stood outside, the #133 "lighting off" report) | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; nearest-8 is an allocation-free partial-select (no per-frame list/sort) | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights) | r13 §12.2 (acdream design); retail D3D 8-light constraint |
| AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E |
| AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) |
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
@ -128,11 +127,16 @@ accepted-divergence entries (#96, #49, #50).
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
| AP-32 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` |
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
| AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) |
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp) | `src/AcDream.App/Rendering/GameWindow.cs:6895` (per-frame predicate) + `:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 |
| AP-35 | Point/spot lights use a single PER-PIXEL accumulation that ports `calc_point_light`'s `(1 dist/falloff_eff)` LINEAR ramp (falloff_eff = Falloff × static_light_factor 1.3) + standard Lambert `N·L`; retail's path is PER-VERTEX Gouraud and additionally applies a half-Lambert wrap (`0.5·dist + N·L_vec`, lights surfaces down to `N·L ≥ 0.5`) and an x87-obscured normalization factor, neither ported | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag:52` (+ `mesh.frag`; `LightInfoLoader.cs:81` folds 1.3 into Range) | The linear ramp is the user-visible fix (kills the hard-disc "spotlight" edge, #133 A7); the dropped wrap/normalization only re-shade the gradient slightly, and per-pixel vs per-vertex Gouraud chiefly differs on coarse geometry. Half-Lambert wrap + factor are an x87-decompile refinement (same artifact class as GetPowerBarLevel AP-24) | Surfaces facing slightly away from a torch (`0.5 ≤ N·L < 0`) stay dark where retail's wrap lights them faintly; near-light gradient shading differs subtly from retail's per-vertex bake | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); static_light_factor 0x00820e24 |
| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` |
---
## 4. Temporary stopgap (TS) — 29 rows
## 4. Temporary stopgap (TS) — 30 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -165,10 +169,11 @@ accepted-divergence entries (#96, #49, #50).
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx |
---
## 5. Unclear (UN) — 6 rows
## 5. Unclear (UN) — 5 rows
These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded
@ -177,7 +182,6 @@ equivalence argument (promote to AD/AP) or a fix.
| # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
| UN-2 | `GetMaxSpeed`: XML doc asserts the bare run rate is retail-correct (~5.9 m/s catch-up; the ×RunAnimSpeed multiply "a misread" → ~23.5 m/s), yet the implementation multiplies by RunAnimSpeed citing ACE as retail-verified. The two recorded justifications CONTRADICT — one describes the current code as known-wrong | `src/AcDream.Core/Physics/MotionInterpreter.cs:972` | None coherent — doc and code disagree about which behavior is retail | If the bare-rate reading is right, remote-entity catch-up runs ~4× retail speed — the multi-second 1-Hz blip / racing-remote symptom the doc itself records | `CMotionInterp::get_max_speed` pc:305127; catch-up :353122 |
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) |
@ -193,20 +197,19 @@ phase-gated — they carry their trigger in their row and should land
WITH that phase, not before.
1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj.
2. **UN-2 — GetMaxSpeed contradiction** — the file argues against its own implementation; if the bare-rate reading is right, remote catch-up runs ~4× retail. Settle with one decomp re-read + a cdb catch-up trace; cheap to resolve, expensive to leave.
3. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
4. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
5. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
6. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
7. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
8. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
9. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
10. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
11. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
12. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
13. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
14. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
15. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there.
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it.
5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity.
8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now.
13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating),

View file

@ -2,7 +2,13 @@
**Status:** Living document. Created 2026-05-12.
**Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index).
**Currently working toward:** **M1.5 — Indoor world feels right.**
**Currently working toward:** **M1.5 — Indoor world feels right.** The
building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13
to include **dungeon support (full Phase G.3)** — dungeons don't work yet
(terrain-less dungeon landblocks aren't supported by the streaming/load
pipeline; issue #133). M1.5 does NOT land until dungeons work. M2 stays
deferred. (Correction: M1.5 was briefly marked landed 2026-06-13; the user
reverted that — the indoor world isn't done while dungeons are broken.)
---
@ -185,7 +191,56 @@ close range and the player sees "You pick up the X." in chat.
---
### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (resumed 2026-05-21 after Phase O ship)
### M1.5 — "Indoor world feels right" — 🔵 ACTIVE (building/cellar demo DONE; EXTENDED 2026-06-13 to include dungeon support / Phase G.3)
**EXTENDED 2026-06-13 — dungeons pulled into M1.5 scope.** The
building/cellar demo (below) is DONE + user-gated, but attempting the
dungeon demo surfaced that dungeons don't work AT ALL: terrain-less
dungeon landblocks aren't supported anywhere in the streaming/load/
render/physics pipeline (`LandblockLoader.Load` returns null with no
`LandBlock` terrain record; the streamer fails with no terrain mesh; the
teleport snap Resolves before hydration — issue #133). The user decided
M1.5 is NOT done while the indoor world excludes dungeons, and chose the
FULL Phase G.3 scope (dungeon streaming + portal-space loading screen +
multi-landblock dungeon LOD + `PlayerTeleport` handling). Design in
progress (`docs/superpowers/specs/` — dungeon-support spec). M1.5 lands
when: building/cellar demo (DONE) + dungeon demo (enter via portal,
navigate 3-5 rooms, walls block, smooth transitions) both pass.
**Building/cellar demo — DONE + user-gated.** The indoor world reads as
solid. Across the
2026-06 sessions the holistic retail-faithful render port (Option A: ONE
`DrawInside(viewer_cell)`, no inside/outside branch — BR-2..BR-7 / T1..T6)
shipped and was user-gated, and the indoor physics/membership family was
brought to retail fidelity (the A6.P4 per-cell shadow architecture; the
#107/#111/#112 spawn + membership fixes; the cellar-lip wedge). End-to-end,
user-gated this milestone: walk into a building and climb a multi-floor inn
without sling-out or wall-clip; descend a cottage cellar and ascend it
without falling through (the #98 + cellar-lip + #108 grass-window closes);
walls block everywhere (indoor + stab-shell, the #99 door run-through
closed); cell transitions are smooth (the doorway "flap" family killed —
#119/#128, #112, #113, #124, #129/#130/#131/#132, #108-residual, #127 all
closed with user gates). The #90-stickiness + `TryFindIndoorWalkablePlane`
synthesis workarounds were removed by A6.P4. Remaining feel-level debt is
tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor
lighting fidelity not yet done — folded forward).
**Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons
don't work: the streaming/load/render/physics pipeline was built entirely
around outdoor landblocks (terrain + scattered buildings) and has no path
for terrain-less indoor-only dungeon landblocks. Confirmed gaps:
`LandblockLoader.Load` returns null with no `LandBlock` record; the
streamer fails with no terrain mesh; the teleport-arrival snap Resolves
before the dungeon hydrates → places the player in the old frame over
ocean. Full G.3 scope chosen by the user 2026-06-13 (streaming + portal-
space loading screen + multi-landblock LOD + `PlayerTeleport` handling).
Spec under `docs/superpowers/specs/`.
---
#### (historical M1.5 working notes below)
🔵 ACTIVE (resumed 2026-05-21 after Phase O ship)
**2026-05-30 — render-pipeline pivot.** The indoor *rendering* seam (seamless
in/out: the flap, missing/transparent walls, terrain bleed) will be solved by a
@ -293,13 +348,23 @@ unblocks that).
---
### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (was: NEXT)
### M2 — "Kill a drudge" — ⏸ DEFERRED until M1.5 lands (incl. dungeons)
**Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit
Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation
play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel
and see it.
**First port target when M2 starts (per the M2 combat-math research memo,
`docs/research/2026-06-04-combat-math-deep-dive.md`):**
`CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready
(ACE is the high-confidence oracle; two known scaffold bugs in
`CombatModel.cs` identified — additive attributeBonus + subtractive armor).
Hit-roll is well-documented client-side; the server sigmoid/crit +
weapon-timing (the x87 `GetPowerBarLevel` artifact) come after. NOTE: M2
was briefly started 2026-06-13 then re-deferred when M1.5 was extended to
include dungeons.
**Phases to ship:**
- **F.2 (panels)** — Inventory panel reading `ItemRepository` (data already
shipped in F.2 base; M2 ships the visual surface).

View file

@ -0,0 +1,95 @@
# Night-session handoff (2026-06-12): nine closes shipped; NEXT = #108-residual
**Branch state:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at
`49cffe6`. Suites green at every commit: App 261+1skip / Core 1439+2skips /
UI 420 / Net 294. CLAUDE.md "Current state" + the render digest
(`claude-memory/project_render_pipeline_digest.md`) are refreshed to this
truth — orient there first.
## 1. What this session closed (all user-gated; do NOT re-litigate)
| Closed | Root cause | Commits |
|---|---|---|
| **#130** doorway top-edge strip | TWO stacked causes: scissor box `Floor(origin)+Ceiling(size)` under-covers top/right (sub-pixel, `NdcScissorRect`); THE strip = the +0.02 m shell draw-lift missing from draw-space portal consumers post-f35cb8b (6.7 px @2.4 m, measured) | `6c4b6d6`, `5135066` (AP-32 row added) |
| **#129** doors leak through terrain at ~a landblock | constant 0.0005 NDC punch bias spans ~190 m of eye depth at distance; capped to 0.5 m eye-space (`MarkBiasNdc`) | `4ba7148` (AD-18 updated) |
| **#113** hill-cottage phantom stairs | dead via `2163308` (cache cross-serving) — re-gate confirmed | — |
| **#124** far-building back walls through openings | interior-root look-ins ported as a LANDSCAPE-STAGE sub-pass (decomp: LScape::draw runs FIRST in DrawCells' outside branch, pc:432719, pre-clear/pre-seal; seeds clip vs the INSTALLED view → `BuildFromExterior(seedRegion:)`; punch-all-then-draw). NEVER merge look-ins into the main frame (post-clear seal z-kill) | `77cef4c` (AP-33 added) |
| **#132** candle flame vs through-opening background | slice particles drew BEFORE the look-ins / merged interiors (no depth self-protection) — the FlushAlphaList deferral ported as the two-phase slice split + outdoor post-frame attached pass | `20d1730`, `87afbc0` (AP-34 added) |
| **#131** portal swirl missing through doorways | FOUR layers (see lesson below); final: the portal is a SERVER object inside the hall's PORCH cell (look-in cell) → partition.Dynamics → dynamics-last culls it (no look-in cells in the main cone) + post-seal z-fail. Fix: `DrawBuildingLookIns` draws look-in-cell dynamics + emitters (retail nested DrawCells/`DrawObjCellForDummies`) | `1d3f9a8`, `47f32cd`, `d208002` |
| **UN-2** GetMaxSpeed ×4 contradiction | the implementation was retail-correct; BN pseudo-C drops x87 fmuls — byte-verified (3× `fmul [0x7C8918]`=4.0f); doc rewritten, weenie-null default aligned to literal 1.0; row deleted | `0cb97aa` (verifier `tools/verify_un2_fmul.py`) |
## 2. THE #131 LESSON (cost: 4 fix iterations)
**Identify the ENTITY before theorizing about draw passes.** Three
real-but-adjacent fixes shipped before the elimination chain (teleport pCell
flip → owner cell; headless replay → flood admits it; partition routing →
exactly one possible drop site) forced the answer. Two tools that would have
shortened it to one iteration:
- **The pick line**: left-click prints `[B.4b] pick guid=… name=…` +
`[B.7] pick-info … setup=…` — names any clickable object in the log.
- **The teleport/pCell flip**: walking onto/into a thing prints its cell.
Both need zero new code. The register also already KNEW the answer (AP-33's
"look-in DYNAMICS are not drawn — deferred") — scan-the-register-on-symptom
applies to rows YOU wrote hours earlier.
## 3. NEXT (the queue to the M1.5 → M2 boundary)
1. **#108-residual — cellar-ascent grass window (NEXT, desk-first).**
Climbing out of a cellar, grass covers the exit door until the eye pops
above grade. Punch/seal exonerated; it is MEMBERSHIP/VIEWER-side (which
cell the camera resolves while the eye is below grade). Apparatus
designed: a VERTICAL exit-walk-harness variant (HouseExitWalkReplayTests
machinery driving the camera up cellar stairs, watching viewer-cell
resolution per step). Read the physics digest + ISSUES #108 before
starting. User needed only for the final cellar gate.
2. **#127 — distant-building admission churn** (flood size oscillates ±13
cells at mm eye deltas; suspect list includes the PortalBounds frustum
pre-gate — machinery #124 now reuses for interior roots).
3. **#116 — slide-response family** (physics, oracle-first: one cdb session).
4. **#125 sticky-drop debt** — failed texture uploads never retried
(session-sticky invisible meshes); robustness, no visual gate.
## 4. Apparatus added this session (all env-gated, kept)
| Tool | How | For |
|---|---|---|
| `[outstage]`/`[outstage-pt]`/`[outstage-own]` | `ACDREAM_PROBE_OUTSTAGE=1` (+`ACDREAM_DUMP_ENTITY=<ids>` doubles as the owner watchlist) | outside-stage dynamics routing/cone verdicts; scene-particle owner matching |
| `Issue130DoorwayStripTests` | App.Tests | aperture-vs-gate top-edge gap in DRAWN (lifted) space; the lift-seam sensitivity pin |
| `NdcScissorRectTests` / `Issue129PunchBiasTests` | App.Tests | scissor containment; punch-bias eye-span cap |
| `Issue124LookInSeedRegionTests` | App.Tests | seedRegion semantics at the real corner-building door |
| `Issue131SetupProbeTests` | App.Tests | dat setup dumps + the porch-admission replay of a captured frame |
| `tools/verify_un2_fmul.py` | `py` | re-derive the GetMaxSpeed ×4.0 byte proof |
## 5. Paste-ready pickup prompt
```
Pick up acdream as a SENIOR 3D ENGINE DEVELOPER on #108-residual (the
cellar-ascent grass window). Branch claude/thirsty-goldberg-51bb9b ==
pushed both remotes at 49cffe6. Read FIRST: CLAUDE.md "Current state",
docs/research/2026-06-12-night-session-handoff-108-residual-next.md (THE
handoff), then BOTH digests (render + physics; DO-NOT-RETRY tables apply).
WORK ORDER:
1. #108-residual — eye-below-grade membership at cellar exits. Build the
VERTICAL exit-walk harness variant (HouseExitWalkReplayTests machinery,
a cellar staircase fixture), watch viewer-cell resolution per step while
the eye is below terrain grade; pin where the resolver demotes to
outdoor/terrain. Punch/seal are exonerated — do NOT touch them.
2. Then #127 (admission churn; PortalBounds pre-gate suspect), #116
(slide-response, oracle-first cdb), #125 sticky-drop debt.
3. When the ledger clears: run the M1.5 DUNGEON DEMO as the milestone
exit gate (milestones doc: enter any dungeon via portal, 3-5 rooms,
walls block / stairs work / lighting correct / transitions smooth).
The old blocker #95 died with the Option A rewrite (the ACME BFS it
lived in was deleted in T4); the portal entry flow is field-tested
(the 2026-06-12 accidental teleport). Dungeon-specific findings
(likely A7 lighting items) get fixed inside M1.5; a clean demo lands
M1.5 -> update the milestones doc + CLAUDE.md and start M2 (kill a
drudge; first port target per the research memos: CombatMath).
The user's reports are AXIOMS. Visual gates are the acceptance tests.
Suites green per commit: App 261+1skip / Core 1439+2skip / UI 420 /
Net 294. Register discipline: new deviation = same-commit row. For any
object-specific render bug: IDENTIFY THE ENTITY FIRST (the pick line
[B.4b] names clicked objects; pCell flips name cells) — the #131 lesson.
```

View file

@ -0,0 +1,205 @@
# Handoff (2026-06-13): M1.5 EXTENDED — dungeon support (full Phase G.3). Design grounded; ready to brainstorm → spec → implement.
**Branch:** `claude/thirsty-goldberg-51bb9b`, pushed to BOTH remotes at the
HEAD this doc commits with. Suites green: App 264+1skip / Core 1445+2skip /
UI 420 / Net 294 (the dungeon dat-probe test added this session is
output-only).
This session closed a batch of M1.5 render/physics issues, then — at the
dungeon-demo gate — discovered dungeons don't work and the user **extended
M1.5 to include full dungeon support (Phase G.3)**. M2 is re-deferred. The
design is grounded (5-way reference research + a decisive dat probe); the
next session brainstorms approaches → writes the spec → implements.
---
## 1. What this session shipped (all on branch, pushed, most user-gated)
| Item | Outcome | Commits |
|---|---|---|
| **#108-residual** (cellar grass window) | CLOSED, user-gated "Yes it is fixed." Terrain was drawn DOUBLE-SIDED; the grass was the grade sheet's underside seen from a below-grade cellar eye. Ported retail `landPolysDraw` eye-side gate as terrain backface cull. Membership/viewer EXONERATED by a vertical cellar-ascent harness. | `007af13`, `96a425a`, `bf80067` |
| **#127** (distant-building flood flap) | CLOSED, user-gated "Seems to have been fixed." Died with the W=0 clip port (`987313a`); confirmed by a run-past churn detector (0 churn, 21 buildings × 5 distances × 100 mm-steps). | `4ad6fb9` |
| **#125** (sticky-drop debt) | CLOSED. Bounded upload retry — a failed `UploadMeshData` re-stages for the next frame up to `MaxUploadRetries` (counter on the `ObjectMeshData`); the CPU-cache short-circuit no longer permanently strands a failed upload. | `8682a8d` |
| **#116** (slide-response) | PARTIAL. Ghidra (the user pointed me to the running Ghidra MCP) resolved the BN `test ah,5` branch-sign ambiguity: `slide_sphere` compares squared magnitudes against `F_EPSILON` (0.0002), not `EpsilonSq` (4e-8) — fixed `TransitionTypes.cs:3098,3105`, full physics suite green. The two reported shapes still need a cdb trace (shape-1 = upstream collision-normal recording; shape-2 = D4 first-frame dispatch). | `35961f2`, `bf18a54` |
---
## 2. The milestone churn (read this — the docs were corrected)
- I briefly marked **M1.5 LANDED** on the building/cellar demo and started M2
(`1bf037a`). **The user reverted that:** the indoor world isn't done while
dungeons are broken, so M1.5 is EXTENDED to include dungeon support, and the
user chose the **FULL Phase G.3 scope** (streaming + portal-space loading
screen + `PlayerTeleport` handling). Correction committed `9c2ceb2`.
- **Current truth:** M1.5 ACTIVE; building/cellar demo DONE + user-gated;
dungeon support (G.3) is the remaining M1.5 exit-gate. M2 (CombatMath first
port) DEFERRED. Docs reflect this (milestones doc, CLAUDE.md current-state,
ISSUES.md #133).
---
## 3. The dungeon bug — CORRECTED root cause (issue #133)
User attempted the dungeon demo via the **meeting-hall portal** → "no dungeon,
just ocean." ACE logged a flood of `failed transition for +Acdream from
0x01250126 [30 -60 6.0] to 0xA9B0000E [-32227 -26748 5.9]` … marching south at
Z≈0.9 (underwater).
**Diagnostic capture (`launch-dungeon-diag.log`, probes
`ACDREAM_PROBE_CELL`/`ACDREAM_PROBE_VIEWER`/`ACDREAM_WB_DIAG`):**
```
live: teleport arrival — old lb=(169,180) new lb=(1,37) dist=42524.0
[snap] claim=0xA9B3000E pos=(30,-60,6.005) cells=17 bestCell=0xA9B30103 ... indoor=False -> targetCell=0xA9B3000E
live: teleport complete — snapped to <30,-60,6.005> cell=0xA9B3000E
[cell-transit] A9B3000E -> A9B2000E -> A9B1000E -> ... (sliding south into ocean)
```
ACE correctly placed the player in dungeon cell **0x01250126** (landblock
`0x0125` = (1,37)). acdream's arrival handler (`GameWindow.cs:4908-4931`)
recenters streaming to (1,37) but then **immediately** calls
`_physicsEngine.Resolve(pos=(30,-60,6.005), cell=0x01250126)` to snap the
player — **before the dungeon landblock has streamed in**. Resolve can't find
the dungeon 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 → slides south into ocean.
### ⚠️ The "terrain-less landblock" framing is WRONG (verified by dat probe)
A pipeline-seam research agent *assumed* dungeon landblocks have no `LandBlock`
record (so `LandblockLoader.Load` returns null) and produced a 13-seam
"rewrite the pipeline for terrain-less landblocks" plan. **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** (all-zero height index =
the lowest/"ocean" terrain) **plus its EnvCells, no buildings/objects**. So
`LandblockLoader.Load(0x0125…)` returns a valid flat landblock, the terrain
mesh builds a flat plane, and `PhysicsEngine.AddLandblock` gets a valid flat
`TerrainSurface`. **The existing pipeline can already stream a dungeon
landblock.** The 13 terrain-dependency seams are NOT the blocker.
**The real blocker is narrow: teleport TIMING + PLACEMENT.**
---
## 4. Reference grounding (5-way research; dat agent failed, replaced by the probe above)
**holtburger (client-behavior oracle):**
- PlayerTeleport (0xF751) → enter `EnteringWorld` (portal space), **suspend
physics bodies**, send **LoginComplete immediately** (no waiting for assets).
- Exit portal space → `InWorld` when the server sends ObjectCreate (entities) +
UpdatePosition (player) + the **StartGame** event → resume bodies.
- holtburger does NOT stream landblocks (entity-centric); not our model — we
DO stream from our own dats. Take the **FSM shape** (EnteringWorld/InWorld +
suspend/resume) not the no-streaming part.
- DDD is NOT part of the teleport flow (responds empty). (`messages.rs:480-486`,
`:190-195`, `player.rs:71-79`, `types.rs:169-175`.)
**ACE (server):** `Player_Location.cs:654-707` Teleport() sends PlayerTeleport
(sequence) → a **fake UpdatePosition** to trigger client load → the real
UpdatePosition with PositionPack (CellID = dungeonID<<16 | cellIndex, e.g.
`0x01250126`, xyz, rotation). **Server sends NO geometry — client loads cells
from its own dats by cellID** (matches our dat-driven model). Portal:
`Portal.cs:269-292` ActOnUse → AdjustDungeon (corrects cell id) →
ThreadSafeTeleport. **Dungeons are SINGLE-landblock** (`Player_Tick.cs:548-560`
forbids moving between dungeon landblocks without teleport) → "multi-landblock
LOD" in the full-G.3 scope is MOOT for AC dungeons. IsDungeon = all heights 0 +
NumCells>0 + no buildings (`Landblock.cs:575-631`).
**Retail decomp (client):** terrain (`CLandBlock::grab_visible_cells`) and
dungeon cells (`CEnvCell::grab_visible_cells`, :311878) load on **separate
paths**; a cell with `seen_outside==0` loads ZERO terrain and walks only its
`stab_list` (adjacent EnvCells). **Portal-space = a 6-state `TeleportAnimState`
FSM** (:219682-219774): WORLD_FADE_OUT → TUNNEL_FADE_IN → TUNNEL (hold while
loading) → TUNNEL_FADE_OUT → WORLD_FADE_IN → OFF; `m_pPortalSpace` is the
tunnel viewport (the "loading"/black screen). Retail gates cell-ready on DDD
(server cell push) — **we don't need DDD** (we have the dats); we gate on our
own streaming hydration. Open: no distinct "pink screen" asset found — retail's
loading visual is the portal tunnel.
**acdream pipeline seams (corrected by the dat probe):** the dungeon landblock
streams fine as flat-terrain. Real seams that matter:
- `GameWindow.cs:4908-4931` — teleport arrival: recenter then **Resolve
immediately** (the bug). No hold-until-hydration.
- `PhysicsEngine.IsSpawnCellReady` (`:468`) — the EXISTING #107 gate; already
handles indoor cells (checks DataCache for 0x0100+). **Reuse it for the
teleport-arrival path.**
- EnvCell hydration (render `_cellVisibility`/`EnvCellRenderer`; physics
`CacheCellStruct`) is iterated from `LandBlockInfo.NumCells` and is
**orthogonal to terrain** — should fire for a dungeon landblock once it
streams (`GameWindow.cs:5564-5576`, `6015-6028`). VERIFY it does.
- Placement: the player is at cell `0x01250126`, pos (30,60,6.005); must be
placed in the **EnvCell** (the #107/#111 validated-claim path,
`WalkableFloorZNearest`), not on the flat terrain.
---
## 5. Design direction (to confirm in the brainstorm)
A retail-faithful, much-narrower-than-feared shape:
1. **Teleport state machine (portal space).** On PlayerTeleport: enter a
PortalSpace/EnteringWorld state, suspend player physics, (optionally) start
the retail `TeleportAnimState` tunnel FSM for the loading visual. On arrival
UpdatePosition: recenter streaming on the destination landblock, then **HOLD**
— do not snap — until the destination landblock + the claimed EnvCell hydrate
(reuse `IsSpawnCellReady`). Then place into the EnvCell (validated-claim
path), exit PortalSpace → InWorld, resume physics, send LoginComplete.
(acdream already has `OnTeleportStarted`/portal-space + the #107 hold for
LOGIN — extend that machinery to the teleport-arrival path rather than
snapping at `:4928`.)
2. **Streaming a far teleport.** Confirm the recenter actually drives the
streamer to load the destination dungeon landblock (the Chebyshev window
around the new center) and unloads the old neighborhood without stranding the
player. The dungeon streams as a flat-terrain landblock — no new loader path
needed, but verify the apply path + EnvCell hydration fire.
3. **Render/physics in the dungeon.** Once the EnvCells hydrate, the existing
PView indoor render + per-cell collision should work (same as buildings).
The flat terrain renders below; PView roots at the viewer EnvCell. VERIFY the
3-5-room navigation, walls block, stairs, lighting (A7 not done — expect
lighting findings), transitions.
4. **Portal-space loading screen (full-G.3 polish).** The retail 6-state tunnel
FSM (`TeleportAnimState`) — implement after the core teleport+place works, or
a simpler fade-to-black first.
**Open design questions for the brainstorm:**
- Do we implement the retail `TeleportAnimState` tunnel FSM faithfully now, or a
simpler fade-to-black for M1.5 and the full tunnel later?
- How long to HOLD before giving up (the dungeon may need several frames to
stream); what's the failure/timeout behavior?
- Does the existing streaming controller already load a landblock 42 km away on
recenter, or does it assume incremental movement? (Confirm the recenter→load
path for a big jump.)
- Placement: the cell-local pos (30,60,6.005) vs the EnvCell origins (~(0,30,0))
— confirm the EnvCell BSP contains the point and the #107/#111 walkable-floor
placement lands the player on the dungeon floor.
---
## 6. Apparatus added this session (kept)
| Tool | How | For |
|---|---|---|
| `DungeonLandblockDatProbeTests` | `dotnet test --filter DungeonLandblockDatProbe` | Dumps the dat structure of a dungeon (0x0125) vs outdoor (A9B4) landblock — the terrain-less-vs-flat resolution |
| `launch-dungeon-diag.log` | `ACDREAM_PROBE_CELL=1 ACDREAM_PROBE_VIEWER=1 ACDREAM_WB_DIAG=1` | The teleport→snap→slide capture; `[snap]`/`[cell-transit]`/`live: teleport` lines are the chain |
| `Issue108CellarAscentViewerReplayTests` | App.Tests filter | Vertical cellar-ascent viewer harness (membership EXONERATED for #108) |
| `Issue127FloodFlipReplayTests.DistantBuildingStrafe_NoAdmissionChurn` | App.Tests filter | #127 run-past churn-detector regression pin |
Decomp grounding: holtburger teleport flow, ACE Teleport/Portal/AdjustDungeon,
retail `CEnvCell::grab_visible_cells` (:311878) + `TeleportAnimState` FSM
(:219682-219774). Full raw research in the workflow output (this session).
---
## 7. Next session: brainstorm → spec → implement
The brainstorming skill was invoked and scope was set (full G.3). The next
session resumes the brainstorm at "propose 2-3 approaches" with the grounding
above, settles the design, writes the spec to
`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`, then →
writing-plans → implement. The paste-ready pickup prompt is in the session
message that produced this doc.

View file

@ -0,0 +1,633 @@
# G.3a — Core Teleport-Into-Dungeon Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path.
**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard.
**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5).
**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes.
---
## File Structure
| File | Responsibility | Action |
|---|---|---|
| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** |
| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** |
| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** |
`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1).
---
## Task 1: `TeleportArrivalController` (pure state machine, TDD)
**Files:**
- Create: `src/AcDream.App/World/TeleportArrivalController.cs`
- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`:
```csharp
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
public class TeleportArrivalControllerTests
{
// Records each Place(destPos, destCell, forced) call.
private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
private static TeleportArrivalController Make(
ArrivalReadiness verdict,
List<PlaceCall> placed,
int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames)
=> new(
readiness: (_, _) => verdict,
place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)),
maxHoldFrames: maxHoldFrames);
[Fact]
public void BeginArrival_EntersHolding()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_WhenIdle_IsNoOp()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.Tick(); // never began
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_NotReady_KeepsHolding_DoesNotPlace()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
c.Tick();
c.Tick();
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
Assert.Empty(placed);
}
[Fact]
public void Tick_Ready_PlacesUnforced_AndIdles()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u);
c.Tick();
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
var call = Assert.Single(placed);
Assert.False(call.Forced);
Assert.Equal(0x01250126u, call.Cell);
Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos);
}
[Fact]
public void Tick_Impossible_PlacesForced_AndIdles()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Impossible, placed);
c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u);
c.Tick();
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
var call = Assert.Single(placed);
Assert.True(call.Forced);
}
[Fact]
public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3);
c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
c.Tick(); // 1
c.Tick(); // 2
Assert.Empty(placed);
Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
c.Tick(); // 3 -> timeout
var call = Assert.Single(placed);
Assert.True(call.Forced);
Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
}
[Fact]
public void BeginArrival_AfterPlace_ReArms()
{
var placed = new List<PlaceCall>();
var c = Make(ArrivalReadiness.Ready, placed);
c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u);
c.Tick(); // places #1, idle
c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u);
c.Tick(); // places #2, idle
Assert.Equal(2, placed.Count);
Assert.Equal(0x01250127u, placed[1].Cell);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error).
- [ ] **Step 3: Write the implementation**
Create `src/AcDream.App/World/TeleportArrivalController.cs`:
```csharp
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
/// demote rather than hold forever.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>The controller is pure: readiness and placement are injected delegates,
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
/// player stays input-frozen while this is Holding because the GameWindow keeps
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
/// needs no external clock; it is a loud safety net for a never-hydrating
/// destination, not a precise deadline.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
private readonly int _maxHoldFrames;
private Vector3 _destPos;
private uint _destCell;
private int _heldFrames;
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
public TeleportArrivalController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
/// AFTER the streaming origin has been recentered on the destination landblock.
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
public void Tick()
{
if (Phase != TeleportArrivalPhase.Holding) return;
_heldFrames++;
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
if (verdict == ArrivalReadiness.Ready)
{
Place(forced: false);
return;
}
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
{
Place(forced: true);
}
// else NotReady -> keep holding
}
private void Place(bool forced)
{
_place(_destPos, _destCell, forced);
Phase = TeleportArrivalPhase.Idle;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"`
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Wire `TeleportArrivalController` into GameWindow
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`)
This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown.
- [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks**
Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`):
```csharp
// G.3a (#133): holds a teleport arrival in portal space until the destination
// dungeon landblock/cell has hydrated, then places the player via the unchanged
// validated-claim Resolve path. Lazily constructed on the first teleport (all
// runtime deps are wired by then).
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
private void EnsureTeleportArrivalController()
{
if (_teleportArrival is not null) return;
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
readiness: TeleportArrivalReadiness,
place: PlaceTeleportArrival);
}
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
// against the teleport's (destPos, destCell): an impossible indoor claim short-
// circuits to immediate placement; otherwise hold until terrain is sampled and,
// for an indoor cell, the cell struct has hydrated.
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
System.Numerics.Vector3 destPos, uint destCell)
{
if (IsSpawnClaimUnhydratable(destCell))
return AcDream.App.World.ArrivalReadiness.Impossible;
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
return AcDream.App.World.ArrivalReadiness.NotReady;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
if (indoor && !_physicsEngine.IsSpawnCellReady(destCell))
return AcDream.App.World.ArrivalReadiness.NotReady;
return AcDream.App.World.ArrivalReadiness.Ready;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
// once the destination is ready (or force-run on impossible/timeout, logged loud).
private void PlaceTeleportArrival(
System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
var resolved = _physicsEngine.Resolve(
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
if (forced)
Console.WriteLine(
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.SetPosition(snappedPos);
pe.ParentCellId = resolved.CellId;
pe.Rotation = _pendingTeleportRot;
}
_playerController.SetPosition(snappedPos, resolved.CellId);
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
playerVelocity: System.Numerics.Vector3.Zero,
isOnGround: true,
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
// Tell the server the client finished loading the new landblock (holtburger
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
```
- [ ] **Step 2: Construct the controller when a teleport starts**
In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace:
```csharp
private void OnTeleportStarted(uint sequence)
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
EnsureTeleportArrivalController();
Console.WriteLine($"live: teleport started (seq={sequence})");
}
```
- [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival**
Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with:
```csharp
// Phase B.3 / G.3a (#133): portal-space arrival detection.
// Only runs for our own player character while in PortalSpace.
if (_playerController is not null
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
&& update.Guid == _playerServerGuid)
{
// Compute old landblock coords from controller position (using the
// current streaming origin as the reference center).
var oldPos = _playerController.Position;
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
System.Numerics.Vector3 newWorldPos;
if (differentLandblock)
{
// Recenter the streaming controller on the new landblock NOW (kick
// off the dungeon load). After recentering, the destination is
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
}
else
{
newWorldPos = worldPos;
}
// G.3a: do NOT snap here. The destination dungeon landblock has not
// streamed in yet; an immediate Resolve falls back to the resident
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
// in portal space — TeleportArrivalController.Tick (per frame) places
// the player via PlaceTeleportArrival once the destination cell
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
EnsureTeleportArrivalController();
_pendingTeleportRot = rot;
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
}
```
- [ ] **Step 4: Add the per-frame Tick after the live-session drain**
In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add:
```csharp
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
// (which applies the destination landblock) and the live-session drain
// (which may have just called BeginArrival), so a destination that
// hydrated this frame is placed the same frame.
_teleportArrival?.Tick();
```
- [ ] **Step 5: Build + run the full suites**
Run: `dotnet build`
Expected: build succeeds (0 errors).
Run: `dotnet test`
Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.)
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133)
Replaces the unconditional OnLivePositionUpdated snap (which resolved against
the resident old landblocks before the destination streamed in -> ocean) with a
recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111
validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or
force-snaps loudly on an impossible claim / ~10s timeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652`
**Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo.
- [ ] **Step 1: Make the edit**
In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is:
```csharp
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */);
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
}
```
Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard:
```csharp
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
// G.3a (#133) hydration decouple: the cell transforms and the physics +
// visibility hydration are INDEPENDENT of whether the cell has drawable
// geometry. Retail couples neither collision nor portal visibility to a render
// mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently
// dropped collision (CellTransit.GetCellStruct -> null -> fall through floor)
// and the visibility node for any geometry-less collision cell. CacheCellStruct
// self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for
// cells that genuinely have no physics.
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
// Render registration only when the cell actually has drawable submeshes.
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
_envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */);
}
```
Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above).
- [ ] **Step 2: Build + run the full suites**
Run: `dotnet build`
Expected: build succeeds.
Run: `dotnet test`
Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133)
BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a
geometry-less collision cell got no collision (fall-through) and no visibility
node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a
null PhysicsBSP, so this is safe. Render registration stays behind the submesh
guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Visual acceptance gate (STOP — user verification)
This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user.
- [ ] **Step 1: Build green**
Run: `dotnet build`
Expected: 0 errors.
- [ ] **Step 2: Launch against the live ACE server**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_CELL = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log"
```
Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon.
- [ ] **Step 2: User verifies (the acceptance criteria)**
The user confirms, in the running client:
- Player **stands in the dungeon cell**, on the floor — not ocean, not falling.
- The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement.
- **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`).
- **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan).
- **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before.
- [ ] **Step 3: On pass — record the milestone progress**
- Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs.
- If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan.
- Update the roadmap G.3 row + the milestones doc (G.3a core landed).
- Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans.
---
## Self-Review
**Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):**
- Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick).
- Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`.
- #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`).
- Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2.
- Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3).
- Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log.
- Decouple physics/visibility hydration from the render-mesh guard → Task 3.
- Visual gate (also settles #95 + hydration coupling) → Task 4.
**Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code).
**Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func<Vector3,uint,ArrivalReadiness>` + `Action<Vector3,uint,bool>` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three.
**Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d).

View file

@ -0,0 +1,455 @@
# 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.