acdream/docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md
Erik 21bf97ed35 docs(render): REOPEN the render half — full retail-faithful redesign dossier (handoff + huge plan + 3 research docs)
The Phase W indoor seal did NOT land. The 2026-06-02 visual gate proved the interior render is fundamentally broken (#78: transparent walls, outdoor terrain + scenery entities bleeding in, grey floors, no outside-looking-in). Stage 4 (sky-through-door clip) was real but a top layer on a base that never sealed.

DECISIVE EVIDENCE (committed in the handoff): the PVS computes correctly AND the cell shells render correctly (opaque, textured, complete — the [shell] probe shows zero NOSNAP / zero missing-texture). The failure is the SEAL + three inconsistent gates — concretely the WbDrawDispatcher.cs:1756 ParentCellId==null -> return true bypass draws outdoor scenery indoors, and the indoor path draws the outdoor world then gates it instead of running ONLY DrawInside. Retail, when inside, runs ONE PView flood: visibility IS the cull; the landscape enters only through clipped exit portals + a conditional depth-only clear.

Dossier (per the user's mandate: NO shortcuts/bandaids, port from retail, redesign the whole pipeline if needed, brainstorm first):
- Master handoff (root cause + retail target + reusable-vs-redesign + apparatus + do-not-repeat + copy-paste pickup prompt).
- Huge staged redesign plan R0(brainstorm)->R1(one visibility authority, kill the bleed)->R2(indoor=DrawInside-only)->R3(the seal, DrawCells port)->R4(per-cell object/particle clip)->R5(outside-looking-in)->R6(dungeons)->R7(polish/conformance). Each ends at a user visual gate.
- 3 research docs: full retail render pipeline reference (705 lines, decomp-verified), acdream pipeline inventory + failure map, reference cross-check (WB two-pipe is the wrong model).

#78 promoted to the redesign. The 5 remaining Core test failures are pre-existing physics/collision bugs, none render-related.

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

526 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# acdream Render Pipeline — Inventory and Failure Map (2026-06-02)
> Produced as a full redesign handoff after the Phase W session (Stage 3 + Stage 4
> shipped, membership fix landed). Incorporates live probe evidence from the
> Holtburg cottage run captured the same session.
---
## 1. Per-frame draw order
All code lives in `GameWindow.OnRender`. Line numbers are from
`src/AcDream.App/Rendering/GameWindow.cs`.
```
~7140 [visibility root]
physicsRoot = DataCache.CellGraph.CurrCell → TryGetCell → physicsRoot
visibility = _cellVisibility.ComputeVisibilityFromRoot(physicsRoot, visRootPos=PLAYER)
→ VisibilityResult { CameraCell, VisibleCellIds (old BFS set), HasExitPortalVisible }
cameraInsideCell = visibility?.CameraCell != null
rootSeenOutside = physicsRoot?.SeenOutside ?? true
~7167 [compute render booleans]
renderSky = !cameraInsideCell || rootSeenOutside
playerInsideCell = cameraInsideCell && !rootSeenOutside (sealed dungeon)
~7250 [PrepareRenderBatches] — EnvCellRenderer
_envCellRenderer.PrepareRenderBatches(envCellViewProj, camPos, filter:null, …)
(CPU snapshot; builds per-cell batches. Always called. Cheap.)
~7290 [ClipFrame / PortalVisibilityBuilder] ← THE AUTHORITATIVE VISIBILITY FRAME
if clipRoot (indoor root):
pvFrame = PortalVisibilityBuilder.Build(clipRoot, visRootPos=PLAYER, envCellViewProj=EYE)
→ PortalVisibilityFrame { OutsideView, CellViews, OrderedVisibleCells }
clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame)
→ TerrainClipMode { Skip | Scissor | Planes }
→ CellIdToSlot (per-visible-cell clip region)
→ OutdoorVisible / OutdoorSlot
→ HasOutsideView / OutsideViewNdcAabb
_wbDrawDispatcher.SetClipRouting(CellIdToSlot, OutdoorSlot, OutdoorVisible)
_envCellRenderer.SetClipRouting(CellIdToSlot)
envCellShellFilter = HashSet<uint>(CellIdToSlot.Keys)
else (outdoor root):
_clipFrame.Reset() — no-clip, slot 0 for everything
_wbDrawDispatcher.ClearClipRouting()
~7373 _clipFrame.UploadShared(_gl) ← pushes TerrainUBO + RegionSsbo to GPU
_wbDrawDispatcher.SetClipRegionSsbo(...)
_envCellRenderer.SetClipRegionSsbo(...)
_terrain.SetClipUbo(...)
~7378 [Stage 4] sky pre-scene (LScape — drawn FIRST through the doorway)
drawSkyThisFrame = renderSky && (clipAssembly==null || clipAssembly.HasOutsideView)
skyDoorwayClip = clipAssembly!=null && clipAssembly.HasOutsideView
if drawSkyThisFrame:
BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb) ← clips to portal opening
re-bind TerrainClipUBO at binding=2
Enable ClipDistance0..7
_skyRenderer.RenderSky(...) ← sky mesh (gl_ClipDistance gated)
Disable ClipDistance0..7
_particleRenderer.Draw(SkyPreScene) ← particles ONLY inside scissor; NO cell gate
EndScissor
~7431 [pre-login guard] if IsLiveModeWaitingForLogin → goto SkipWorldGeometry
~7444 Enable ClipDistance0..7 ← world-geometry bracket opens
~7464 [terrain]
if terrainClipMode == Skip → nothing drawn
elif terrainClipMode == Scissor → glScissor to OutsideView AABB → _terrain.Draw → disable
else (Planes) → _terrain.Draw (UBO gated per-vertex)
~7523 [Stage 4] conditional doorway Z-clear
if skyDoorwayClip:
glScissor to OutsideViewNdcAabb
glClear(DepthBufferBit) ← depth only; color preserved
EndScissor
~7538 [cell shells — opaque pass]
if clipAssembly!=null && envCellShellFilter!=null:
_envCellRenderer.Render(Opaque, envCellShellFilter)
(per-cell geometry from PrepareRenderBatches; clip-gated via binding=3 slot map)
~7546 [entities]
_wbDrawDispatcher.Draw(camera, LandblockEntries, frustum,
visibleCellIds: visibility?.VisibleCellIds, ← OLD BFS set, NOT pvFrame
animatedEntityIds: animatedIds)
Per-entity slot routing (SetClipRouting active = indoor root):
serverGuid != 0 (live-dynamic) → slot 0 (unclipped)
ParentCellId in CellIdToSlot → cell's clip slot
ParentCellId null → OutdoorSlot or CULL (OutdoorVisible)
ParentCellId not in CellIdToSlot → CULL
IsShellScopedSet always returns false (U.1 deleted shell sets); so outdoor
scenery (ParentCellId==null) routes to OutdoorSlot/CULL — BUT clip routing
only fires for indoor root (SetClipRouting active). Outdoor root: ALL slot 0,
nothing culled → outdoor scenery ALWAYS draws when outdoors.
~7553 [cell shells — transparent pass]
if clipAssembly!=null && envCellShellFilter!=null:
_envCellRenderer.Render(Transparent, envCellShellFilter)
~7561 Disable ClipDistance0..7 ← world-geometry bracket closes
~7568 [Scene particles]
_particleRenderer.Draw(Scene)
NO cell filter; no clip planes. All particles draw regardless of camera location.
~7583 [Stage 4] sky post-scene (weather / rain)
if drawSkyThisFrame:
BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb)
re-bind TerrainClipUBO
Enable ClipDistance0..7
_skyRenderer.RenderWeather(...) ← rain cylinder clipped to doorway
Disable ClipDistance0..7
_particleRenderer.Draw(SkyPostScene) ← NO cell gate; scissor only
EndScissor
~7739 SkipWorldGeometry: (pre-login goto target)
```
---
## 2. The gate table — the headline inconsistency
| What is gated | Gate source | Computed by | Consistency verdict |
|---|---|---|---|
| **Terrain** | `terrainClipMode` + UBO planes | `ClipFrameAssembler``PortalVisibilityBuilder` (pvFrame OutsideView) | CORRECT — skips when no exit portal visible. Planes-mode clips to portal opening per-vertex. Scissor-mode over-includes. |
| **Cell shells** | `envCellShellFilter` (CellIdToSlot.Keys) + per-instance clip slot (binding=3) | Same pvFrame / ClipFrameAssembler | CORRECT — only draws cells whose clip region is non-empty. |
| **Indoor static entities** (ParentCellId set) | `CellIdToSlot` lookup via `ResolveSlotForFrame` | Same pvFrame / ClipFrameAssembler | CORRECT for indoor statics. CULL when cell not visible. |
| **Outdoor scenery / building shells** (ParentCellId == null) | `OutdoorVisible` → OutdoorSlot or CULL | Same pvFrame / ClipFrameAssembler | GATED when `_clipRoutingActive` (indoor root only). Clips to OutsideView slot. BUT: `IsShellScopedSet` always returns false (shell sets deleted in U.1), so the `IsBuildingShell` anchor-cell branch in `EntityPassesVisibleCellGate` is dead code — the live path at line 1756 returns `true` unconditionally for null-ParentCellId when NOT clip-routing. See §3. |
| **Live-dynamic entities** (serverGuid != 0) | None — slot 0, always unclipped | N/A | INTENTIONAL — matches retail. |
| **Particles (Scene / SkyPost / SkyPre)** | Sky pre/post: doorway scissor only. Scene: NONE | N/A | BROKEN: no cell gate. Issue #104 (deferred). |
| **Sky mesh** | `drawSkyThisFrame` (seen_outside + HasOutsideView) + gl_ClipDistance against TerrainClipUBO | Same pvFrame | CORRECT — gated to portal opening or full-screen outdoors. |
### The core inconsistency (three gates, not one)
```
GATE #1 — Terrain:
Computed from pvFrame.OutsideView (PortalVisibilityBuilder) → TerrainClipMode
Skip ⇒ 0 terrain. Planes ⇒ clipped to portal. Scissor ⇒ AABB over-include.
SOURCE: ClipFrameAssembler (~GameWindow.cs:7322)
GATE #2 — Cell shells:
Computed from pvFrame.CellIdToSlot (same PortalVisibilityBuilder) → envCellShellFilter
Only draws cells whose clip region is non-empty (correct, but incomplete seal).
SOURCE: ClipFrameAssembler (~GameWindow.cs:7333)
GATE #3 — Entities (WbDrawDispatcher):
Indoor statics: ParentCellId ∈ CellIdToSlot → correct.
Outdoor stabs (ParentCellId == null):
When _clipRoutingActive (indoor root): gated to OutdoorSlot or culled — looks correct.
BUT: visibleCellIds passed to EntityPassesVisibleCellGate is from
CellVisibility.ComputeVisibilityFromRoot → VisibilityResult.VisibleCellIds,
which is the OLD BFS (GetVisibleCellsFromRoot). This is a SEPARATE computation
from pvFrame. The two can agree, but they are NOT the same object.
SOURCE: _cellVisibility.ComputeVisibilityFromRoot (~GameWindow.cs:7166)
NOT from PortalVisibilityBuilder.
```
**The duplication:** `CellVisibility.ComputeVisibilityFromRoot` (old BFS, produces `VisibilityResult.VisibleCellIds`) and `PortalVisibilityBuilder.Build` (new pvFrame BFS) are TWO SEPARATE portal traversals per frame. The entity dispatcher consumes the old one via `visibleCellIds`; terrain + shells consume the new one via `pvFrame`. When they disagree, entities draw in cells whose shells are culled, or vice versa.
**The structural gap (issue #78 root cause):** outdoor scenery (ParentCellId == null: houses, trees, landblock-baked stabs) bypasses the entity gate entirely when NOT clip-routing (outdoor root). When clip-routing is active (indoor root), they route to OutdoorSlot/cull — BUT the `IsShellScopedSet` branch that would use `BuildingShellAnchorCellId` for building-shell culling is dead code (always returns false, line 1761). So building shells registered with `IsBuildingShell=true` but no `BuildingShellAnchorCellId` still pass. The result: some outdoor geometry leaks through the portal restriction.
**Retail's design:** one PView traversal (`PView::ConstructView``ClipPortals``AddViewToPortals`, retail decomp `:433750`). Every renderer consumes the SAME regions. There is no separate entity-visibility BFS.
---
## 3. Per-renderer notes
### 3.1 EnvCellRenderer (`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`)
- **What it does:** draws indoor cell geometry (walls/floor/ceiling of EnvCells). WB port
(`WB EnvCellRenderManager.cs`). Per-cell batches prepared by `PrepareRenderBatches`
(~line 530), drawn in two passes (Opaque / Transparent) via `Render(pass, filter)` (~line 790).
- **Clip mechanism:** per-instance clip-slot buffer (binding=3, `_clipSlotBuffer`). Each
instance stores its `CellIdToSlot` slot index. The shared clip-region SSBO (binding=2,
`ClipFrame.RegionSsbo`) is handed in via `SetClipRegionSsbo`.
- **Diagnostics:**
- `ACDREAM_PROBE_SHELL=1``[shell]` line each opaque pass (~line 943). Reports per-cell:
`gfx=N` (gfxObj count), `tf=N` (transform count), `batch=N`, `idx=N` (index count / 3 = tris),
`tr=N` (translucent batches), `zh=N` (zero-handle = missing bindless texture).
Diagnosis tree: `NOSNAP` = cell not in snapshot (no batches prepared); `zh>0` = geometry
present but texture handle zero (invisible); `idx>0 + zh=0 + tr=0` = opaque geometry drawn,
fault is depth/occlusion not the shell itself.
- **Session evidence:** all visited cells (01700175) showed `idx>0 + zh=0 + tr=0 + tr=0`.
Shells ARE drawn, textured, opaque. Max `filter=3` cells rendered at once (correct for a
3-cell visibility set). ZERO `NOSNAP`.
- **Known issue (U.4 fix):** GL state (Blend, DepthMask, uViewProjection, CullMode cache)
must be set self-contained at entry to each `Render()` call. Bugs were hit 3× in Phase U.4
when state bled from prior renderers. Fixed by explicit state setup at ~line 810 + 1010.
`CullMode.Landblock → CullMode.None` override at line 1216 renders cell polys double-sided
as a stopgap (architectural cause not yet resolved).
### 3.2 TerrainModernRenderer (`src/AcDream.App/Rendering/TerrainModernRenderer.cs`)
- **What it does:** draws all loaded landblock terrain via `glMultiDrawElementsIndirect`.
Single global VBO/EBO, one slot per landblock (~line 26 onwards).
- **Clip mechanism:** TerrainClipUBO (binding=2, `ClipFrame.TerrainUbo`) handed in via
`SetClipUbo`. When the UBO count > 0 (Planes mode), `terrain_modern.vert` clips per-vertex
via `gl_ClipDistance`. When count==0 (outdoor root / Scissor mode), ungated.
- **Gate in GameWindow:** `terrainClipMode` (~line 7464):
- `Skip` → no draw at all (correct for sealed cellar: OutsideView is empty).
- `Scissor``glScissor` to `TerrainScissorNdcAabb`; UBO count 0 (no plane gating).
This mode OVER-INCLUDES (everything in the scissor box draws, not just through the portal).
- `Planes` → draws normally; UBO planes gate per-vertex.
- **Known gap:** Scissor-mode over-inclusion. When the OutsideView polygon exceeds the
convex-plane budget, terrain is scissored to its NDC AABB rather than clipped to the
actual portal shape. An unusually large or non-convex doorway can let terrain bleed.
### 3.3 WbDrawDispatcher (`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`)
- **What it does:** draws all world entities (static scenery, NPCs, doors, items) via
`glMultiDrawElementsIndirect`. Per-instance clip slots (binding=3) + shared clip SSBO
(binding=2).
- **Entity gate:** `EntityPassesVisibleCellGate` (static, line 1739):
- `visibleCellIds == null` → pass (outdoor root: nothing culled).
- `ParentCellId` set → pass iff cell ∈ `visibleCellIds`.
- `ParentCellId == null && IsBuildingShell && IsShellScopedSet` → anchor-cell check. BUT
`IsShellScopedSet` always returns `false` (line 1761, U.1 deletion). So this branch is
dead.
- `ParentCellId == null && !(the above)`**unconditionally returns `true`** (line 1756).
This is the outdoor-scenery bypass: houses, trees, landblock-baked stabs all pass when
`visibleCellIds` is non-null but they have no `ParentCellId`.
- **Clip-slot routing** (when `_clipRoutingActive`, i.e. indoor root):
- `serverGuid != 0` → slot 0 (unclipped — retail behavior).
- `ParentCellId in CellIdToSlot` → cell's clip slot.
- `ParentCellId == null && OutdoorVisible` → OutdoorSlot (gated to OutsideView).
- `ParentCellId == null && !OutdoorVisible` → CULL.
- `ParentCellId not in CellIdToSlot` → CULL.
- When NOT `_clipRoutingActive` (outdoor root): all slot 0, no culling.
- **The gap:** `visibleCellIds` passed by GameWindow is `visibility?.VisibleCellIds` (old
`CellVisibility` BFS), not `pvFrame.OrderedVisibleCells` (new `PortalVisibilityBuilder`
BFS). These are parallel traversals of the same graph; in practice they should agree for
the simple cottage graph, but they can diverge for complex portal topologies.
### 3.4 CellVisibility (`src/AcDream.App/Rendering/CellVisibility.cs`)
- **What it does:** OLD portal visibility system. `ComputeVisibilityFromRoot(root, pos)` does
a simple BFS through `LoadedCell.Portals`, collecting the full reachable set as
`VisibleResult.VisibleCellIds` (HashSet). Sets `CameraCell` when inside a cell.
Also produces `HasExitPortalVisible` (a portal with `OtherCellId == 0xFFFF` was reached).
- **Used for:** `visibility?.VisibleCellIds` → entity gate in `WbDrawDispatcher.Draw`.
Also for `cameraInsideCell`, `physicsRoot` lookup.
- **NOT used for:** terrain gating, shell filter, clip-slot assignment. Those come from
`PortalVisibilityBuilder`.
- **Note:** `FindCameraCell` AABB grace-frame fallback was DELETED in Stage 3 (2026-06-02).
`ComputeVisibilityFromRoot(null, …)` returns `null` (outdoor root) — no AABB scan fallback.
### 3.5 PortalVisibilityBuilder (`src/AcDream.App/Rendering/PortalVisibilityBuilder.cs`)
- **What it does:** the NEW portal-clip visibility BFS (port of retail `PView::ConstructView`
`:433750`). Produces `PortalVisibilityFrame`: per-cell `CellView` (screen-space NDC clip
region), `OutsideView` (union of all exit-portal regions), `OrderedVisibleCells`
(closest-first list).
- **Used for:** terrain clip mode, shell filter, entity clip slots (via `ClipFrameAssembler`).
- **KEEP** — this is the correct retail-faithful PView kernel. Output is correct (confirmed
by `[vis]` probe: cellar 0174 shows `outPolys=0 / terrain=Skip`; room 0171 shows
`outPolys=1 / terrain=Planes`).
### 3.6 ClipFrame / ClipFrameAssembler / ClipPlaneSet
- **What it does:** CPU → GPU bridge. `ClipFrameAssembler.Assemble` translates the
`PortalVisibilityFrame` into a `ClipFrameAssembly`: per-cell plane arrays packed into a
SSBO (`RegionSsbo`), a UBO for terrain (`TerrainUbo`), `CellIdToSlot` map, `TerrainClipMode`.
- **KEEP** — pure CPU logic, GL-free, fully unit-tested. The slot/gate policy is correct.
### 3.7 ParticleRenderer (`src/AcDream.App/Rendering/ParticleRenderer.cs`)
- **What it does:** draws particle emitters for all three passes (SkyPreScene, Scene,
SkyPostScene). `particle.vert` has NO `gl_ClipDistance` support.
- **Gating:** SkyPreScene and SkyPostScene are bounded by the doorway scissor
(`BeginDoorwayScissor`) when `skyDoorwayClip` is set. Scene pass: **no gate at all** — draws
every particle regardless of camera location.
- **Known gap:** issue #104 (deferred). Inside a sealed cellar, `Scene` particles from the
outdoor network-portal (e.g. Holtburg Town portal effect) are still drawn, visible through
walls.
### 3.8 SkyRenderer (`src/AcDream.App/Rendering/Sky/SkyRenderer.cs`)
- **What it does:** sky mesh (celestial objects) + weather (rain cylinder). Uses `sky.vert`
which writes `gl_ClipDistance` from the same binding=2 TerrainClipUBO.
- **Gate:** `drawSkyThisFrame = renderSky && (clipAssembly==null || HasOutsideView)`.
Indoor with no exit portal → no sky (seen_outside=false for dungeon; HasOutsideView=false
for a room whose portal is around a corner). Indoor with visible portal → sky/weather
clipped to doorway via gl_ClipDistance (Planes mode) or glScissor (Scissor mode).
- **KEEP** — Stage 4 correctly implements the retail LScape draw split.
---
## 4. Failure map — 5 user symptoms → root cause → gate
**Probe context:** `[vis]` probe confirms PVS is correct.
`[shell]` probe confirms shells ARE drawn (idx>0, zh=0, tr=0).
Player moves 0170→0171→0175→0174 correctly via `[cell-transit]`.
### Symptom 1: From outside through a door/window, interior walls are transparent
- **Root cause:** outside-looking-in (U.5) — when the camera is outdoors and the player
is outdoors, `clipRoot == null` → outdoor root → `_clipRoutingActive = false` → every
entity slot 0, terrain ungated, shells not drawn (shell rendering is only active when
`clipAssembly != null`). EnvCell shells are NEVER rendered from the outdoor root.
The indoor cells are not in the WbDrawDispatcher entity walk because they have
`ParentCellId` set to cells that may not be in `visibleCellIds` (which is `null`
outdoors).
- **Gate:** GATE #2 (shell rendering only active for indoor root). No outdoor → indoor
visibility pipeline exists yet.
- **Status:** planned Phase U.5 / deferred by the render-reset mandate.
### Symptom 2: Inside you see only the outdoor world + NPCs/particles/doors (no interior)
- **Root cause (indoor root path):** shells ARE drawn (probe confirmed). But outdoor terrain
renders on top and clips through the shells because:
(a) **Terrain** (Gate #1): in Planes mode, outdoor terrain is clipped to the portal-plane
region, but since the cottage room has an exit portal, terrain draws THROUGH the doorway
region. In Scissor mode it draws through the portal bounding box. The shells do not
depth-occlude the terrain if terrain draws after the shells — but terrain draws BEFORE
the shells (line 7464 vs 7538). So: terrain draws first, shells draw on top. This is
correct draw order. The issue is that the terrain PLANE region covers the entire portal
opening (the door/window), not just the area past the wall, so terrain pixels are
written inside the room via the doorway.
(b) **Outdoor stabs** (Gate #3, line 1756): `ParentCellId==null` entities bypass
`visibleCellIds`, always pass the `EntityPassesVisibleCellGate`. When `_clipRoutingActive`,
they route to OutdoorSlot (clipped to OutsideView) or are culled when `!OutdoorVisible`.
For a cellar with `seen_outside=true` but sealed walls (OutsideView present), the stabs
are clipped to the OutsideView AABB — but an AABB is a rectangle, not the actual doorway
silhouette, so stabs visible in the same NDC box as the doorway still draw.
- **Gate:** GATE #1 (terrain) leaks through the portal opening; GATE #3 (entity) allows
outdoor stabs to render through any pixel in the OutsideView bounding box.
### Symptom 3: Looking out from inside, you see houses/trees through the ground
- **Root cause:** same as Symptom 2 from the other direction — outdoor stabs have
`ParentCellId==null` → unconditionally pass `EntityPassesVisibleCellGate` (line 1756).
When inside, clip routing is active and they route to OutdoorSlot. If `OutdoorVisible`
is true (there IS an exit portal), they clip to the OutsideView AABB — but the AABB
over-includes relative to the actual portal polygon. If `OutdoorVisible` is false (cellar
with no direct exit portal in view) they should be culled — and the `[vis]` probe shows
`terrain=Skip` for the sealed cellar, meaning this is correct for terrain. But stabs
still leak through because clip-slot routing uses `outdoorSlot` (which points to the
OutsideView region, computed per frame), not a true exclusion.
- **The deeper gap:** even when `OutdoorVisible=false` (terrain Skip), the `ClipSlotCull`
sentinel at line 399 (`return outdoorVisible ? outdoorSlot : ClipSlotCull`) should cull
outdoor stabs. Let's verify this is actually reaching that code: `ResolveSlotForFrame`
(line 414) gates on `_clipRoutingActive`. When active and `OutdoorVisible=false`,
`ResolveEntitySlot` returns `ClipSlotCull``culled=true` → entity dropped. This is
CORRECT in theory. But the symptom persists, suggesting either:
(a) `_clipRoutingActive` is not set on frames where the symptom shows (possible race
between PrepareRenderBatches and Render), or
(b) some outdoor stabs have `serverGuid != 0` (live-dynamic) and thus take the
slot-0 / always-unclipped branch.
- **Gate:** GATE #3 (entity). The theory says it should work for dat-hydrated stabs when
`OutdoorVisible=false`; if the symptom still appears from the cellar, (b) is likely.
### Symptom 4: On the cellar stairs, walls show but floor is grey and entities show through walls above
- **Root cause (grey floor):** terrain is rendering. For cell 0175 (stairs), `seen_outside=true`
→ terrain draws. The stairs cell has an exit portal chain, so `OutsideView` is non-empty,
`terrain=Planes`. Terrain at the landblock Z level renders through the portal-plane region.
The stair geometry is at a higher Z than the terrain; the terrain wins depth for some pixels
→ grey floor (terrain texture visible through the stair floor polygon).
- **Root cause (entities through walls above):** live-dynamic entities (`serverGuid != 0`)
take slot 0 (unclipped) unconditionally (line 391: `if (serverGuid != 0) return 0`).
This matches retail intent, but means NPCs / doors visible through walls above the stairs
are working as designed — they are depth-tested only, not portal-clipped.
- **Gate:** terrain GATE #1 (Planes clip is correct in theory but terrain at ground level
is visible through a downward-looking portal); entity unclipped slot for live-dynamics.
### Symptom 5: In the cellar you see grey world instead of the floor
- **Root cause (confirmed by `[vis]` probe):** cellar 0174 shows `outPolys=0 / terrain=Skip`.
The cellar IS correctly sealed — terrain does NOT draw for the cellar frame (Skip mode).
The grey is NOT outdoor terrain. It is the **GL clear color** (background) showing through
because:
(a) The cell shell DOES draw (`[shell]` probe: 0174 `idx=42`, 14 triangles, opaque, zh=0).
But the FLOOR polygon of the cellar GfxObj may not be present in the dat's polygon list
(the cellar has no floor polygon — only walls). The floor visual is a separate landblock
terrain tile at the cellar depth, normally occluded by the terrain mesh. With terrain
SKIPPED (correct!), nothing draws at that Z level — the clear color shows through.
(b) Alternatively, the floor is present but the GfxObj has `CullMode.Landblock` remapped to
`CullMode.None` (EnvCellRenderer line 1216). If the floor polygon's normal points up,
it still draws. If the floor is missing from the dat, nothing closes the bottom.
- **This is a geometry/dat gap, not a gating bug** — the cellar cell may simply not
have a floor polygon in the EnvCell dat, expecting the terrain to serve as the floor
(retail gets away with this because the terrain is never skipped in retail for a
`seen_outside=true` cell with an exit portal reachable — retail draws terrain for every
cell it visits in the PView walk, not just when an OutsideView exists). The Skip mode
(correct for a SEALED dungeon with no exit portal) is too aggressive for a `seen_outside=true`
cellar where the exit portal is simply behind you / not in the current frustum.
- **Gate:** GATE #1 (terrain). `Skip` fires when `OutsideView.IsNothingVisible` — this is
correct for a dungeon, but for a `seen_outside=true` building interior it fires whenever
the exit portal is behind the camera, which removes the terrain floor.
---
## 5. REUSABLE vs REDESIGN inventory
### KEEP (correct, retail-faithful, well-tested)
| Component | What to keep | Why |
|---|---|---|
| `PortalVisibilityBuilder` | Entire file | Correct PView BFS port. Probe-confirmed: OutsideView polygons, ordered cells, exit-portal detection all correct. |
| `ClipFrame` / `ClipPlaneSet` | Entire files | GL-free, unit-tested. The slot/gate policy and SSBO layout are correct. |
| `ClipFrameAssembler` | Entire file + tests | Correct translation of pvFrame → GPU slots. `TerrainClipMode.Skip` = correct for sealed dungeon. |
| `LoadedCell.SeenOutside` | Field + hydration | Correct retail anchor (`acclient.h seen_outside`). Stage 3 uses it correctly for sky/sun gate. |
| Doorway Z-clear (Stage 4) | GameWindow ~7523 | Correct retail port of `PView::DrawCells:432731`. |
| Sky clip to OutsideView (Stage 4) | SkyRenderer path | Correct retail LScape split. gl_ClipDistance gating works. |
| `CellVisibility.ComputeVisibilityFromRoot` | Root-selection logic only | The physics-membership root selection is correct (Stage 3 fix). The BFS body can be retired once entity dispatch reads from pvFrame directly. |
| `EnvCellRenderer` | Geometry/texture/MDI path | `[shell]` probe confirms correct geometry, textures, depth, state. Keep the renderer; redesign the gating. |
| `TerrainModernRenderer` | Renderer itself | Works correctly. Just needs its caller to supply the correct clip mode. |
| `WbDrawDispatcher` slot-routing | `ResolveEntitySlot` logic (lines 381400) | The policy is retail-faithful for indoor statics + live-dynamics. The gap is the outdoor-stab bypass, fixable without redesigning the dispatcher. |
| All diagnostic probes | `ACDREAM_PROBE_SHELL`, `PROBE_VIS`, `PROBE_FLAP`, `PROBE_CELL`, `PROBE_SWEPT` | Essential apparatus. Keep until the pipeline redesign is visual-verified. |
### REDESIGN / FIX
| Component | Problem | Fix direction |
|---|---|---|
| `WbDrawDispatcher.EntityPassesVisibleCellGate` line 1756 | `ParentCellId==null` returns `true` unconditionally (outdoor stab bypass). | When `_clipRoutingActive` AND `!OutdoorVisible`: return false. The `ResolveSlotForFrame` path already culls via `ClipSlotCull` for the clip-slot mechanism — verify that path is actually reached and the `IsBuildingShell` anchor branch fires for shells. |
| `WbDrawDispatcher.Draw` `visibleCellIds` parameter | Sourced from `CellVisibility` old BFS, not from `pvFrame`. Two separate traversals per frame. | Retire `VisibilityResult.VisibleCellIds` as the entity gate; use `pvFrame.OrderedVisibleCells` (same data, one traversal). |
| `CellVisibility.GetVisibleCellsFromRoot` BFS | Duplicate of PortalVisibilityBuilder's BFS (minus clip regions). Called once per frame alongside pvFrame. | Retain only `CameraCell` derivation (needed for `clipRoot`); discard the `VisibleCellIds` set. |
| `TerrainClipMode.Skip` trigger | Fires when `OutsideView.IsNothingVisible` — correct for dungeon, but fires for a `seen_outside=true` building interior when the exit portal is behind the camera. Removes the terrain floor for the cellar case. | Gate terrain-skip on `!physicsRoot?.SeenOutside` (i.e. Skip ONLY for dungeons where `seen_outside=false`). When `seen_outside=true`, always draw terrain at least at clip-mode Planes/Scissor. |
| `ParticleRenderer.Draw(Scene)` | No cell filter; no clip planes. Particles draw everywhere. | Add an indoor gate: when `clipAssembly != null` (indoor root), scissor Scene particles to OutsideView AABB. Full clip-slot support for particles is a larger change (particles have no instanceID). |
| `EnvCellRenderer` CullMode.Landblock → None override (line 1216) | Architectural stopgap — renders cell polys double-sided. The real winding issue hasn't been resolved. | Investigate whether AC's EnvCell geometry has consistent winding (retail uses backface cull). If winding is consistent, remove override and set CullMode per polygon's winding from the dat. |
| Outside-looking-in (U.5) | No pipeline for outdoor-camera → indoor-cell visibility. Shell rendering is only active for indoor root. | Phase U.5: add outdoor shell pass when `CellVisibility` detects a nearby building cell in the frustum. |
---
## 6. The diagnostic apparatus
### Probes: env vars and what they emit
| Env var | C# property | Emission site | Line format | When to use |
|---|---|---|---|---|
| `ACDREAM_PROBE_CELL=1` | `PhysicsDiagnostics.ProbeCellEnabled` | `PlayerMovementController.cs:776` | `[cell-transit] old=0x... new=0x... pos=(x,y,z) reason=...` | Confirm cell membership changes. Low volume (fires only on transitions). |
| `ACDREAM_PROBE_VIS=1` | `RenderingDiagnostics.ProbeVisibilityEnabled` | `GameWindow.cs:7338` | `[vis] root=0x... cells=[...] outPolys=N outPlanes=N per-cell:{0x...:N,...}` | Confirm PVS output. Cell-change-throttled to stay readable. |
| `ACDREAM_PROBE_FLAP=1` | `RenderingDiagnostics.ProbeFlapEnabled` | `GameWindow.cs:7352`, `PortalVisibilityBuilder.cs:235` | `[flap-cam] root=0x... res=None eyeInRoot=Y/n eye=... terrain=... outVisible=...` | Check whether terrain/portals flap. High volume; use briefly. |
| `ACDREAM_PROBE_SHELL=1` | `RenderingDiagnostics.ProbeShellEnabled` | `EnvCellRenderer.cs:950` | `[shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:gfx=N tf=N batch=N idx=N tr=N zh=N]` | Confirm cell shells are drawn per frame. Opaque pass only. |
| `ACDREAM_PROBE_SWEPT=1` | `PhysicsDiagnostics.ProbeSweptEnabled` | `PhysicsEngine.cs:861` | `[swept-sphere] ...` | Physics swept-sphere diagnostics (not render). |
| `ACDREAM_PROBE_PUSH_BACK=1` | `PhysicsDiagnostics.ProbePushBackEnabled` | BSPQuery.cs | `[push-back]`, `[push-back-disp]`, `[push-back-cell]` | A6 apparatus; heavy under motion. |
| `ACDREAM_PROBE_FLAP=1` (builder) | same | `PortalVisibilityBuilder.cs:235` | `[flap] camCell=0x... portals=N TRV/SKIP entries...` | Portal traversal trace per frame. Heavy. |
Runtime-toggleable via DebugPanel (F11 → Diagnostics checkboxes) without relaunch
for `PROBE_CELL` and `PROBE_VIS`.
### Reading logs on Windows (launch.log is UTF-16 LE from PowerShell Tee-Object)
Do NOT use GNU `grep` on `launch.log` — it interprets UTF-16 as binary.
**Correct approach (PowerShell):**
```powershell
# Filter [shell] lines
Select-String -Path launch.log -Pattern '^\[shell\]' | Select-Object -ExpandProperty Line
# Filter [vis] lines
Select-String -Path launch.log -Pattern '^\[vis\]' | Select-Object -ExpandProperty Line
# Filter [cell-transit] lines
Select-String -Path launch.log -Pattern '^\[cell-transit\]' | Select-Object -ExpandProperty Line
```
**Correct approach (ripgrep / Grep tool) — works with UTF-16 LE when -E flag is set:**
```
rg --encoding utf-16-le '\[shell\]' launch.log
rg --encoding utf-16-le '\[vis\]' launch.log
```
If the log was tee'd with PowerShell's default `Tee-Object` (UTF-16 LE without `-Encoding utf8`),
every other byte is NUL. Read the file with `Get-Content launch.log | Select-String '\[shell\]'`
or force UTF-8 at launch time:
```powershell
dotnet run ... 2>&1 | Tee-Object -FilePath launch.log -Encoding utf8
```
### `[shell]` diagnosis tree
```
[shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:...]
├── NOSNAP → cell not in PrepareRenderBatches snapshot
│ Cause: cell not yet loaded (streaming lag) OR PrepareRenderBatches filter excluded it.
│ Action: check streaming for the cell; verify PrepareRenderBatches is called with filter:null.
├── gfx=0 → cell present in snapshot but no GfxObj batches
│ Cause: EnvCell has no renderable geometry (possible for corridor/transition cells).
│ Action: inspect dat with DatReaderWriter dump; verify the cell has a non-empty
│ EnvironmentId → GfxObj polygon list.
├── idx=0 → gfxObj present but zero index count
│ Cause: GfxObj loaded but ObjectMeshManager returned 0-index batch.
│ Action: check mesh staging; PrepareMeshDataAsync may not have completed for this GfxObj.
├── zh>0 → batch present with zero bindless texture handle
│ Cause: texture not yet uploaded to TextureCache; GfxObj mesh arrived before texture decode.
│ Action: wait for texture upload; check for TextureCache errors in log.
└── idx>0 + zh=0 + tr=0 → OPAQUE GEOMETRY DRAWN — fault is elsewhere
The shell is geometrically correct and textured. The problem is:
(a) Terrain/outdoor stabs render in front and are not gated to the portal opening,
or (b) outside-looking-in (U.5, no outdoor root shell pass).
This is the confirmed state for the current session.
```
---
## 7. Summary
The current pipeline has PVS that is **computationally correct** but enforces visibility
through **three inconsistent gates**:
1. **Gate 1 (terrain):** `PortalVisibilityBuilder``ClipFrameAssembler``TerrainClipMode`.
Correct for sealed dungeons. Over-aggressive `Skip` for `seen_outside=true` buildings
when portal is behind the camera.
2. **Gate 2 (shells):** same `PortalVisibilityBuilder` output → `envCellShellFilter`.
Correct. Only active for indoor root.
3. **Gate 3 (entities):** parallel OLD `CellVisibility` BFS → `VisibleCellIds` (set membership),
combined with `PortalVisibilityBuilder`-derived clip routing. The outdoor-stab bypass
(`ParentCellId==null``return true`, line 1756) is the primary architectural gap.
Retail has **one gate**: PView. One traversal, one region per cell, every geometry type
clipped to its portal-derived region. The fix is to make `PortalVisibilityBuilder` the
sole source of truth for ALL geometry types and retire `CellVisibility.VisibleCellIds` as
a rendering gate.
The `[shell]` probe evidence rules out: geometry missing, textures missing, wrong cull mode,
blend/depth state errors. The shells ARE drawn correctly. The residual is purely that outdoor
geometry is not gated to portal openings.