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>
This commit is contained in:
Erik 2026-06-02 18:28:01 +02:00
parent b595cfbb9f
commit 21bf97ed35
6 changed files with 2394 additions and 2 deletions

View file

@ -309,9 +309,16 @@ the indoor-lighting plumbing.
## #78 — Outdoor geometry (stabs + terrain mesh) visible inside EnvCells
**Status:** OPEN — **next-session investigation target (2026-05-25)**
**Status:** OPEN — **PROMOTED 2026-06-02 to the full render-pipeline redesign** (this IS the
core interior-seal bug; root cause now PROVEN). See
[docs/research/2026-06-02-render-pipeline-redesign-handoff.md](research/2026-06-02-render-pipeline-redesign-handoff.md)
+ [the redesign plan](superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md). Decisive evidence
(2026-06-02 [shell]/[vis] probes): the PVS + cell shells render correctly; 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 render draws the outdoor world then gates it
instead of running ONLY `DrawInside` (retail: visibility IS the cull). Fix = redesign Phase R1→R3.
**Severity:** HIGH (immediate visual jank; broadened scope per 2026-05-25 PM finding)
**Filed:** 2026-05-19 (broadened 2026-05-25)
**Filed:** 2026-05-19 (broadened 2026-05-25; promoted to redesign 2026-06-02)
**Component:** rendering, visibility
**Description:** Standing inside Holtburg Inn looking at the floor or

View file

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

View file

@ -0,0 +1,431 @@
# Render Pipeline Redesign — Master Handoff (2026-06-02)
> **Canonical pickup for the full render-pipeline redesign.** Phase W's interior "seal"
> did **not** land. The single final visual verification (user, 2026-06-02) proved the
> indoor render is fundamentally broken (issue **#78** — outdoor geometry visible inside
> EnvCells — plus transparent walls, missing-floor appearance, entity/scenery bleed, and
> no outside-looking-in). This document consolidates ALL research + the decisive live
> diagnostic evidence + the retail-faithful target + the mandate.
>
> **Read order for the next session:** this doc → the 3 research docs (§5§7 links) →
> then **BRAINSTORM** (`superpowers:brainstorming`) the architecture **before writing any
> code**. The plan in §10 is the spine; the brainstorm refines it with the user.
---
## 0. The mandate (user, 2026-06-02 — NON-NEGOTIABLE)
Quoting the user's intent directly:
- **FULLY WORKING** outdoor, indoor, and dungeon rendering. **No flaps, no missing
textures, no transparent walls, no ground/terrain texture leaking into the cellar, no
bleed-through.** Outside, inside, portal transitions, dungeons, particle clipping —
**EVERYTHING.**
- **No shortcuts. No bandaids. No quick fixes.** "If you have choices of doing something
fast when it might not be the best way or the most architecturally correct way, never
take that way." If the correct way is slower, take it anyway.
- **If you have to refactor and redesign the whole pipeline, DO THAT.**
- **Port from retail** decomp where needed — it is the oracle (`docs/research/named-retail/`).
- **If you need more research mid-session, do it** — never guess.
- **Start with a brainstorm** before implementing.
This supersedes the incremental "wire 3 gaps" framing of the Phase W render plan. The
render half of Phase W is **reopened as a ground-up, retail-faithful redesign.**
---
## 1. Current broken state — the visual gate (2026-06-02, Holtburg cottage)
The user walked the cottage and reported five distinct failures:
1. **Outdoor → looking through an open door/window:** interior walls are **transparent**
(you see into/through the house). *(= outside-looking-in not implemented; deferred U.5.)*
2. **Just inside:** you see **only the outdoor world background + NPCs/particles/doors**
no interior shell sealing you in.
3. **Inside → looking out:** sky is visible, but you see **houses and trees through the
ground**.
4. **On the cellar stairs:** the cellar walls show, but the **floor is grey**, and NPCs /
particles / doors show **through the walls above**.
5. **In the cellar:** you see the **grey world instead of the floor**.
Net: **the interior is not a sealed space.** This is issue **#78** ("outdoor geometry
visible inside EnvCells") in full, plus entity/scenery bleed and the missing
outside-looking-in path.
---
## 2. Decisive live evidence — the root cause is PROVEN (not theorized)
Captured this session via the in-tree probes (`ACDREAM_PROBE_CELL` / `_VIS` / `_SHELL`),
read from the UTF-16 `Tee-Object` logs. **These three facts together pinpoint the cause:**
### 2.1 The visibility math is CORRECT
- `[cell-transit]` shows the player moving cleanly through the cottage cells:
`0xA9B40031`(outdoor) → `0170`(vestibule) → `0171`(room) → `0175`(stairs) → `0174`(cellar).
Membership works (the Stage-1/2 fix `59f3a13` holds; some doorway flips are the player
genuinely crossing the threshold, not a strobe).
- `[vis]` shows the right visible set + portal openings, e.g.:
- `root=0xA9B40171 cells=2 ids=[0171,0170] outside(polys=1,planes=4)` — from the room,
the exit portal to outdoors IS detected (terrain/sky can draw through it).
- `root=0xA9B40174 cells=1 ids=[0174] outside(polys=0,planes=0)` — the cellar is
correctly **sealed** (no exit portal in view → terrain Skip).
- Per-cell clip-plane counts are populated. **`max filter=3`** cells in any single frame.
**⇒ The PVS (`PortalVisibilityBuilder`) is not the bug. It computes the correct answer.**
### 2.2 The cell shells RENDER CORRECTLY — opaque, textured, complete
The `[shell]` probe (added for #78) emits, per opaque pass, the actual geometry drawn.
Distinct patterns observed (frequency × line):
```
205977x filter=1 ... tris=2 [0xA9B40174:gfx=1 tf=1 batch=3 idx=42 tr=0 zh=0] (cellar: 14 tris)
1476x filter=3 ... tris=48 [0xA9B40171:...idx=270 tr=0 zh=0] [0173:idx=24] [0172:idx=144] (room+subcells: 90+8+48 tris)
130x filter=1 ... tris=38 [0xA9B40171:...idx=270 tr=0 zh=0] (room: 90 tris)
125x filter=2 ... [0xA9B40174:idx=42] [0xA9B40175:idx=24] (cellar+stairs)
75x filter=1 ... [0xA9B40175:idx=24] (stairs)
```
- **Zero `NOSNAP`** (no "geometry not prepared" cases). **Zero `zh>0`** (no missing-texture
cases). **Zero `tr`** (all opaque). Every visible cell draws its full, textured mesh.
- Per the probe's own diagnosis tree: `idx>0 + zh=0 + tr=0`**"opaque geometry IS drawn,
with valid textures — the fault is DEPTH/OCCLUSION, or the geometry isn't the wall."**
**⇒ The shells are not the bug either. They draw fine. This was NEVER a shader/texture/
missing-mesh problem** (which is what naive guesses would chase).
### 2.3 So the failure is the SEAL + the GATES
Putting 2.1 + 2.2 together with the symptoms: the visible cells draw correctly, but
- only **≤3 cells'** shells draw, and **they do not form a closed opaque occluder** over
the rest of the world, so the outdoor scene shows through wherever indoor geometry isn't;
- the **outdoor terrain + outdoor scenery entities** (the "houses and trees through the
ground" = `ParentCellId == null` stabs/scenery) are **not culled or depth-occluded** when
you're indoors — they draw and win;
- **outside-looking-in** draws no interior at all (no exterior→interior portal path; U.5).
This is the **three-inconsistent-gates** failure (see §3), the exact problem the project's
own memory (`render — one gate (PView)`) flags as the core unsolved indoor-render issue —
"the week of 2026-05-25→31 made no indoor-render progress because the pipeline had 3
inconsistent gates (terrain/shell/entity)."
---
## 3. Root-cause analysis — three inconsistent gates, no closed-occluder seal
acdream's render loop gates four classes of geometry, from **inconsistent sources**:
| Geometry | Gate in code | Source of truth | Problem |
|---|---|---|---|
| **Terrain** | `terrainClipMode` (Skip/Scissor/Planes) | `ClipFrameAssembler``PortalVisibilityBuilder` OutsideView | OK-ish, but only one of several gates |
| **Cell shells** | `envCellShellFilter = clipAssembly.CellIdToSlot.Keys` | `PortalVisibilityBuilder` | Draws ≤3 cells; never forms a *closed occluder* |
| **Entities** (incl. outdoor scenery, doors, NPCs) | `visibility?.VisibleCellIds` | **`CellVisibility.ComputeVisibilityFromRoot`** — a *different* traversal | Disagrees with the shell/terrain gate; **outdoor scenery (`ParentCellId=null`) not culled indoors** |
| **Particles** | *(none)* | — | Issue #104 — no cell clip at all |
| **Sky/weather** | `drawSkyThisFrame` (Stage 4) | `ClipFrameAssembler` | The only consistent one — but a top layer |
**Two distinct architecture defects:**
1. **Two visibility computations, not one.** `PortalVisibilityBuilder.Build` (the faithful
PView BFS) feeds the shell + terrain + sky gates. But the **entity** gate reads
`CellVisibility.ComputeVisibilityFromRoot` — an *older, separate* visibility path. They
are not guaranteed to agree, and the entity one does not properly cull outdoor scenery
when indoors. **Retail has ONE traversal (`PView`) that produces one `cell_draw_list`,
and every cell's objects are drawn (and only those) — there is no second gate.**
2. **No "closed interior occluder" model.** Retail's interior render is *sealed by
construction*: it draws the visible cells (whose `drawing_bsp` are closed boxes — floor,
walls, ceiling), draws `LScape` only through exit-portal clip regions, does a conditional
**Z-only** clear at the doorway, and draws each cell's objects clipped to that cell. The
outdoor world is *never drawn behind a wall* because the traversal simply never visits it
and the closed cells occlude it. acdream instead draws the outdoor world (terrain +
scenery) and *then* a few cell shells on top, relying on depth/gates that don't hold —
so the world bleeds through.
**The fix is not another gate or another clip. It is to make the render obey ONE
visibility answer for ALL geometry, with the interior sealed by drawing closed cells +
clipping LScape through portals — i.e., port retail's `PView::DrawCells` faithfully.**
---
## 4. Reusable vs Redesign (from the live evidence; research doc B §5 deepens this)
**KEEP (proven correct, build on these):**
- **Cell membership** — the transition-owned `find_cell_list` fix (`59f3a13`). `[cell-transit]`
confirms correct cell tracking. Do not reopen.
- **`PortalVisibilityBuilder`** (the PView BFS) — produces the correct visible set +
OutsideView + `OrderedVisibleCells`, with `seen`-HashSet termination (#102 closed). This
is the single visibility authority the redesign should route EVERYTHING through.
- **The sky/weather NDC-clip insight** (Stage 4, `ce2edad`/`b595cfb`): OutsideView clip
planes are screen-space half-spaces → projection-independent, so `sky.vert` clips the
dome exactly. Reusable once the base seals; keep the commits.
- **`ClipFrame` / `ClipPlaneSet` / `ClipFrameAssembler` / `PortalView`** — the clip-plane
machinery + the per-cell NDC regions. Reusable.
- **Cell shell rendering** (`EnvCellRenderer`) — draws correct opaque textured geometry.
Keep; the bug is *what gets drawn around it*, not the shells.
- **The diagnostic apparatus**`ACDREAM_PROBE_CELL/VIS/SHELL` + the `[shell]` diagnosis
tree. Indispensable; see §8.
**REDESIGN (the broken parts):**
- **The gating.** Collapse the two visibility computations into ONE (`PortalVisibilityBuilder`'s
visible set), and route terrain + shells + **entities** + particles through it. Delete the
`CellVisibility.ComputeVisibilityFromRoot` entity path (or make it the same answer).
- **The interior seal** — adopt retail's closed-cell + LScape-through-portal + Z-clear model
so the outdoor world is never drawn behind a wall (occlusion by construction, not by gate).
- **Outdoor-scenery gating**`ParentCellId=null` entities (houses, trees, stabs) must be
culled / occluded when indoors and not visible through a portal.
- **Particle cell-clip** (#104) — particles need a cell and must clip to the visible set.
- **Outside-looking-in (U.5)** — the exterior→interior portal path (look into a building
from outside and see its sealed interior, not transparent walls).
- **Dungeons** (#95) — validate BFS convergence + the no-landscape path on a real dungeon.
---
## 5. The retail-faithful target architecture
**→ Full reference: `docs/research/2026-06-02-retail-render-pipeline-full-reference.md`** (research doc A —
verified against the named-retail pseudo-C + `acclient.h` this session).
### 5.1 The ten load-bearing retail facts (THE architecture to port)
1. **One cell graph, one membership answer, render obeys it.** Physics tracks `curr_cell`
through the sweep; the camera tracks `viewer_cell`; both resolve via the same
`CObjCell::GetVisible`.
2. **The top-level decision is BINARY** (`RenderNormalMode @ 0x453aa0`): viewer in an
outdoor landcell → `LScape::draw` (full landscape). Viewer in an EnvCell → **`DrawInside`
only.** It is **NOT** "draw outdoors then draw cells on top." **When inside, the full
outdoor scene is never drawn.** ← *This single fact is the inversion of acdream's bug.*
3. **`DrawInside` = one PView portal flood** (`ConstructView → DrawCells`) producing the
`cell_draw_list` + per-cell clip regions + **one** `outside_view`.
4. **Exit portals (`other_cell_id == 0xffffffff`) pull the landscape INTO the indoor
traversal** — `ClipPortals` (pc:433662) copies the doorway clip region into `outside_view`.
The outdoors is seen *only* through that clipped region.
5. **The seal sequence in `DrawCells`** (when `outside_view.view_count > 0`):
`LScape::draw` **clipped to the doorway** → conditional **Z-ONLY** `Clear(4,…)` (NOT
color) → exit-portal stencil → `DrawEnvCell` (closed geometry) → per-cell objects.
6. **No blue clear-color hole by construction** — the only clear is depth-only + conditional;
the doorway shows real terrain because LScape drew there first.
7. **Ceilings/walls sealed by dat geometry**`drawing_bsp` is a closed box; portal holes
are stencil-masked, never filled; there is no "cap the ceiling" step.
8. **VISIBILITY *IS* THE CULL.** Only `cell_draw_list` cells render, and only **their**
`object_list` objects (drawn with `PortalList` set). There is **no second "draw all
entities then gate them" pass.** → no wall/object/particle bleed, by construction.
9. **Outside-looking-in is the mirror image**`DrawPortal @ 0x5a5ab0` runs
`ConstructView(CBldPortal)` + `DrawCells` to render the interior through the door's clip,
the **same** machinery.
10. **Dungeons are emergent** — all-EnvCell, `seen_outside == 0`, no exit portals →
`outside_view` stays 0 → `LScape::draw` is never called → no terrain/sky, automatically.
Plus: BFS convergence is **watermark-bounded** by per-cell `portal_view_type.update_count`
(the retail replacement for a fixed reprocess cap — fixes #102 *and* the dungeon PVS blowup
#95); `find_visible_child_cell @ 0x52dc50` resolves child cells via the portal/stab graph +
BSP `point_in_cell` (never AABB); each cell carries **three** BSPs (`drawing_bsp` render /
`physics_bsp` collision / `cell_bsp` containment).
### 5.2 The porting checklist (doc A §7 — the spine of the plan)
- **CL-A Membership foundation** (physics owns the cell): swept-cell return, exit-portal
crossing, interior-wins + prune, commit-on-difference. *(Largely DONE — `59f3a13`.)*
- **CL-B Render-root unification:** single decision, root at physics `CurrCell`, child-cell
via graph, landscape keep/release on `seen_outside`, `grab_visible_cells`, pre-position terrain.
- **CL-C PView traversal:** BFS, InitCell, ClipPortals (both branches), AddViewToPortals/AddToCell,
`update_count` watermark, GetClip. *(`PortalVisibilityBuilder` already covers much of this.)*
- **CL-D Seal mechanics in DrawCells:** LScape-through-door FIRST, conditional Z-only clear,
exit-portal stencil, closed-geometry draw, **per-cell clipped objects**, self-contained GL state.
- **CL-E Outside-looking-in:** DrawPortal, ConstructView(CBldPortal) recursion, separate outdoor pview.
- **CL-F Entity/particle cell clipping:** graph placement, draw only visible cells, portal-clip straddlers.
- **CL-G Conformance/acceptance:** cottage sealed + sky-through-door, dungeon sealed + no terrain,
outside-looking-in, headless asserts.
**The redesign verdict:** acdream must stop drawing the outdoor world and then gating it.
When the viewer is in an EnvCell, it must run **one `DrawInside` flood** that draws only the
visible cells + their objects, pulling LScape in only through clipped exit portals. The cull
*is* the visibility. Everything in §3 ("three inconsistent gates") collapses into this.
---
## 6. Reference cross-check
**→ Full cross-check: `docs/research/2026-06-02-render-reference-crosscheck.md`** (research doc C).
**WorldBuilder is the wrong model and must NOT be re-adopted for the seal** (10-point verdict):
WB has a hard `isInside` branch switching two completely different render paths (retail has
one); WB's "seal" is a per-**building** GPU stencil from portal-polygon rasters (flat,
building-granularity) whereas retail's is a CPU-derived `OutsideView` clip polygon from the
recursive per-**portal** BFS; **WB's flap is inherent** — the `isInside` branch flips at the
doorway and the two stencil setups tear on that frame; WB clips one stencil per building
(per-portal precision is impossible); WB works for a static dat-viewer but is
**architecturally wrong for a live client that crosses thresholds**; grafting onto WB's
two-pipe is **not recoverable** — the retail PView port is the correct fix.
- **ADOPT:** retail PView BFS (already in acdream as `PortalVisibilityBuilder`), the
`SeenOutside` terrain gate, the `OtherCellId==0xFFFF` exit-portal sentinel, the WB **mesh
pipeline** (keep — it's just GL plumbing), `EnvCellRenderer.PrepareRenderBatches(filter)`,
entity outdoor-slot routing via `WbDrawDispatcher.ResolveEntitySlot`.
- **AVOID (do not reintroduce):** WB `RenderInsideOut`/`RenderOutsideIn` stencil two-pipe,
`BuildingPortalGPU`/`RenderBuildingStencilMask`, any `isInside` / `cameraInsideBuilding`
gate, ACViewer brute-force all-cells draw, any AABB grace-frame fallback for the visibility root.
---
## 7. Current pipeline inventory + failure map
**→ Full inventory: `docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md`** (research doc B).
### 7.1 The concrete bugs (file:line — these are the *mechanisms* behind §1's symptoms)
- **🔴 THE outdoor-scenery bleed ("houses/trees through the ground"):**
`WbDrawDispatcher.EntityPassesVisibleCellGate` hits an **unconditional `return true` at
`WbDrawDispatcher.cs:1756`** for entities with `ParentCellId == null` (outdoor scenery:
houses, trees, landblock stabs). The `IsShellScopedSet` anchor-cull branch that *should*
gate them is **dead code** (always false since U.1 deleted shell sets). ⇒ **outdoor stabs
always draw, even inside a sealed cellar.** This is the #1 visible bleed.
- **🔴 Two visibility computations:** the entity gate is fed by `CellVisibility.VisibleCellIds`
(a *parallel, separate* BFS), while shells + terrain are fed by `PortalVisibilityBuilder`.
They are not the same answer. Retail has ONE (§5.1 fact 8).
- **🟠 Terrain `Skip` over-aggression:** `TerrainClipMode.Skip` fires whenever the exit portal
is not in the current view — correct for a true dungeon, but it removes the terrain floor for
a `seen_outside=true` cottage when the player faces away from the door. (In the retail model
this is moot: terrain is only ever drawn *through* an exit-portal clip, never as a floor under
the interior.)
- **🟠 Particles:** no cell gate at all (#104).
- **🟠 `CullMode.Landblock → None` double-sided stopgap** (`EnvCellRenderer.cs:~1210`) — cells
draw double-sided to dodge a winding issue; a redesign should resolve the actual winding.
- **🟠 Outside-looking-in (U.5):** no outdoor-root shell pass → transparent walls through a door.
### 7.2 The gate table (the inconsistency, from B)
| Geometry | Gate | Fed by | Verdict |
|---|---|---|---|
| Terrain | `TerrainClipMode` | `PortalVisibilityBuilder` | over-aggressive Skip; wrong *model* (floor vs portal-clip) |
| Cell shells | `envCellShellFilter` | `PortalVisibilityBuilder` | correct geometry, but never a closed occluder |
| **Entities** | `CellVisibility.VisibleCellIds` | **parallel BFS** | wrong source + `null`-ParentCell bypass = bleed |
| Particles | *(none)* | — | #104 |
| Sky/weather | `drawSkyThisFrame` | `PortalVisibilityBuilder` | the only consistent one (Stage 4) |
### 7.3 KEEP (from B): `PortalVisibilityBuilder`, `ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`,
Stage-4 sky/weather clip + doorway Z-clear, `EnvCellRenderer` geometry/MDI path,
`TerrainModernRenderer`, `ResolveEntitySlot` (correct for indoor statics), all probes.
### 7.4 REDESIGN/FIX (from B): (a) delete the `ParentCellId==null → return true` bypass + gate
outdoor stabs; (b) feed entity dispatch from `pvFrame.OrderedVisibleCells`, not the parallel
`CellVisibility` BFS; (c) fix terrain `Skip`/model; (d) particle cell-gate (#104); (e) resolve
the double-sided stopgap; (f) implement U.5 outside-looking-in.
---
## 8. Diagnostic apparatus (use it — evidence-first, always)
- **Launch** (per CLAUDE.md "Running the client"): set `ACDREAM_LIVE=1`, host/port/user/pass,
`ACDREAM_DAT_DIR`, then `dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build
-c Debug 2>&1 | Tee-Object -FilePath launch.log`, in the background. The character `+Acdream`
spawns at the Holtburg cottage (`0xA9B40031`, outdoor); walk in to reach `0170→0171→0175→0174`.
- **Probes** (env vars; the log is **UTF-16** — read with PowerShell `Select-String` or the
ripgrep Grep tool, **NOT** GNU grep):
- `ACDREAM_PROBE_CELL=1``[cell-transit]` (membership; low volume, decisive).
- `ACDREAM_PROBE_VIS=1``[vis]` (the PVS frame: root cell, visible cells, OutsideView
poly/plane counts, per-cell plane counts).
- `ACDREAM_PROBE_SHELL=1``[shell]` (per opaque pass: `filter`, `drawCalls`, `inst`,
`tris`, and per-cell `gfx tf batch idx tr zh`). **Diagnosis tree:** `NOSNAP`/`gfx=0`
no geometry prepared; `idx>0 + zh>0` ⇒ prepared but missing texture (invisible);
`idx>0 + zh=0 + tr=0` ⇒ opaque drawn, fault is depth/occlusion or wrong geometry.
⚠ Unthrottled — fires every frame (183K lines in a short walk). Filter in PowerShell.
- **Screenshots:** Windows blocks foreground-steal from a background process; capturing the
AcDream window reliably needs the user to foreground it (or a `PrintWindow` approach that
is unreliable for GL surfaces). Prefer the probes + the user's description for render bugs.
- **`tools/A8CellAudit`** + the committed doorway fixture remain for offline membership checks.
---
## 9. Do-NOT-repeat / settled facts
- **Do not chase shaders/textures/missing-meshes for #78.** The `[shell]` evidence proves
shells draw opaque + textured + complete. The bug is the seal + the gating, not the shells.
- **Do not add another gate, scissor, or clip layer on top.** That is the failed incremental
approach. The redesign collapses to ONE visibility answer + a closed-occluder seal.
- **Do not reintroduce WorldBuilder's `RenderInsideOut` stencil two-pipe** (abandoned
2026-05-30; reference-divergent from retail). Port retail's recursive PView.
- **Do not reopen the membership fix** (`59f3a13`) — `[cell-transit]` confirms it works.
- **My mistake this session (learn from it):** I trusted the prior handoff's "render infra
already exists, just wire 3 gaps" framing and built the Stage-4 sky-through-door clip on
top **without verifying the base seal end-to-end** (I can't see the screen; I leaned on the
test suite + a smoke-launch). The visual gate correctly caught that the premise was wrong.
**Lesson:** for a render seal, get the user's eyes (or the `[shell]`/`[vis]` evidence) on
the ACTUAL sealed result EARLY, before building layers on top. A green test suite proves
nothing about whether the interior looks sealed.
- **Stage-4 commits are kept** (`ce2edad`, `a8b831c`, `872dd34`, `21609a7`, `4bc99fc`,
`b595cfb`) — the sky NDC-clip + the green-tests triage are real and reusable; they are a
top layer that becomes correct once the base seals. The 5 remaining Core test failures are
pre-existing physics/collision bugs (2 step-up gaps incl. a regression from A6.P4's door
fix; 3 door-collision apparatus / A6.P5), none Phase-W's, flagged not fixed.
---
## 10. The redesign plan
**→ `docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md`** (the huge staged plan).
Stages: **R0** brainstorm/lock → **R1** one-visibility-authority (kill the outdoor-scenery bleed)
**R2** indoor = `DrawInside`-only (stop drawing the outdoor world indoors) → **R3** the seal
(`DrawCells` faithful port: LScape-through-door → Z-only clear → closed cells) → **R4** per-cell
object + particle clip → **R5** outside-looking-in (U.5 / `DrawPortal`) → **R6** dungeons → **R7**
polish + conformance. Each ends at a **user visual gate**. The core inversion: **when inside, run
ONLY the PView flood — visibility IS the cull; the outdoor world enters only through clipped exit
portals.**
---
## 11. Pickup prompt (copy-paste for the next session)
```
RENDER PIPELINE REDESIGN — full retail-faithful rewrite of the world render (outdoor + indoor +
dungeon + portals + particles). Continue on branch claude/thirsty-goldberg-51bb9b (do NOT
branch/worktree; do NOT push without asking; NEVER git stash/gc — a shared stash is under
investigation). Use PowerShell on Windows; the launch logs are UTF-16 (read with Select-String /
the ripgrep Grep tool, NOT GNU grep).
MANDATE (user, non-negotiable): FULLY WORKING outdoor + indoor + dungeon rendering — no flaps, no
missing textures, no transparent walls, no terrain leaking into cellars, no entity/particle
bleed-through, outside-looking-in works. NO shortcuts, NO bandaids, NO quick fixes; take the
architecturally-correct path even if slower; redesign the whole pipeline if needed; PORT FROM
RETAIL; do more research mid-session rather than guess; START WITH A BRAINSTORM.
READ FIRST (in order):
1. docs/research/2026-06-02-render-pipeline-redesign-handoff.md (THIS handoff: §2 proven evidence,
§3 root cause = three inconsistent gates + no closed-occluder seal, §5 retail target + porting
checklist CL-A..G, §9 do-not-repeat).
2. docs/research/2026-06-02-retail-render-pipeline-full-reference.md (the retail PView pipeline to
port — the seal mechanics in DrawCells).
3. docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md (the concrete bugs:
the WbDrawDispatcher.cs:1756 ParentCellId==null bypass = the outdoor bleed; the parallel
visibility BFS; terrain Skip model).
4. docs/research/2026-06-02-render-reference-crosscheck.md (why WB's two-pipe stencil is the wrong
model — do NOT reintroduce it).
5. THE PLAN: docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md (stages R0..R7).
PROVEN ROOT CAUSE (don't re-investigate): the PVS computes correctly and the cell shells render
correctly (opaque, textured, complete — [shell] probe). The failure is the SEAL + the GATING:
acdream draws the outdoor world (terrain + scenery ENTITIES that aren't culled) and then a few cell
shells on top, relying on three inconsistent gates. Retail, when inside, runs ONLY DrawInside (one
PView flood) — visibility IS the cull, and the landscape enters only through clipped exit portals.
That inversion is the redesign.
DO NEXT:
1. Phase R0 — BRAINSTORM the §1 architecture (handoff §5 / plan §1) with the user; resolve the open
questions (plan §3); write the per-phase design spec. NO code until the design is locked.
2. Then execute R1→R7 (plan §2), each retail-anchored, each ending at a user visual gate. R1 (one
visibility authority + kill the WbDrawDispatcher.cs:1756 bypass) kills the headline bleed first.
EVIDENCE-FIRST: launch per CLAUDE.md "Running the client" with ACDREAM_PROBE_CELL/VIS/SHELL=1; walk
the Holtburg cottage (spawn 0xA9B40031 outdoor → 0170 vestibule → 0171 room → 0175 stairs → 0174
cellar). The [shell] diagnosis tree + [vis] OutsideView counts are decisive. GET THE USER'S EYES on
the actual sealed result at every visual gate — never declare a seal off the test suite (that was
the mistake that produced this handoff).
KEEP (don't rewrite): PortalVisibilityBuilder (the PVS), ClipFrame/ClipFrameAssembler/ClipPlaneSet,
EnvCellRenderer mesh path, TerrainModernRenderer, the WB mesh pipeline, the membership fix (59f3a13),
the Stage-4 sky NDC-clip + doorway Z-clear (ce2edad/b595cfb). The work is RESTRUCTURING THE
ORCHESTRATION + the entity/particle draw to be per-cell, not a from-scratch rewrite.
TEST STATE: full suite green except 5 pre-existing Core failures (2 step-up gaps incl. an A6.P4 door
regression; 3 door-collision apparatus / A6.P5) — none render-related, flagged in the handoff, do not
chase as part of this work.
```
---
## 12. Session ledger (2026-06-02, render half)
- Stage 4 (sky/weather portal clip + Z-clear + green-tests triage) shipped: `ce2edad`, `a8b831c`,
`872dd34`, `21609a7`, `4bc99fc`, `b595cfb`. Real + reusable, but a top layer — the base seal was
never working (#78). Visual gate FAILED → this redesign handoff.
- Research this session: retail pipeline reference (doc A), acdream inventory+failures (doc B),
reference cross-check (doc C), this master handoff, the redesign plan.
- Issues to file/track: #78 (the core seal — now the redesign target), #95 (dungeon BFS), #104
(particle cell-clip), the `WbDrawDispatcher.cs:1756` outdoor-scenery bypass, the
`CullMode.Landblock→None` double-sided stopgap, U.5 outside-looking-in.

View file

@ -0,0 +1,449 @@
# Indoor Cell / Portal Visibility Render Reference Cross-Check
**Date:** 2026-06-02
**Purpose:** Inform acdream's Phase U unified render pipeline redesign by documenting what
each reference client does for indoor cell rendering, portal visibility, the interior seal,
outdoor-scenery gating, and object/particle clipping — with explicit ADOPT / AVOID verdicts.
---
## 1. WorldBuilder — Indoor Render Approach (RenderInsideOut Two-Pipe Stencil)
### What WB actually does
**Source files:**
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 8801008
#### The branch decision
`GameScene.cs:881` sets `isInside = currentEnvCellId != 0` (the camera is in ANY EnvCell).
Two completely different code paths branch on that boolean:
- **Inside (`RenderInsideOut`)**`VisibilityManager.RenderInsideOut` (lines 73239)
- **Outside (`RenderOutsideIn` or fallback)**`VisibilityManager.RenderOutsideIn` (lines 241358)
or `RenderEnvCellsFallback` when `EnableCameraCollision` is off
This is the split acdream inherited from Phase N.4. **It is the root cause of every
doorway seam bug.** The two paths are fundamentally incompatible at a threshold crossing —
the hand-off point is where the flap, transparent walls, and terrain bleed all happen.
#### RenderInsideOut walkthrough (VisibilityManager.cs:73239)
1. **Stencil Bit 1 — doorway mask:** All portal polygons of the *camera's building* are rasterized
with `StencilOp.Replace` → Bit 1 = 1. This marks the screen pixels that correspond to doorways.
Done with `DepthFunc(Always)` so portals draw regardless of what's in front of them.
2. **Punch depth at doorways:** The same portal geometry is drawn again with `uWriteFarDepth=1`
(the stencil shader writes `gl_FragDepth = 1.0`). This clears depth at the doorway pixels
so the outdoor terrain can bleed through them.
3. **Render indoor cells ALWAYS** (no stencil guard). The camera building's full EnvCell set
is drawn unconditionally (`_currentEnvCellIds` = union of all cells in the camera building).
No per-portal clip. All cells in the building render even if behind the player.
4. **Gate outdoor geometry (terrain/scenery/static) through Bit 1.** Stencil func `Equal(1, 0x01)`
terrain, scenery, and static objects only draw where the portal polygons were rasterized.
This is the "seal" against outdoor bleed: if Bit 1 wasn't set, the pixel never gets terrain.
5. **Other buildings' cells** (Step 5, lines 157229): For each other building visible through
our doorways, WB does a further two-step mask — Bit 2 marks the intersection of our doorway
AND the other building's portals (stencil == 3 meaning both bits set), then draws that
building's cells only where both portals are open. Uses occlusion queries to skip buildings
that were fully occluded last frame.
6. **RenderOutsideIn** (outside path, lines 241358): mirrors Step 12 but camera is outside
looking in. Portal polygons mark Bit 1; depth is cleared at those pixels; EnvCells render
through the mask. Terrain/scenery/statics draw normally (no stencil guard).
#### What `GetVisibleBuildingPortals` provides
`PortalRenderManager.GetVisibleBuildingPortals` returns a `BuildingPortalGPU` per **building**
(not per cell or per portal). The `BuildingPortalGPU` is a triangle-fan tessellation of ALL
portal polygons for the building concatenated into a single VAO/VBO. This is the flat union —
there is no per-portal polygon tracking. One stencil pass per building.
`EnvCellRenderManager.GenerateForLandblockAsync` discovers cells recursively from building
portals (`portal.OtherCellId != 0xFFFF` — exit portals are skipped). The `seenOutsideCells`
set tracks cells with `EnvCellFlags.SeenOutside` but WB only stores this for diagnostic use;
it does NOT gate the cell draw off `SeenOutside`.
#### How WB decides cell visibility for the filter
`VisibilityManager.PrepareVisibility` (lines 4771): when `isInside`, adds ALL cells of every
building the camera is in (`_buildingsWithCurrentCell`). No per-portal traversal. No
per-portal clip. No `VisibleCells` stab-list from the dat. The full cell set of the building
is the filter.
When `isInside=false`, `GetVisibleBuildingPortals` returns frustum-visible building groups;
ALL their cells are added to `visibleEnvCells`. Again, no per-portal traversal.
**There is no WB equivalent of retail's per-cell `VisibleCells` (the PVS stab-list). WB
never reads `EnvCell.VisibleCells`.** WB's visibility is building-level, not cell-level.
#### Why the WB two-pipe diverges from retail's recursive PView
Retail `PView::ConstructView` (decomp ~433750) and `ClipPortals` (~433572):
| Property | WB RenderInsideOut | Retail PView |
|---|---|---|
| **Visibility unit** | Building (all cells in a building) | Per-cell portal traversal |
| **Clip granularity** | One stencil mask per building | Per-portal screen-space clip polygon |
| **Camera branching** | Hard `isInside` branch switching two completely different code paths | No branch — "which cell is the camera in" changes only the BFS root, not the algorithm |
| **Outdoor geometry gate** | Stencil Bit 1 derived from the portal polygon raster at the wall | OutsideView clip polygon accumulated by clipping through exit portals in the BFS |
| **Per-cell PVS** | Not used | `CEnvCell.stab_list` + `seen_outside` read per cell; portal side test per edge |
| **Scenery gating** | Outdoor scenery draws only where Bit 1 is set (all portals of the camera building) | Outdoor entities/scenery assigned OutsideView clip slot; cull when OutsideView empty |
| **Terrain gate** | Depth punched at portal pixels; terrain only draws stencil==1 | TerrainClipMode: Skip when no exit portal visible, Planes/Scissor when one is |
| **Other-building cells** | 2-bit stencil composed gate with occlusion query fallback | Same recursive BFS — other buildings' cells are cells in the PVS; no separate pipe |
| **Seam at doorway** | **Inherent** — the two pipes switch at `currentEnvCellId != 0`; the frame of the switch always tears | **None** — outdoor/indoor are the same draw loop with different clip regions |
#### Why acdream abandoned WB's two-pipe (2026-05-30)
From `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`:
> "The A8.F effort tried to graft retail's recursive clip *on top of* WB's two-pipe stencil
> (a CPU-built NDC mask bridging the two pipes). That hybrid is inherently fragile and failed
> its visual gate (issue #103). You cannot make two pipes hand off seamlessly at a doorway;
> retail avoids the entire bug class by never splitting."
The specific failure modes WB's architecture cannot fix without replacing the architecture:
1. **The flap** — The `isInside` / `currentEnvCellId != 0` gate flips on the same frame the camera
crosses the doorway. On the flip frame WB switches from RenderInsideOut to RenderOutsideIn (or
vice versa). The stencil state from the prior frame is cleared; the new portal polygon raster
hasn't loaded. There is one frame where the indoor draw is missing OR the outdoor draw bleeds
through. This is inherent to the architecture.
2. **Terrain bleed indoors** — WB's stencil Bit 1 mask is derived from the camera-building portal
polygons rasterized with `DepthFunc(Always)` BEFORE terrain. If the portal polygon is degenerate,
or the stencil clear races the portal raster, terrain bleeds through. The mask is not ground in
the per-cell PVS (`VisibleCells` / `SeenOutside`) so it cannot make a definitive "no terrain here
EVER" decision the way retail's `seen_outside=false` does for sealed dungeons.
3. **Transparent walls** — In RenderOutsideIn, WB clears depth with `uWriteFarDepth=1` at the portal
stencil, then renders EnvCells into the cleared region. The wall geometry adjacent to the portal
also had its depth cleared (the portal polygon doesn't exactly hug the wall opening), so a wall
polygon's depth test can fail against the cleared far-plane, making it appear transparent.
4. **Outdoor scenery entities indoors** — WB's `RenderInsideOut` gates terrain/scenery/static-objects
through Bit 1 (only where the portal polygon rasters hit). But if the entity's ParentCellId is
not tracked or is 0 (outdoor scenery has no indoor cell parent), it routes to the stencil
unguarded path and draws everywhere.
5. **Cannot be fixed without rebuilding** — The stencil approach is a GPU-side approximation of
what retail does on the CPU (per-portal clip-polygon BFS). Every "fix" to the stencil approach
adds more GPU state to paper over a case where the stencil and the intended visibility diverge.
The correct fix replaces the stencil entirely with retail's CPU-driven visibility.
**VERDICT: DO NOT REINTRODUCE the WB RenderInsideOut/RenderOutsideIn two-pipe stencil or any
approximation of it. The architecture is the bug.**
---
## 2. ACViewer — Cell/Portal Rendering and Sealing Approach
**Source files:**
- `references/ACViewer/ACViewer/Render/R_Landblock.cs`
- `references/ACViewer/ACViewer/Render/R_EnvCell.cs`
- `references/ACViewer/ACViewer/Render/Buffer.cs`
- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs`
### What ACViewer does
ACViewer is a **dat viewer and map editor** (MonoGame/DirectX), not a game client. Its rendering
is a brute-force draw with no runtime visibility or sealing:
**`R_Landblock.cs:83101`** `BuildEnvCells()`: For each cell id from 0x100 to
`0x100 + NumCells - 1` in the landblock, creates an `R_EnvCell` and adds it to the list.
All cells for the landblock are built, with no portal traversal.
**`Buffer.Draw()` / `Buffer.DrawWithZSlicing()`**: Draws all batches in `RB_EnvCell`,
`RB_StaticObjs`, `RB_Buildings`, `RB_Scenery` unconditionally (gated only by the Z-slicing
filter for multi-floor dungeon inspection). No portal culling. No stencil. No clip.
**`R_EnvCell.Draw()`** (`R_EnvCell.cs:7087`): Calls `DrawEnv()` (sets xWorld + draws the
environment cell struct mesh) + `DrawStaticObjs()` (draws each stab). No filter.
**There is no portal-based visibility in ACViewer at all.** ACViewer draws ALL cells and ALL
objects in every loaded landblock every frame. Culling is done only by the MonoGame frustum
culling on the DirectX state (backface culling ON in dungeon mode per `Buffer.cs:166`).
### ACViewer's EnvCell data model — what it DOES read
**`EnvCell.cs`** (Physics/Common, the ACViewer version used by `R_EnvCell`):
- `VisibleCellIDs` — list of low-byte cell IDs from the dat (`stab_list` / `numStabs`). This
is the DAT-baked PVS for this cell. ACViewer reads it in the constructor (`VisibleCellIDs =
envCell.VisibleCells`) and builds `VisibleCells` dict via `build_visible_cells()`.
- `SeenOutside``envCell.SeenOutside` flag. ACViewer reads it.
- `Portals` — the `CellPortal[]` list. ACViewer reads it.
- `find_visible_child_cell(Vector3 origin, bool searchCells)` (lines 206231): Checks if `origin`
is in this cell's AABB; if not, searches `VisibleCells.Values` (or falls back to `Portals`) for
a cell containing `origin`. This is the **retail `CEnvCell::find_visible_child_cell`** ported
to ACViewer's physics tree — it is the cell-membership resolver for moving objects in physics.
**Critically, ACViewer reads and maintains the PVS / portal data but uses it ONLY for physics
collision, not for rendering.** The render path is entirely brute-force.
### Does ACViewer seal interiors?
**No.** ACViewer does not try to occlude the outdoor world when drawing from inside a building.
It draws everything: terrain + scenery + building geometry + dungeon cells simultaneously. It
relies on correct depth testing to show the right surfaces. This works for a static viewer (you
can rotate to any position and inspect geometry) but would be completely broken for a game client
(outdoor terrain bleeds through dungeon floors when the camera is inside).
**ACViewer is GPL-licensed (read for understanding only; do not copy code).**
### What is reusable from ACViewer
- **The `EnvCell` data model** (especially `find_visible_child_cell`, `build_visible_cells`,
`VisibleCellIDs`, `SeenOutside`): these are faithful ports of the retail data structures.
acdream already has its own equivalent (`LoadedCell.VisibleCells`, `LoadedCell.SeenOutside`
in `CellVisibility.cs`). The ACViewer source confirms the field interpretations.
- **`CellPortal` struct** (`PolygonId`, `OtherCellId`, `OtherPortalId`, `PortalSide`): confirms
the exact field layout. acdream's `CellPortalInfo` record matches.
- **Algorithm understanding**: ACViewer's `find_visible_child_cell` confirms the retail pattern —
first check `point_in_cell(origin)` (self), then search `VisibleCells` by AABB, then fallback
to portal-linked neighbours. This is the retail `CEnvCell::find_visible_child_cell` at
`acclient_2013_pseudo_c.txt:311397`.
---
## 3. ACE — Cell/Portal/Visibility Data Model
**Source files:**
- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs`
- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/BuildInfo.cs`
- `references/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs`
- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs`
ACE is the **server** — it defines the canonical dat file format that both the retail client and
acdream read. The data model here is authoritative.
### EnvCell fields (ACE DatLoader, `EnvCell.cs`)
| Field | Type | Purpose |
|---|---|---|
| `Flags` | `EnvCellFlags` | Bitflags: `SeenOutside (0x1)`, `HasStaticObjs (0x2)`, `HasRestrictionObj (0x8)` |
| `Surfaces` | `List<uint>` | Surface IDs (`0x08000000 | shortId`) — the textures for this cell |
| `EnvironmentId` | `uint` | Environment dat id (`0x0D000000 | shortId`) — the prefab geometry block |
| `CellStructure` | `ushort` | Which sub-structure within the environment (one environment can have many cell shapes) |
| `Position` | `Frame` | World-space placement of this cell (position + rotation) |
| `CellPortals` | `List<CellPortal>` | Connectivity list: `OtherCellId` (0xFFFF = exit portal), `OtherPortalId`, `PolygonId`, `ExactMatch`, `PortalSide` |
| `VisibleCells` | `List<ushort>` | PVS stab-list: low-byte cell IDs of cells potentially visible from this one (precomputed by AC's content tools) |
| `StaticObjects` | `List<Stab>` | Static geometry placed within this cell (furniture, decorations) |
| `RestrictionObj` | `uint` | GUID of the object controlling access (housing barriers) |
| `SeenOutside` | `bool` | Derived from `Flags.HasFlag(SeenOutside)` — this cell has line of sight to the exterior |
**Note from the ACE comment at line 46:** `numStabs` (the `VisibleCells` count) — "I believe this
is what cells can be seen from this one. So the engine knows what else it needs to load/draw."
This confirms the stab-list is the visibility PVS the renderer should use as a filter.
### The exit portal sentinel: `CellPortal.OtherCellId == 0xFFFF`
`CellPortal.cs:10,19`: `OtherCellId` is a `ushort`. Value `0xFFFF` (65535) means "exit to
outdoor world" — the portal connects this cell to the exterior. This is the seal-breaking
portal. A cell with an exit portal in its `CellPortals` list has line of sight to the
outdoors, which is why retail's `SeenOutside` flag is TRUE for such cells (and recursively
for any cell that can reach an exit portal through the connectivity graph).
When `OtherCellId != 0xFFFF`, the portal connects to another EnvCell with low-byte id
`OtherCellId` in the same landblock. The full id is `(landblockId << 16) | OtherCellId`.
### LandblockInfo — building portal graph (`LandblockInfo.cs`, `BuildInfo.cs`, `CBldPortal.cs`)
The `LandblockInfo` dat file (`xxxxFFFE`) contains:
| Field | Type | Purpose |
|---|---|---|
| `NumCells` | `uint` | Total number of EnvCells in this landblock |
| `Objects` | `List<Stab>` | Static objects at landblock level (not inside any EnvCell) |
| `Buildings` | `List<BuildInfo>` | One `BuildInfo` per building structure |
Each `BuildInfo` has:
- `ModelId` — the GfxObj/Setup id of the building mesh
- `Frame` — world placement
- `Portals``List<CBldPortal>` — the **building-level portal list** (distinct from the per-cell `CellPortals`)
Each `CBldPortal` has:
- `OtherCellId` — the EnvCell low-byte id that this portal opens into (0xFFFF = no indoor side)
- `OtherPortalId` — back-link into that cell's portal list
- `StabList` — list of cells visible through this portal opening (the per-building-portal PVS)
This is the **entry-point graph** acdream uses in `EnvCellRenderManager.GenerateForLandblockAsync`
(line 657) to discover which EnvCells belong to which building: start from `BuildInfo.Portals`,
follow `CBldPortal.OtherCellId` → first EnvCell of the building → follow `CellPortal` connections
recursively to discover all cells in the building.
**WB's cell discovery correctly skips `portal.OtherCellId == 0xFFFF`** — these are exit portals
that lead outside, not to another EnvCell (`EnvCellRenderManager.cs:658,780`). Acdream's
`CellVisibility.GetVisibleCellsFromRoot` also skips them (line 467) and sets `HasExitPortalVisible`.
### What the seal means in the data model
A cell is "sealed" (truly indoor, no outdoor bleed) if:
- `SeenOutside == false`: no exit portal reachable from this cell via the connectivity graph.
Retail's `CellManager::ChangePosition` releases the landscape (terrain) when `seen_outside`
is false on the current cell, because there is definitively nothing outdoor to draw.
A cell "sees outside" if:
- `SeenOutside == true`: at least one exit portal (`OtherCellId == 0xFFFF`) is reachable.
Terrain may be visible; the outdoor world should be drawn, clipped to the visible exit portal.
Acdream already reads this correctly. The `LoadedCell.SeenOutside` field is set from the dat
flag (GameWindow.cs:7704), and `rootSeenOutside = physicsRoot?.SeenOutside ?? true` gates terrain.
---
## 4. Chorizite.ACProtocol
`references/Chorizite.ACProtocol/` — generated from the protocol XML. No rendering-relevant files;
it covers network wire types only. Not applicable to this cross-check.
---
## 5. AC2D
`references/AC2D/` — C++ fixed-function OpenGL client. Does not implement indoor rendering (it
renders everything via the server's authoritative Z; no client-side interior/exterior split). Not
applicable to indoor rendering cross-check.
---
## 6. Reusability Table
| Reference | Component | Reusable? | License | Caveat |
|---|---|---|---|---|
| **WorldBuilder** | `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher` mesh pipeline | YES (in-tree) | MIT | Keep — Phase U is about visibility, not mesh extraction |
| **WorldBuilder** | `EnvCellRenderManager.GenerateForLandblockAsync` cell discovery via `CBldPortal` / recursive `CellPortal` | YES (in-tree, adapted) | MIT | The discovery loop is correct; WB already skips 0xFFFF exit portals |
| **WorldBuilder** | `EnvCellRenderManager.PrepareRenderBatches` frustum + filter | YES (in-tree) | MIT | Used by acdream's `EnvCellRenderer`; only the visibility FILTER needs replacing (use stab-list PVS instead of per-building set) |
| **WorldBuilder** | `PortalRenderManager.GetVisibleBuildingPortals` + `BuildingPortalGPU` mesh | AVOID | MIT | This is the per-building stencil mesh — only useful for WB's two-pipe stencil; not needed for a unified PView pipeline |
| **WorldBuilder** | `VisibilityManager.RenderInsideOut` / `RenderOutsideIn` two-pipe stencil | **DO NOT USE** | MIT | Root cause of all indoor seam bugs; see §1 |
| **WorldBuilder** | `EnvCellFlags.SeenOutside` / `CBldPortal.OtherCellId != 0xFFFF` sentinel | YES (already used) | MIT | Data semantics confirmed correct |
| **ACViewer** | `EnvCell.find_visible_child_cell` | READ-ONLY (GPL) | GPL | Confirms retail `CEnvCell::find_visible_child_cell` interpretation; already ported in acdream's `CellVisibility` |
| **ACViewer** | `EnvCell.build_visible_cells` + `VisibleCells` dict | READ-ONLY (GPL) | GPL | Confirms PVS stab-list usage; acdream has `LoadedCell.VisibleCells` equivalent |
| **ACViewer** | Brute-force render of all cells (Buffer.cs) | **DO NOT USE** | GPL | No visibility, no seal, not usable for a game client |
| **ACE** | `EnvCell` dat file format (all fields) | YES (already used) | AGPL | acdream reads these via DatReaderWriter; field semantics confirmed authoritative |
| **ACE** | `CellPortal.OtherCellId == 0xFFFF` exit-portal sentinel | YES (already used) | AGPL | Critical for detecting the indoor/outdoor boundary |
| **ACE** | `LandblockInfo.Buildings` / `BuildInfo.Portals` entry-point graph | YES (already used) | AGPL | The cell discovery starting point; WB and acdream both use this correctly |
| **ACE** | `CBldPortal.StabList` (per-building-portal PVS) | INVESTIGATE | AGPL | Per-portal stab-list not currently used by acdream; may supplement the per-cell stab-list for cross-building portal resolution |
---
## 7. Recommendations for Phase U Redesign
### Adopt
1. **Retail PView portal-traversal as the single unified visibility pass** (retail anchor:
`PView::ConstructView` ~433750, `ClipPortals` ~433572, `GetClip` ~432344).
acdream already has `PortalVisibilityBuilder` and `CellVisibility.GetVisibleCellsFromRoot`
which are correct unit-tested ports. These are the keepers.
2. **Single code path regardless of camera position.** The camera being inside or outside an EnvCell
changes only which `LoadedCell` is the BFS root — null (outdoor root: player in a LandCell) vs
non-null (indoor root: player in an EnvCell). The draw algorithm does NOT branch on this.
3. **Per-cell PVS stab-list (`LoadedCell.VisibleCells`) as the filter ground.** Retail's
`grab_visible_cells` (decomp 311878) loads the stab-list into `VisibleCells` at cell entry
time. The per-frame BFS walks portal connectivity PLUS the stab-list to determine the drawable
set. The stab-list is precomputed by AC's content tools and is more conservative than a
per-frame portal clip (it includes cells reachable from any viewpoint in the cell, not just
from the current camera position). Use it as the BFS frontier expansion to avoid missed cells
at glancing angles.
4. **`SeenOutside` gate for terrain** (already in acdream at GameWindow.cs:7177).
`seen_outside=false` → skip terrain entirely (pure dungeon). `seen_outside=true` and an
exit portal is visible → draw terrain clipped to the `OutsideView` clip region.
This is retail's `CellManager::ChangePosition` @ `0x004559B0` behavior.
5. **`CellPortal.OtherCellId == 0xFFFF` as the outdoor-world gate** (already in acdream at
`CellVisibility.cs:467`, `PortalVisibilityBuilder`). Every exit portal reached in the BFS
contributes to `OutsideView`. When `OutsideView` is empty, NOTHING outdoor draws (terrain,
scenery, outdoor entities all cull). This is the closed seal.
6. **Outdoor scenery/entity gating via ParentCellId** (already in `WbDrawDispatcher.ResolveEntitySlot`):
- Live-dynamic entities (server GUID != 0): always slot 0 (no clip) — retail draws players/NPCs
through depth without portal clipping.
- Indoor entities (ParentCellId is a full EnvCell id): route to that cell's clip slot; cull if
the cell is not in the visible set.
- Outdoor scenery/statics (ParentCellId == null or is a LandCell id): route to `OutdoorSlot`;
cull when `OutdoorVisible = false` (no exit portal in view). **This is the outdoor-scenery seal.**
7. **The WB-derived mesh pipeline** (`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`,
`TerrainModernRenderer`) is NOT the visibility problem and should not be replaced. Phase U
replaces the draw ORCHESTRATION (what gets drawn, when, with what clip), not the mesh
extraction (what vertices are in the VBO).
8. **`EnvCellRenderer.PrepareRenderBatches` with an explicit `filter` set** (already wired:
`envCellShellFilter` in GameWindow.cs:7333). The filter set is the BFS-visible cell ids from
`ClipFrameAssembly.CellIdToSlot.Keys`. This correctly excludes cells outside the visible set
(e.g. the other side of a multi-story building when the player is only in one floor).
### Avoid
1. **WB's `RenderInsideOut` / `RenderOutsideIn` two-pipe stencil** — abandoned 2026-05-30, do not
reintroduce. The architecture is the bug, not a parameter of the architecture.
2. **Per-building stencil mesh** (`BuildingPortalGPU`, `PortalRenderManager.RenderBuildingStencilMask`,
the `_stencilShader` + `InitializeStencilShader` machinery from WB) — only useful for WB's
stencil two-pipe. If acdream needs per-portal depth-clip, the retail mechanism is a software
clip plane (gl_ClipDistance) set from the per-portal NDC clip region, not a GPU stencil.
3. **The `isInside` / `cameraInsideBuilding` gate** — this is the two-pipe switch. Phase U's
redesign must not have any version of this. The outdoor case is `root == null` (player in a
LandCell); the indoor case is `root != null` (player in an EnvCell). These are inputs to the
SAME algorithm, not selectors for different algorithms.
4. **ACViewer's brute-force all-cells draw** — usable for map viewing tools, not for a game
client. The `Buffer.Draw()` approach will render hundreds of EnvCells including those in
completely different buildings on the other side of the landblock, causing massive overdraw
and incorrect visibility.
5. **Any "grace frame" or fallback AABB resolver for the portal-visibility root.** The root
comes from the physics-ownership answer (`CellGraph.CurrCell`) exclusively — retail's
`CellManager::ChangePosition` reads the transition-owned `curr_cell` with no AABB fallback.
Stage 3 (2026-06-02) deleted the FindCameraCell AABB grace-frame fallback from acdream.
6. **Re-porting the outdoor-terrain or EnvCell mesh extraction from retail decomp.** The WB
inventory doc (`docs/architecture/worldbuilder-inventory.md`) classifies these as green
(already correctly extracted from WB into `src/AcDream.{Core,App}/Rendering/Wb/`). The
rendering *orchestration* is 🔴 (must come from retail), the mesh *extraction* is 🟢
(WB has a tested port). Do not re-port what WB already got right.
---
## 8. Summary of WB-vs-Retail Divergence (10-line version)
1. WB branches hard on `isInside` (camera in ANY EnvCell) → two completely different render paths.
2. Retail has ONE path — portal-traversal BFS from the camera cell; indoor and outdoor are just cells.
3. WB's "seal" is a per-building GPU stencil derived from portal polygon rasters (flat, building-level).
4. Retail's seal is the CPU-derived `OutsideView` clip polygon (recursive, per-portal, per-cell).
5. WB uses NO per-cell PVS stab-list (`VisibleCells`) for rendering; retail uses it as the BFS frontier.
6. WB's outdoor gate (terrain/scenery draw only where stencil=1) fails at doorway-crossing frames (the flap).
7. Retail's outdoor gate (terrain clips to `OutsideView`; skip when empty) is frame-exact and derived from the same BFS as the cell draw.
8. WB cannot express per-portal clip precision (one stencil per building); retail clips each portal opening independently.
9. WB's approach is sound for a static dat-viewer where you never cross thresholds; it is architecturally wrong for a live game client.
10. The Phase U unified pipeline (retail PView port) is the correct fix; grafting anything onto WB's two-pipe stencil is not.
---
## Key File Paths Referenced
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs` — WB RenderInsideOut/RenderOutsideIn (full stencil two-pipe; read for understanding; DO NOT reintroduce)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs` — WB building portal GPU mesh + stencil shader
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` — WB cell discovery loop (reusable), PrepareRenderBatches with filter (reusable)
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 8801008 — the `isInside` branch; root of the two-pipe problem
- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs``find_visible_child_cell`, `build_visible_cells`, `VisibleCellIDs`, `SeenOutside` (read-for-understanding; GPL; don't copy)
- `references/ACViewer/ACViewer/Render/Buffer.cs` / `R_EnvCell.cs` / `R_Landblock.cs` — brute-force all-cells draw (read-for-understanding; GPL; DO NOT use)
- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs` — authoritative field list including `VisibleCells`, `SeenOutside`, `CellPortals`
- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs``OtherCellId == 0xFFFF` exit-portal sentinel
- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs` + `Entity/BuildInfo.cs` + `Entity/CBldPortal.cs` — building portal entry-point graph
- `src/AcDream.App/Rendering/CellVisibility.cs` — acdream's BFS visibility system (correct, keep)
- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — acdream's recursive portal-clip BFS (correct, keep)
- `src/AcDream.App/Rendering/ClipFrameAssembler.cs` — acdream's per-cell clip slot assembly (correct, keep)
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — entity outdoor-slot routing via `ResolveEntitySlot` (correct, keep)
- `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md` — Phase U decision rationale

View file

@ -0,0 +1,810 @@
# Retail AC — the FULL render pipeline (outdoor + indoor + dungeon + seal), implementable reference
> **Purpose.** The single, exhaustive, port-ready reference for how the **retail** AC client
> (Sept 2013 EoR build, PDB-named) renders the world: outdoor landscape, building interiors,
> dungeons, portals, the "seal" (drawing the outside through a doorway with no blue hole,
> capped ceilings, no wall-bleed), and object/particle clipping. This is the foundation of the
> Phase W / Phase U unified-render redesign. **The code is modern; the behavior is retail —
> port this faithfully, with no shortcuts.**
>
> **Sources.** Consolidates the four 2026-06-02 decomp studies
> (`-opus48-a`, `-opus48-b`, `-sonnet46`, `-codex`) and the approved design spec
> (`docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md`),
> then **deepens the DRAW/seal pipeline** with the actual pseudo-C read this session from
> `docs/research/named-retail/acclient_2013_pseudo_c.txt` and verbatim structs from
> `docs/research/named-retail/acclient.h`. Citations are `Class::method @ 0xADDR (pc:LINE)` for
> decomp and `acclient.h:LINE` for structs. Membership/transition material (Section A) is
> deliberately summarized — it is fully covered in the four studies and is *not* the render
> redesign's spine; the render pipeline (Sections 27) is the new, deep material.
---
## 1. Executive summary — the ONE retail pipeline (≤12 bullets)
1. **One cell graph, one membership answer, render obeys it.** Physics tracks the player's cell
as `SPHEREPATH::curr_cell` carried *through* the collision sweep; the camera tracks its own
`viewer_cell` via a second (spring-arm) transition. Both resolve through the **same**
`CObjCell::GetVisible(objcell_id)` graph. There is **no** separate render cell system, **no**
static "re-derive the cell from XYZ", and **no** "underground" boolean.
2. **Top-level decision is binary, per frame** (`SmartBox::RenderNormalMode @ 0x00453aa0`,
pc:92635). `is_player_outside`/viewer-cell test → **outdoor** path = `LScape::draw` (full
terrain+sky+buildings); **EnvCell** path = `RenderDevice::DrawInside(viewer_cell)`. It is
*not* "landscape then inside" at the top level — when inside, **only** `DrawInside` runs.
3. **`DrawInside` (`PView`) is one portal-flood traversal** (`PView::DrawInside @ 0x005a5860`
`ConstructView @ 0x005a57b0``DrawCells @ 0x005a4840`). It produces an ordered
`cell_draw_list` (visible EnvCells) plus per-cell screen **clip regions** (`portal_view`) and
one accumulated `outside_view` (the outdoors seen through exit portals).
4. **The landscape is pulled INTO the indoor traversal through exit portals.** A portal with
`other_cell_id == 0xffffffff` is an *exit portal*. `ClipPortals` (`@ 0x005a5520`, pc:433662)
copies its screen clip region into `this->outside_view` (gated by `draw_landscape`). This is
the seam that makes the outside visible through a doorway.
5. **The seal sequence in `DrawCells`** (pc:432715+), when `outside_view.view_count > 0`:
(a) `LScape::draw(lscape)` with `Render::PortalList = this` → terrain+sky+rain+exterior,
**clipped to the doorway**; (b) a **conditional Z-only clear** `Clear(4, …)` (flag 4 = Z
buffer, NOT color; conditional on `portalsDrawnCount`); (c) per-cell exit-portal **stencil**
via `DrawPortalPolyInternal`; (d) per-cell **`DrawEnvCell`** (the closed interior geometry);
(e) per-cell **`DrawObjCellForDummies`** with `PortalList` set to that cell's view (objects).
6. **There is no blue clear-color hole, by construction.** The only clear in the indoor path is
`Clear(4, …)` = depth only, and even that is conditional. The doorway shows real terrain/sky
because the landscape was drawn first, clipped to the exit-portal region.
7. **Ceilings/walls/floors are sealed by the dat geometry.** Each EnvCell's `drawing_bsp` is a
*closed* box (floor + walls + ceiling) with holes only where `CCellPortal`s exist. There is no
"cap the ceiling" step; `DrawCells` draws each visible cell's `drawing_bsp`. Portal openings
are masked, not filled.
8. **Visibility IS the cull.** Only cells reached by the portal BFS are in `cell_draw_list`; only
their objects (`object_list`, drawn per-cell with `PortalList` set) are drawn. An object/
particle in a non-visible cell is never iterated → no wall bleed-through. Membership comes from
the *same* physics `enter_cell`/`leave_cell` graph.
9. **Outside-looking-in is the mirror image, same machinery.** While drawing the landscape, when
the camera can see a building's door, `PView::DrawPortal @ 0x005a5ab0` runs
`ConstructView(CBldPortal, polygon, …) @ 0x005a59a0` — a viewer-vs-portal-plane side test, a
`GetClip`, then it **recurses `ConstructView(interior_cell, other_portal_id)`** and `DrawCells`
the interior through the door's clip region. Outside↔inside is one recursive portal-clipped
traversal over the shared graph.
10. **Dungeons are emergent, not flagged.** A dungeon is an all-EnvCell landblock (terrain heights
0, ≥1 EnvCell, no buildings) where every cell has `seen_outside == 0` and no exit portals. So
`outside_view.view_count` stays 0, `LScape::draw` is never reached, and there is no terrain/
sky — automatically. Same `DrawInside` path as a cottage interior.
11. **Terrain/sky/landscape state keys off `seen_outside`.** `CellManager::ChangePosition @
0x004559b0` keeps the landscape loaded + sunlight/outdoor-ambient live iff the current cell is
a landcell **or** `CObjCell::seen_outside` is set; otherwise it `release_all`s the landscape
and uses flat indoor ambient. `CEnvCell::grab_visible_cells @ 0x0052e220` loads the landscape
iff `seen_outside`.
12. **BFS convergence is watermark-bounded, not capped.** `ConstructView` uses a per-cell
`portal_view_type.update_count` watermark + a `cell_todo_list` worklist; a cell can be
re-processed only for genuinely new view slices (`update_count → view_count`). This is the
retail replacement for acdream's fixed `MaxReprocessPerCell` cap (issue #102) and the right
fix for dungeon PVS blowup (#95).
---
## 2. Render-decision tree — outdoor / indoor / both
### 2.1 The single per-frame gate: `SmartBox::RenderNormalMode @ 0x00453aa0` (pc:92635)
Verbatim control flow (pc:92642-92684), with the decompiler-garbled predicate named:
```c
if (render_device->m_bOpenScene != 0) {
// edi_2 == SmartBox::is_player_outside(this)-equivalent: viewer is in an OUTDOOR landcell
// ebx_1 == "outside is relevant" = (viewer outside) || (viewer_cell->seen_outside != 0)
if (edi_2 != 0 || this->viewer_cell->seen_outside != 0) ebx_1 = 1; else ebx_1 = 0;
// FOV / view-distance setup (omitted) ...
if (edi_2 == 0) { // ── VIEWER IS INSIDE AN ENVCELL ──
if (ebx_1 != 0) { // cell can see outside:
uint eax = Position::get_outside_cell_id(&this->viewer); // pc:92669
LScape::update_viewpoint(this->lscape, eax); // pre-position terrain
}
Render::update_viewpoint(&this->viewer);
render_device->vtable->DrawInside(render_device, this->viewer_cell); // pc:92675 PView
} else { // ── VIEWER IS OUTSIDE (landcell) ──
LScape::update_viewpoint(this->lscape, this->viewer.objcell_id); // pc:92679
Render::update_viewpoint(&this->viewer);
Render::set_default_view();
Render::useSunlightSet(1);
LScape::draw(this->lscape); // pc:92683 full outdoor render
}
}
D3DPolyRender::FlushAlphaList(0);
// ... target bounding-box callback + rendering callback ...
```
**Key facts:**
- **Exactly two branches**, chosen by `edi_2` = "is the viewer cell an outdoor landcell?" When
outside, the *only* draw is `LScape::draw`. When inside, the *only* draw is `DrawInside` — the
landscape, if shown, is drawn **inside** `DrawInside`/`DrawCells` (Section 4), *not* here.
- **`ebx_1` (= outside-relevant)** controls whether the landscape's *viewpoint* is updated before
the indoor draw, so that if a doorway shows terrain it is centered on the right landblock. The
*actual* terrain draw-through-door decision is `outside_view.view_count > 0` inside `DrawCells`.
- The predicate maps to **`SmartBox::is_player_outside @ 0x00451e80` (pc:90996)**:
```c
int is_player_outside(SmartBox* this) {
if (this->player == 0) return 0;
uint lowWord = (uint16_t)this->player->m_position.objcell_id; // pc:91004
return lowWord < 0x100; // (decompiler garbles the compare; semantics: outdoor iff < 0x100)
}
```
Outdoor landcell ids are `0x0001..0x0040`; EnvCell ids are `>= 0x0100`. This low-word test is
the *type* discriminator used everywhere (`find_cell_list` pc:308753; `GetVisible` pc:308209).
### 2.2 The decision matrix (port this exactly)
| Viewer cell | `seen_outside` | Top-level draw | Terrain/sky? |
|---|---|---|---|
| Outdoor landcell (`id&0xFFFF < 0x100`) | n/a | `LScape::draw` (full) | Yes (always). Buildings recurse via `CBldPortal`/`DrawPortal`. |
| EnvCell (cottage/inn interior) | **1** | `DrawInside` | **Only through exit portals** (`outside_view>0``LScape::draw` clipped to doorway). |
| EnvCell (dungeon / sealed room) | **0** | `DrawInside` | **No** (no exit portal reachable → `outside_view==0``LScape::draw` never called). |
**There is no "both at once" at the top level.** "Both" happens *only inside* the indoor path:
`DrawCells` draws the landscape (through exit portals) **then** the interior cells, in one pass.
### 2.3 `RenderDeviceD3D::DrawInside @ 0x0059f0d0` (pc:427843) — thin forwarder
```c
return PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2); // pc:427847
```
The render device owns two persistent `PView` instances: `indoor_pview` (rooted at the viewer
cell) and `outdoor_pview` (used by `DrawPortal` for outside-looking-in, Section 6.2).
---
## 3. PView traversal — step by step
`PView` ("portal view") is the heart of indoor rendering: a breadth-first (worklist) portal flood
over the shared `CEnvCell` graph that yields one ordered visible-cell list + per-cell clip regions.
### 3.1 Entry: `PView::DrawInside @ 0x005a5860` (pc:433793)
```c
Render::object_scale = 1; /* object_scale_vec = (1,1,1) */
CEnvCell::curr_view_push(viewer_cell); // pc:433800 push a view slot
PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // pc:433801 seed PVS from stab list
Frame::cache(&identityFrame); Render::positionPush(3, &identityFrame);
Render::copy_view(viewer_cell->portal_view[num_view-1], nullptr, 4); // pc:433814 seed root view = full screen
ConstructView(this, viewer_cell, 0xffff); // pc:433817 build visible set
PView::DrawCells(this, …); // pc:433819 draw it
Render::framePop();
PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list);
viewer_cell->num_view -= 1; // pop the view slot
```
`add_views`/`remove_views` (`@ 0x005a5210`) push/pop a per-cell `portal_view_type` slot for every
cell in the root's stab list, so the BFS has somewhere to accumulate clip regions for those cells.
### 3.2 The BFS: `PView::ConstructView(CEnvCell*, ushort) @ 0x005a57b0` (pc:433750)
Verbatim:
```c
this->outside_view.view_count = 0; // reset the outdoor-through-portal accumulator
PView::master_timestamp += 1; // new traversal stamp (cycle guard)
this->cell_todo_num = 0; this->cell_draw_num = 0;
PView::InitCell(this, root, portalId); // compute root's per-portal in/seen flags + clip
PView::InsCellTodoList(this, root, 0); // push root onto the worklist
while (true) {
if (cell_todo_num <= 0) return;
cell = cell_todo_list[--cell_todo_num]->cell; // POP (LIFO: index num-1)
if (cell == 0) return;
// append to the visible draw list (grow by 0x1e if needed):
cell_draw_list[cell_draw_num++] = cell; // pc:433783
cell->portal_view[cell->num_view - 1]->cell_view_done = 1; // pc:433784
if (PView::ClipPortals(this, cell, 0) != 0) // pc:433786 clip this cell's portals
PView::AddViewToPortals(this, cell); // pc:433787 enqueue visible neighbours
}
```
**Outputs:** `cell_draw_list[0..cell_draw_num]` (visible EnvCells in pop order), each cell's
`portal_view[...]->view` (accumulated screen clip polys), and `this->outside_view` (exit-portal
clip regions). Note it is technically a LIFO worklist (a stack), but converges to the same visible
set; "BFS" is used loosely in the studies.
### 3.3 `PView::InitCell @ 0x005a4b70` (pc:432896) — per-portal sidedness + clip init
For a cell's current view slot (`portal_view[num_view-1]`):
- Early-out if `esi[0xe] == 0` (no active view — `update_count`/`view_count` empty). pc:432903.
- `Render::positionPush(3, &cell->pos)` to enter cell-local space.
- For each portal: compute the portal polygon's plane vs the **viewer viewpoint**
(`Render::FrameCurrent->viewer.viewpoint`), set per-portal `seen`/`inflag`
(`portal_info { int seen; int inflag; }`, acclient.h:32459) according to `portal_side` — i.e.
which portals face the viewer and can be seen through. Compute `max_indist` and set
`update_count = view_count` (the watermark — see §5.4). This is "which of my portals are live
for this view slice."
### 3.4 `PView::AddToCell @ 0x005a4d90` (pc:433050) — incremental view-slice merge
When a cell is reached **again** through a *different* portal (a second clip region), `AddToCell`
processes only the **new** view slices, from the cell's `update_count` watermark up to its current
`view_count` (pc:433056-433080). This is how a far cell seen through two doorways accumulates two
clip regions without re-walking the slices it already has — the watermark guarantees each slice is
expanded once.
### 3.5 `PView::ClipPortals @ 0x005a5520` (pc:433572) — clip portals + feed `outside_view`
Two phases. **Phase 1 (pc:433583-433620):** for each portal with `seen != 0 && inflag != 1`,
resolve `other_cell_ptr` (via `CEnvCell::GetVisible(other_cell_id)`, cached into the portal); set a
local `var_c = 1` if the portal has *any* destination — a loaded neighbour, **or** the exit
sentinel `other_cell_id == 0xffffffff`, or a resolvable neighbour. If `var_c == 0` (no portal goes
anywhere) the function returns 0 → the cell contributes no neighbours.
**Phase 2 (pc:433622-…):** for each live view slice `i` of this cell, `Render::set_view`, then per
portal compute its screen clip via `GetClip` (`@ 0x005a4320`, projects the portal poly to screen,
runs `polyClipFinish`, honors `Sidedness`). The decisive branch on the portal's destination
(pc:433651-433701):
```c
GetClip(this, portal.portal_side, portal.portal, &clip_view, &clipNonEmpty, 1);
if (clipNonEmpty != 0) {
if (portal.other_cell_id == 0xffffffff) { // pc:433662 ── EXIT PORTAL ──
if (this->draw_landscape != 0) { // pc:433664 indoor PView built with draw_landscape=1
if (cliplandscape != 0)
Render::copy_view(this /*->outside_view*/, &clip_view, clipNonEmpty); // pc:433674
else /* draw_landscape */
Render::copy_view(this /*->outside_view*/, nullptr, 0);
}
} else if (portal.other_cell_ptr != 0) { // pc:433687 ── INTERIOR PORTAL ──
// OtherPortalClip intersects clip with the neighbour's own portal opening, then
// copies the resulting clip region into the neighbour cell's portal_view (set_view/copy_view)
OtherPortalClip(this, portal, &clip_view, &clipNonEmpty); // pc:433692
// → records the clipped view onto edi_1 (the neighbour's portal_view at +0x134/+0x138)
}
}
```
**This is the load-bearing seam.** The exit-portal branch (`0xffffffff`) is the *only* place the
outdoors enters the indoor traversal: it accumulates the doorway's screen region into
`this->outside_view`. The interior branch propagates the clipped region into the neighbour cell so
that cell is later drawn only through the intersection of all portals it was seen through.
### 3.6 `PView::AddViewToPortals @ 0x005a52d0` (pc:433446) — enqueue neighbours
For each portal of the cell whose `other_cell_ptr` exists and is active:
```c
neighbour = portal.other_cell_ptr;
if (neighbour not yet inited this traversal) {
InitCell(this, neighbour, portal.other_portal_id); // pc:433480 set up neighbour's view slot
InsCellTodoList(this, neighbour, portal.max_indist); // pc:433485 enqueue it
if (portal.flag >= 0) SetOtherSeen(this, cell, portalIdx); // pc:433490 mark exit-seen / matching portal
} else {
AddToCell(this, neighbour, portal.other_portal_id); // pc:433494 merge a new clip slice
}
```
`SetOtherSeen @ 0x005a4e30` records the reciprocal portal as seen (prevents re-entering the cell
you just came from with a redundant slice). `InsCellTodoList @ 0x005a4f50` pushes onto the worklist
(growing it as needed).
### 3.7 The exterior→interior sibling: `ConstructView(CBldPortal*, CPolygon*, int, int) @ 0x005a59a0` (pc:433827)
Used by `DrawPortal` (Section 6.2) when the camera is **outside** and can see a building door:
```c
// side-test the building portal polygon vs the viewer viewpoint:
d = dot(FrameCurrent->viewer.viewpoint, portal->plane.N) + portal->plane.d;
side = (d < 0.0002) ? NEGATIVE : (|d|<eps ? IN_PLANE : POSITIVE); // pc:433832-433849
if (side matches the portal's required sidedness) {
GetClip(this, side, portal, &clip_view, &arg3, arg4); // pc:433856 clip to the door opening
if (clip non-empty) {
interior = CEnvCell::GetVisible(bldPortal->other_cell_id); // the room behind the door
if (arg5 != 2) DrawPortalPolyInternal(portal, …); // stencil the doorway
ConstructView(this, interior, bldPortal->other_portal_id); // pc:433879 RECURSE into the interior
}
}
```
So **the same `ConstructView`** builds the interior visible set whether entered from inside (the
`CEnvCell` overload) or from outside through a building portal (the `CBldPortal` overload). This is
why outside↔inside is seamless.
---
## 4. Seal mechanics — `PView::DrawCells @ 0x005a4840` (pc:432709), the exact sequence
This is the function the prior studies under-covered. Read this section against the verbatim
pseudo-C; it has **three sequential per-cell loops** plus the landscape-through-door block.
### 4.1 The landscape-through-door block + conditional Z-clear (pc:432715-432732)
```c
if (this->outside_view.view_count > 0) { // pc:432715 an exit portal was visible
Render::useSunlightSet(1);
Render::PortalList = this; // pc:432718 tell LScape to clip to outside_view
LScape::draw(this->lscape); // pc:432719 DRAW terrain+sky+rain+exterior, CLIPPED
D3DPolyRender::FlushAlphaList(0);
render_device->m_nFrameStamp += 1;
// ── CONDITIONAL Z-ONLY CLEAR ──
bool nothingDrawn;
if (forceClear == 0) {
nothingDrawn = (D3DPolyRender::portalsDrawnCount == 0); // pc:432727
D3DPolyRender::portalsDrawnCount = 0;
}
if (forceClear != 0 || !nothingDrawn)
render_device->vtable->Clear(4, 0x820fc0, 1.0f); // pc:432732 flag 4 == Z-BUFFER ONLY
... (loops below run inside this `if`) ...
}
```
**Critical seal facts:**
- **`Clear(4, …)`** — the `4` is the **depth/Z buffer bit only**, NOT color. There is *no*
`Clear(color)` anywhere in this path. The "blue hole" acdream sees is the *absence* of the
`LScape::draw` step (the outdoors is never injected), not a stray blue clear.
- **The clear is conditional** on `portalsDrawnCount` (and `forceClear`). It re-establishes a clean
depth field for the interior cells after the landscape (which is at far depth) was drawn through
the doorway, so interior geometry composites correctly over it. Color is preserved → the terrain
pixels in the doorway survive.
- **`Render::PortalList = this`** before `LScape::draw` is what clips the *entire landscape draw*
to the union of exit-portal screen regions in `outside_view`. Outside the doorway region, the
landscape contributes nothing.
- **`0x820fc0`** is the clear-region/rect parameter (the area to clear), not a color.
### 4.2 Loop 1 — exit-portal stencil masks (pc:432737-432808)
Iterates `cell_draw_list` from `cell_draw_num-1` down to 1 (reverse). For each cell whose
`structure->drawing_bsp != 0`:
```c
SetCurrentMaterial(render_device, nullptr, 0);
Render::SetSurfaceArray(cell->surfaces);
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1; // -1 == "one default view"
for (view = 0; view < |viewCount|; view++) { // pc:432772
CEnvCell::setup_view(cell, view); // pc:432774 select this clip slice
for (j = 0; j < cell->num_portals; j++) {
if (cell->portals[j].other_cell_id == 0xffffffff) // pc:432785 EXIT portal
D3DPolyRender::DrawPortalPolyInternal(cell->portals[j].portal, 0); // pc:432786 STENCIL the opening
}
}
Render::framePop();
```
This stencils each exit-portal polygon (per clip slice) so the interior geometry drawn next is
masked to leave the doorway showing the already-drawn landscape. (`setup_view` re-binds the
per-slice clip region so multi-doorway cells are handled correctly.)
### 4.3 Loop 2 — draw the closed interior geometry (pc:432815-432866)
Runs **unconditionally** (outside the `outside_view>0` block — i.e., for dungeons too). Reverse
iterate `cell_draw_list`; for each cell with `drawing_bsp != 0`:
```c
SetCurrentMaterial(...); Render::SetSurfaceArray(cell->surfaces);
Render::object_scale = 1; Render::positionPush(3, &cell->pos);
viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1;
for (view = 0; view < |viewCount|; view++) {
CEnvCell::setup_view(cell, view); // pc:432852 select clip slice
render_device->vtable->DrawEnvCell(cell); // pc:432853 DRAW floor+walls+CEILING (drawing_bsp)
}
Render::framePop();
```
**`DrawEnvCell` draws the cell's full closed mesh** (floor, four walls, ceiling) from
`structure->drawing_bsp`, back-face-culled and clipped to the cell's accumulated `portal_view`
slices. The ceiling is sealed *because it is part of the cell mesh* — there is no separate cap step.
Portal openings are not filled (Loop 1 stenciled them).
### 4.4 Loop 3 — per-cell objects/particles, clipped (pc:432870-432882)
```c
for (cell = cell_draw_num-1; cell >= 0; cell--) {
eax = cell_draw_list[cell];
// PortalList = the cell's CURRENT portal_view slot (its accumulated clip region):
Render::PortalList = *(eax->portal_view.data[eax->num_view - 1] ... ); // pc:432877
render_device->vtable->DrawObjCellForDummies(eax); // pc:432878 draw this cell's objects
}
Render::object_scale = 1; Render::useSunlightSet(1); // restore
```
**`DrawObjCellForDummies(cell)`** draws the objects registered in that cell's `object_list`
(the same list physics `enter_cell`/`leave_cell` maintains), with `Render::PortalList` set to the
cell's clip region — so objects (and their attached particles) are clipped to the portal opening(s)
through which their cell is visible. **An object in a cell not in `cell_draw_list` is never drawn**
→ no bleed-through. An object straddling a portal is clipped to the opening.
### 4.5 Why the seal holds — the four guarantees
1. **No blue hole:** outdoors is drawn first (`LScape::draw`, clipped to `outside_view`); the only
clear is Z-only and conditional; color survives in the doorway.
2. **Sealed ceiling/walls:** each visible cell's `drawing_bsp` is a closed box; `DrawEnvCell` draws
it whole; portal holes are stencil-masked, not filled.
3. **No outdoor bleed-in:** the landscape only paints through exit-portal clip regions; if
`outside_view.view_count == 0` (dungeon), `LScape::draw` is never called at all.
4. **No object/particle bleed:** objects are drawn per-cell, clipped to that cell's `PortalList`,
only for cells in `cell_draw_list`.
---
## 5. Data structures (annotated, verbatim from `acclient.h`)
### 5.1 The cell hierarchy
```c
// acclient.h:30915 — the base cell. ONE graph for physics AND render.
struct CObjCell : SerializeUsingPackDBObj, CPartCell {
LandDefs::WaterType water_type;
Position pos; // cell-to-world frame
unsigned int num_objects;
DArray<CPhysicsObj*> object_list; // RENDER + physics: objects in this cell (Loop 3)
unsigned int num_lights; DArray<LIGHTOBJ const*> light_list;
unsigned int num_shadow_objects;
DArray<CShadowObj*> shadow_object_list; // PHYSICS: collision objects registered here
unsigned int restriction_obj;
ClipPlaneList **clip_planes;
unsigned int num_stabs;
unsigned int *stab_list; // STATIC visibility set (PVS): cell ids this cell can see
int seen_outside; // boolean: this interior can reach the outdoors ★
LongNIValHash<GlobalVoyeurInfo>* voyeur_table;
CLandBlock *myLandBlock_;
};
// acclient.h:31880 — adds a building pointer
struct CSortCell : CObjCell { CBuildingObj* building; };
// acclient.h:31886 — outdoor surface cell of a landblock (8×8 grid → ids 0x01..0x40)
struct CLandCell : CSortCell { CPolygon** polygons; BoundingType in_view; };
// acclient.h:32072 — interior cell (building room OR dungeon room; id >= 0x100)
struct CEnvCell : CObjCell {
unsigned int num_surfaces; CSurface** surfaces; // material/surface array (RENDER)
CCellStruct *structure; // geometry + 3 BSP trees (see 5.3)
CEnvironment *env;
unsigned int num_portals; CCellPortal* portals; // portal graph edges
unsigned int num_static_objects;
IDClass* static_object_ids; Frame* static_object_frames; CPhysicsObj** static_objects;
RGBColor *light_array; int incell_timestamp;
MeshBuffer *constructed_mesh; int use_built_mesh; unsigned int m_current_render_frame_num;
unsigned int num_view; // RENDER: # of active per-portal view slots
DArray<portal_view_type*> portal_view; // RENDER: per-portal clip state (see 5.4) ★
};
```
`seen_outside` is the dat flag `EnvCellFlags.SeenOutside = 0x1` (ACViewer
`ACE.Entity/Enum/EnvCellFlags.cs:7`). **Confirmed verbatim** at acclient.h:30929.
### 5.2 The portals
```c
// acclient.h:32300 — interior↔interior (room↔room) AND interior→exterior
struct CCellPortal {
unsigned int other_cell_id; // 0xFFFFFFFF (low 0xFFFF) ⇒ EXIT PORTAL (opens to the outdoors) ★
CEnvCell *other_cell_ptr; // cached resolved neighbour (or null until GetVisible)
CPolygon *portal; // the portal polygon (its plane = the doorway plane)
int portal_side; // which half-space is "inside" this cell
int other_portal_id; // index of the matching portal in the neighbour
int exact_match;
};
// acclient.h:32094 — outdoor landblock → building interior (the door from the street)
struct CBldPortal {
int portal_side;
unsigned int other_cell_id; // the interior EnvCell this building portal leads into
int other_portal_id;
int exact_match;
unsigned int num_stabs; unsigned int* stab_list; // PVS of interior cells visible through this door
float sidedness;
};
```
Held by `CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves;
CPartCell** leaf_cells; ... }` (acclient.h:31908), which a `CLandCell`/`CSortCell` references.
### 5.3 Per-cell geometry: `CCellStruct` (acclient.h:32275) — THREE BSP trees
```c
struct CCellStruct {
... vertex_array; num_portals; CPolygon** portals; surface_strips; polygons;
BSPTree* drawing_bsp; // RENDER — back-face order the closed cell mesh (DrawEnvCell)
... physics_polygons;
BSPTree* physics_bsp; // PHYSICS — collide the sphere against cell walls/floor
BSPTree* cell_bsp; // CONTAINMENT — point/sphere-in-cell tests (point_in_cell, membership)
};
```
A dungeon cell and a building-interior cell use the **same** struct; only their portal topology and
`seen_outside` differ. **The closed mesh (with ceiling) is authored in the dat** — there is no
ceiling-cap code path.
### 5.4 The render view state — `portal_view_type` (acclient.h:32346) + the watermark
```c
struct portal_view_type { // one slot per active view of a CEnvCell (CEnvCell.portal_view[])
DArray<portal_info> portal; // per-portal { int seen; int inflag; } (acclient.h:32459)
view_type view; // the screen-clip geometry (poly+vertex)
float max_indist;
unsigned int view_count; // how many view slices this cell currently has
int cell_view_done;
int view_timestamp;
int update_count; // ★ WATERMARK: slices [update_count..view_count) are "new"
};
struct view_type { unsigned vertex_count_total; DArray<view_poly> poly; DArray<view_vertex> vertex; };
struct view_poly { int vertex_count; int vertex_index; float xmin, xmax, ymin, ymax; }; // 2D screen clip rect/poly
```
**The `update_count` watermark — how it bounds the BFS without a fixed cap (issue #102):**
When a cell is *first* reached, `InitCell` sets `update_count = view_count` (the slices it has).
When the cell is reached *again* through another portal, `AddViewToPortals → AddToCell` only
processes slices from `update_count` up to the (now larger) `view_count` — i.e., the genuinely new
clip regions — then advances `update_count`. A cell therefore expands each distinct view slice
**exactly once**; the traversal terminates when no portal produces a new slice. `master_timestamp`
(bumped per `ConstructView`) + `view_timestamp`/`cell_view_done` are the per-traversal cycle guards.
**This is the retail replacement for acdream's `PortalVisibilityBuilder.MaxReprocessPerCell = 4`**
and the correct fix for dungeon PVS blowup (#95): the watermark naturally converges; a fixed cap
either truncates (missing cells) or never converges (blowup).
### 5.5 The traversal owner — `PView` (acclient.h:45934)
```c
struct PView {
portal_view_type outside_view; // ★ accumulated clip region of the outdoors seen thru exit portals
int draw_landscape; // 1 for indoor_pview ⇒ exit portals feed outside_view (ClipPortals)
CBldPortal **outdoor_portal_list; // building doors the camera can see (used by DrawPortal)
DArray<CEnvCell*> cell_draw_list; // ★ ordered visible cells (the BFS output)
unsigned int cell_draw_num;
DArray<CellListType*> cell_todo_list; // the worklist
unsigned int cell_todo_num;
LScape *lscape; // the landscape to draw through doorways
};
```
### 5.6 The physics-membership structs (summary — full detail in the studies)
```c
// acclient.h:32625 — the per-transition working state
struct SPHEREPATH {
... CObjCell* begin_cell; Position* begin_pos; Position* end_pos;
CObjCell* curr_cell; Position curr_pos; // ★ ACCEPTED membership (the answer)
... CObjCell* check_cell; Position check_pos; // candidate this sub-step
SPHEREPATH::InsertType insert_type;
CObjCell* backup_cell; ...
int hits_interior_cell; int cell_array_valid; ...
};
// acclient.h:31574 — the COLLISION candidate set (distinct from curr_cell)
struct CELLARRAY { int added_outside; int do_not_load_cells; unsigned int num_cells;
DArray<CELLINFO> cells; }; // CELLINFO = { uint cell_id; CObjCell* cell; }
```
---
## 6. Dungeons, outside-looking-in, object/particle clipping
### 6.1 Dungeons — emergent, no flag
- **Data:** a dungeon landblock has terrain heights all 0, ≥1 EnvCell, and **no buildings** (ACE's
`IsDungeon` heuristic, `references/ACE/.../Landblock.cs:575` — a *server* heuristic; the **client
needs no such flag**). Its EnvCells have `seen_outside == 0` and **no exit portals**.
- **Movement:** identical to a building interior — `transition` advances `curr_cell` across
`CCellPortal`s; `find_transit_cells`' exit-portal flag (`var_44`) never fires (no exit portals),
so `add_all_outside_cells` is never called → outdoor cells never enter the candidate set.
- **Loading:** `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878) — adds self + every
`stab_list` cell to the visible table, then `if (seen_outside == 0) return;` (**pc:311893** —
dungeon stops here, never touches the landscape); a `seen_outside` cell tail-calls
`LScape::grab_visible_cells`. **This is the exact load-time decision** "stream the outdoor world
or not."
- **Render:** `DrawInside` runs; `ConstructView` floods the dungeon cells; **no exit portal is ever
reached**, so `outside_view.view_count` stays 0; `DrawCells`' landscape block (§4.1) is skipped;
Loops 2+3 still draw the closed cells + their objects. Result: sealed dungeon, no terrain, no sky
— by construction, same path as a cottage.
### 6.2 Outside-looking-in — a building interior seen through its door from the street
Driven by the **outdoor** render (`LScape::draw`), which calls `PView::DrawPortal @ 0x005a5ab0`
(pc:433895) for each building door the camera can see:
```c
Render::m_pRenderer->polyListFinishInternal(); ACRender::backup_curr_state();
bldPortal = this->outdoor_portal_list[arg2->portal_index]; // the building's door
portal = arg2->portal; // its polygon
PView::add_views(this, bldPortal->num_stabs, bldPortal->stab_list); // seed interior PVS
result = ConstructView(this, bldPortal, portal, arg3, arg4); // pc:433910 build interior visible set thru door
if (result == 0) { // door not actually visible
if (arg4 == 3) DrawPortalPolyInternal(portal, 0); // just stencil the closed door
ACRender::restore_curr_state();
} else {
if (arg4 != 1) PView::DrawCells(this, …); // pc:433924 DRAW the interior through the door
ACRender::restore_curr_state();
Render::positionPush(3, CBuildingObj::curr_pos); Render::obj_view_set();
}
PView::remove_views(this, bldPortal->num_stabs, bldPortal->stab_list);
```
So the **same `ConstructView` + `DrawCells`** that render an interior from inside also render it
from outside through the door — entered via the `CBldPortal` overload of `ConstructView`
(§3.7) which side-tests the door plane, clips to the opening, and recurses into the room. The
`outdoor_pview` instance is used here (vs `indoor_pview` for `DrawInside`). This is the path
acdream is missing ("outside-looking-in shows no interior" residual, spec §1a).
### 6.3 Object / particle clipping to the visible cell set
- **Membership:** objects live in a cell's `object_list` (and collision objects in
`shadow_object_list`), maintained by physics `enter_cell @ 0x00510ed0` / `leave_cell @
0x00510f50` — the **same** graph render reads. A particle attached to an object inherits the
object's cell.
- **Draw + clip (indoor):** Loop 3 of `DrawCells` (§4.4) iterates **only** `cell_draw_list`, draws
each cell's `object_list` via `DrawObjCellForDummies`, with `Render::PortalList` set to that
cell's clip region. Non-visible cells' objects are never iterated → no bleed.
- **Child-cell resolution for entities:** `CEnvCell::find_visible_child_cell @ 0x0052dc50`
(pc:311397) — given a point, returns the exact child cell containing it:
```c
if (point_in_cell(this, p)) return this; // pc:311402
if (arg3 == 0) { // search via portals:
for (portal in this->portals) {
n = CCellPortal::GetOtherCell(portal);
if (n && n->point_in_cell(p)) return n; // pc:311424
}
return 0;
} else { // search via stab_list (PVS):
for (id in this->stab_list) {
c = CEnvCell::visible_cell_table[id];
if (c && c->point_in_cell(p)) return c; // pc:311456
}
}
```
Used for the 3rd-person camera offset (which child cell is the eye in?) and for placing
entities/particles into the right cell **via the graph/BSP, never an AABB**. This is the
retail-faithful replacement for acdream's `CellVisibility.FindCameraCell` AABB + grace-frame
resolver.
---
## 7. PORTING CHECKLIST — the spine of the redesign
Ordered list of every behavior a faithful C# port must implement to get a fully-sealed
outdoor+indoor+dungeon render with **no bleed, no flaps, no transparent walls, no blue hole**.
Cross-referenced to the Phase W staged plan; each behavior cites its retail anchor.
### CL-A. Membership foundation (prerequisite — physics owns the cell)
*(Detail in the four studies + spec §1/§1a; summarized here because render roots on it.)*
- [ ] **A1.** `ResolveWithTransition` returns the swept `sp.CurCellId` (mirror
`SetPositionInternal @ 0x00515330` reading `sphere_path.curr_cell`), **not** a static
`ResolveCellId(origin,…)`. Demote `ResolveCellId` to seed-only (spawn/teleport/server-set).
- [ ] **A2.** Replace `FindEnvCollisions`' early static re-derive (`TransitionTypes.cs:1947→1949`)
with a retail directed exit-portal crossing (`CEnvCell::find_transit_cells @ 0x0052c820`
exit-portal path) — become outdoor by crossing the doorway polygon, not by re-resolving XYZ.
- [ ] **A3.** Port the `find_cell_list` interior-wins pick (`@ 0x0052b4e0`, pc:308814-308819) +
`do_not_load_cells` prune (pc:308829-308867) into `CellTransit`; re-gate `FindTransitCellsSphere`'s
unconditional `exitOutside=true` (`CellTransit.cs:95-123`).
- [ ] **A4.** Commit-on-difference: write `CellGraph.CurrCell` once, fire a "cell changed" event
only when it differs (mirror `change_cell @ 0x00513390`, the `if (this->cell != curr_cell)` guard).
### CL-B. Render root unification (Stage 3)
- [ ] **B1.** Port the **single per-frame decision** (`RenderNormalMode @ 0x00453aa0`): viewer cell
is an outdoor landcell (`id&0xFFFF < 0x100`, `is_player_outside @ 0x00451e80`) → outdoor path;
else → indoor `DrawInside`. Remove the `ACDREAM_A8_INDOOR_BRANCH` two-pipe split.
- [ ] **B2.** Root render visibility at the **physics `CellGraph.CurrCell`** (the U.4c flap fix),
not an independent AABB `FindCameraCell`. Delete the 3-frame grace-frame hack.
- [ ] **B3.** 3rd-person camera offset resolves its child cell via
`CEnvCell::find_visible_child_cell @ 0x0052dc50` (graph/BSP), rooted at the player cell — never
an AABB reclassification. (Eye drives projection; player cell drives the visibility root.)
- [ ] **B4.** Port the **landscape keep/release policy** (`CellManager::ChangePosition @
0x004559b0`, pc:94601-94682): keep landscape loaded + sunlight/outdoor-ambient live iff the
current cell is a landcell OR `seen_outside`; else `LScape::release_all` + flat indoor ambient.
- [ ] **B5.** Port `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878): add self + `stab_list`
to the visible set; load the landscape **iff `seen_outside`** (the dungeon gate, pc:311893).
- [ ] **B6.** Pre-position the through-door terrain via `Position::get_outside_cell_id @
0x004527b0` + `LScape::update_viewpoint` before the indoor draw (when `seen_outside`).
### CL-C. PView traversal (Stage 4, the big one)
- [ ] **C1.** Port the BFS `PView::ConstructView(CEnvCell*) @ 0x005a57b0`: reset `outside_view`,
bump `master_timestamp`, `InitCell(root)`, push root, then pop-and-expand into `cell_draw_list`
via `ClipPortals` + `AddViewToPortals` until the worklist drains.
- [ ] **C2.** Port `PView::InitCell @ 0x005a4b70`: per-portal sidedness vs the **viewer viewpoint**
`seen`/`inflag`; compute `max_indist`; set `update_count = view_count`.
- [ ] **C3.** Port `PView::ClipPortals @ 0x005a5520` with **both** branches: exit portal
(`other_cell_id == 0xffffffff`) → `copy_view` into `outside_view` (gated by `draw_landscape`);
interior portal → `OtherPortalClip` → propagate clipped region into the neighbour's `portal_view`.
- [ ] **C4.** Port `PView::AddViewToPortals @ 0x005a52d0`: enqueue uninited neighbours
(`InitCell` + `InsCellTodoList` + `SetOtherSeen`); merge a new slice into already-inited
neighbours (`AddToCell @ 0x005a4d90`).
- [ ] **C5.** Implement the **`update_count` watermark** convergence (per-cell, slices
`[update_count..view_count)` processed once) — **delete the fixed `MaxReprocessPerCell` cap**
(closes #102; correct dungeon-PVS fix for #95).
- [ ] **C6.** Port `PView::GetClip @ 0x005a4320` (project portal poly → screen, `polyClipFinish`,
honor `Sidedness`) producing 2D `view_poly` clip rects/polys.
### CL-D. Seal mechanics in `DrawCells` (Stage 4)
- [ ] **D1.** When `outside_view.view_count > 0`: set `PortalList = this`, `LScape::draw(lscape)`
(terrain+sky+rain **clipped to the doorway**) FIRST (`DrawCells @ 0x005a4840`, pc:432715-432719).
- [ ] **D2.** Implement the **conditional Z-only clear** (`Clear(4, …)`, pc:432731-432732): depth
bit only, NOT color; conditional on `portalsDrawnCount`/`forceClear`. **Never `Clear(color)` in
the indoor path** — that is the blue-hole bug.
- [ ] **D3.** **Loop 1** — per visible cell, per view slice (`setup_view`), `DrawPortalPolyInternal`
every exit portal (`other_cell_id == 0xffffffff`) to stencil the openings (pc:432785-432786).
- [ ] **D4.** **Loop 2** — per visible cell, per view slice, `DrawEnvCell` the closed
`structure->drawing_bsp` (floor+walls+**ceiling**). Runs for dungeons too (unconditional). No
ceiling-cap step — the mesh is closed (pc:432852-432853).
- [ ] **D5.** **Loop 3** — per visible cell, set `PortalList` to the cell's clip region, then
`DrawObjCellForDummies` (the cell's `object_list`) — objects/particles clipped to the doorway
(pc:432876-432878).
- [ ] **D6.** Renderer self-contained GL state: the indoor pass must SET every GL state it depends
on (view-proj, BLEND, DepthMask, Cull, FrontFace, A2C) — never inherit (memory
`render-self-contained-gl-state`; `EnvCellRenderer` hit this 3× in U.4).
### CL-E. Outside-looking-in (Stage 4/5)
- [ ] **E1.** Port `PView::DrawPortal @ 0x005a5ab0` on the **outdoor** path: for each visible
building door (`outdoor_portal_list`), `add_views(stab_list)`,
`ConstructView(CBldPortal,polygon)`, then `DrawCells` the interior through the door's clip; if
the door isn't actually visible, just `DrawPortalPolyInternal` (stencil the closed door).
- [ ] **E2.** Port the `ConstructView(CBldPortal*) @ 0x005a59a0` exterior→interior recursion:
side-test the door plane vs the viewer, `GetClip` to the opening, `GetVisible(other_cell_id)`,
optional `DrawPortalPolyInternal`, then **recurse `ConstructView(interior, other_portal_id)`**.
- [ ] **E3.** Use a **separate `outdoor_pview`** instance for E1/E2 (vs `indoor_pview` for
`DrawInside`), matching `RenderDeviceD3D::indoor_pview`/`outdoor_pview_1`.
### CL-F. Entity / particle cell clipping (Stage 5)
- [ ] **F1.** Entities/particles are placed into cells via the **physics graph** (`object_list`),
resolved with `find_visible_child_cell @ 0x0052dc50` — never an AABB.
- [ ] **F2.** Entities/particles draw **only** for cells in `cell_draw_list`, clipped to the cell's
`PortalList` (Loop 3). Non-visible-cell entities are not drawn → no NPC/door/smoke bleed-through.
- [ ] **F3.** An entity straddling a portal is clipped to the portal opening (inherits the cell's
clip region), not drawn full-screen.
### CL-G. Conformance / acceptance gates
- [ ] **G1.** Cottage (`seen_outside`): flicker gone (CL-A); interior sealed; sky/rain visible
through the door; **no blue hole**; no transparent walls; no bleed-through.
- [ ] **G2.** Dungeon (`seen_outside == 0` everywhere): sealed; **no terrain/sky**;
`outside_view.view_count == 0`; traversal converges (watermark) without blowup (#95).
- [ ] **G3.** Outside-looking-in: standing in the street facing an open cottage door, the interior
(walls/floor/objects) renders through the doorway (CL-E).
- [ ] **G4.** Headless asserts: doorway `outside_view` non-empty + `LScape::draw` invoked;
sealed-cellar `outside_view` empty + `LScape::draw` NOT invoked; PVS root id == physics
`CurrCell.Id` every frame; a cell receiving two clip slices is processed once per slice.
---
## Appendix — primary decomp address index (all read/verified this session)
```
Top-level / landscape policy
SmartBox::RenderNormalMode 0x00453aa0 pc:92635 (binary decision; DrawInside vs LScape::draw)
SmartBox::is_player_outside 0x00451e80 pc:90996 (low-word objcell_id < 0x100)
Position::get_outside_cell_id 0x004527b0 pc:91552 (interior pos → outdoor landcell id)
CellManager::ChangePosition 0x004559b0 pc:94601 (keep/release landscape on seen_outside)
CEnvCell::grab_visible_cells 0x0052e220 pc:311878 (self+stab; landscape iff seen_outside @311893)
RenderDeviceD3D::DrawInside 0x0059f0d0 pc:427843 (→ PView::DrawInside(indoor_pview))
PView traversal + seal
PView::DrawInside 0x005a5860 pc:433793
PView::ConstructView(CEnvCell) 0x005a57b0 pc:433750 (the BFS worklist)
PView::ConstructView(CBldPortal) 0x005a59a0 pc:433827 (exterior→interior recursion)
PView::InitCell 0x005a4b70 pc:432896 (per-portal sidedness; update_count=view_count)
PView::AddToCell 0x005a4d90 pc:433050 (incremental new-slice merge)
PView::AddViewToPortals 0x005a52d0 pc:433446 (enqueue neighbours / SetOtherSeen)
PView::ClipPortals 0x005a5520 pc:433572 (exit→outside_view @433662; interior→OtherPortalClip)
PView::OtherPortalClip 0x005a5400 pc:433524
PView::GetClip 0x005a4320 pc:432344 (portal poly → screen clip)
PView::SetOtherSeen 0x005a4e30 pc:433089
PView::InsCellTodoList 0x005a4f50 pc:433183
PView::add_views / remove_views 0x005a5210 pc:433382
PView::DrawCells 0x005a4840 pc:432709 (LScape-thru-door + Z-clear + 3 loops)
PView::DrawPortal 0x005a5ab0 pc:433895 (outside-looking-in entry)
LScape::draw 0x00506330 (terrain+sky; clipped via Render::PortalList)
CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 (point → child cell via portals/stab_list)
Membership (summary; see studies)
CTransition::validate_transition 0x0050aa70 pc:272547
CTransition::check_other_cells 0x0050ae50 pc:272717
CObjCell::find_cell_list 0x0052b4e0 pc:308742
CPhysicsObj::SetPositionInternal 0x00515330 pc:283399
CPhysicsObj::change_cell 0x00513390 pc:281192
CEnvCell::find_transit_cells 0x0052c820 pc:309968
CLandCell::add_all_outside_cells 0x00533630 pc:317499
CEnvCell::check_building_transit 0x0052c5d0 pc:309827
CObjCell::GetVisible 0x0052ad40 pc:308209 (≥0x100→CEnvCell::GetVisible else CLandCell)
CEnvCell::GetVisible 0x0052dc10 pc:311378
Structs (acclient.h)
CObjCell 30915 (object_list 30920; shadow_object_list 30924; num_stabs/stab_list 30927-28; seen_outside 30929)
CEnvCell 32072 (structure 32076; portals 32079; surfaces 32075; num_view/portal_view 32089-90)
CLandCell 31886 | CSortCell 31880 | CBuildingObj 31908
CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 (drawing_bsp/physics_bsp/cell_bsp)
portal_view_type 32346 (view_count, cell_view_done, view_timestamp, update_count) | view_type 32338
view_poly 32465 | portal_info 32459 (seen, inflag) | PView 45934 (outside_view, draw_landscape,
outdoor_portal_list, cell_draw_list, cell_todo_list, lscape)
SPHEREPATH 32625 | CELLARRAY 31574 | CELLINFO 31925
Reference cross-checks
ACE Transition.cs:984 / PhysicsObj.cs:1171 / ObjCell.cs:335 / EnvCell.cs:311 / CellArray.cs:17
ACE Landblock.cs:575 (IsDungeon, server heuristic — client needs no flag)
ACViewer EnvCellFlags.cs:7 (SeenOutside=0x1); Buffer.cs (brute-force draw, NO PView — divergent)
WorldBuilder PortalService / VisibilityManager.RenderInsideOut (flat stencil — Silk.NET base, NOT the algorithm)
```
> **Divergence note (do not copy the references' render model).** ACViewer brute-force draws all
> loaded EnvCells with a `DungeonMode` cull toggle; WorldBuilder uses a flat `RenderInsideOut`
> stencil pass. **Neither implements retail's portal-clipped `PView` / landscape-through-door.**
> For Sections 27 the decomp is the sole authority and it wins. WorldBuilder's classes are a
> useful Silk.NET *implementation* base (buffer management, shader plumbing) but the *algorithm*
> is `PView` as documented above.

View file

@ -0,0 +1,169 @@
# Render Pipeline Redesign — Full Staged Plan (2026-06-02)
> **Read the master handoff first:** `docs/research/2026-06-02-render-pipeline-redesign-handoff.md`
> (§2 evidence, §3 root cause, §5 retail target + porting checklist CL-A..G).
> Then the 3 research docs. Then **BRAINSTORM** (Phase R0) before any code.
## The mandate (user, 2026-06-02 — non-negotiable, repeated so it's never lost)
**FULLY WORKING outdoor + indoor + dungeon rendering. No flaps, no missing textures, no
transparent walls, no terrain leaking into cellars, no entity/particle bleed. NO shortcuts,
NO bandaids, NO quick fixes — if the architecturally-correct way is slower, take it. Refactor
or redesign the whole pipeline if that's what it takes. Port from retail. Do more research
mid-session rather than guess. Start with a brainstorm.**
---
## 1. The target architecture (retail-faithful — the ONE model)
From the retail decomp (handoff §5; doc A). The single inversion that fixes everything:
> **When the viewer is in an EnvCell, run ONLY `DrawInside` — the one PView portal flood.
> Do NOT draw the outdoor world and then gate it. Visibility IS the cull: only the visible
> cells and their per-cell objects render; the landscape (terrain/sky/rain) is pulled in
> ONLY through clipped exit portals, followed by a conditional depth-only clear.**
```
RenderWorld(viewer):
if viewer.cell is outdoor landcell:
DrawOutside() # LScape: terrain + scenery + sky + weather (today's outdoor path)
# buildings seen from outside render their interiors via DrawPortal (R5, outside-looking-in)
else: # viewer in an EnvCell
DrawInside(viewer.cell): # ONE flood — nothing else
frame = ConstructView(cell) # PView BFS → cell_draw_list + per-cell clip + outside_view
if frame.outside_view nonEmpty:
DrawLScapeClippedTo(frame.outside_view) # terrain/sky/rain through the doorway
ConditionalDepthOnlyClear(frame.outside_view) # Z only — never color → no blue hole
for cell in frame.cell_draw_list (closest-first):
DrawCellShell(cell, clip=frame.clip[cell]) # closed geometry: floor+walls+ceiling
DrawCellObjects(cell, clip=frame.clip[cell]) # ONLY this cell's objects (statics/entities)
DrawCellParticles(cell, clip=frame.clip[cell]) # ONLY this cell's particles
```
**Components to KEEP (research says these are correct):** `PortalVisibilityBuilder` (the BFS),
`ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`/`PortalView` (the clip machinery), `EnvCellRenderer`
mesh/geometry path, `TerrainModernRenderer`, the WB mesh pipeline, the membership fix (`59f3a13`),
the Stage-4 sky NDC-clip + doorway Z-clear, the diagnostic probes.
**The work is RESTRUCTURING THE ORCHESTRATION + the entity/particle draw, not a from-scratch
rewrite.** No stencil two-pipe, no `isInside` gate, no AABB grace-frame, no WB `RenderInsideOut`
(handoff §6/§9).
---
## 2. The phases (each is visually-verifiable; retail-anchored; no bandaids)
> Sizing note: this is a multi-session arc (M1.5 "indoor world feels right"). Each phase ends
> at a **user visual gate** (the only acceptance that counts for a render seal — handoff §9
> lesson). Do NOT batch phases past a gate. The phases are ordered so each is independently
> testable and the bleed is killed early.
### Phase R0 — Brainstorm + lock the design (FIRST, with the user)
- `superpowers:brainstorming` the §1 architecture with the user. Confirm the single-flood model,
the per-cell object draw, the keep/redesign split. Resolve the open questions in §3.
- Write the detailed per-phase spec (`docs/superpowers/specs/2026-06-…-render-redesign-design.md`).
- **No code.** Gate: design approved.
### Phase R1 — One visibility authority + kill the outdoor-scenery bleed
**Retail anchor:** CL-B (render-root unification), CL-F (visibility is the cull). Fact 8 (§5.1).
- Make `PortalVisibilityBuilder.Build`'s `OrderedVisibleCells` / `CellViews` the **single**
visibility answer. Route the **entity** dispatch off it (the same `pvFrame` the shells/terrain
use) instead of the parallel `CellVisibility.ComputeVisibilityFromRoot` BFS.
- **Delete the `ParentCellId==null → return true` bypass** at `WbDrawDispatcher.cs:1756`. Outdoor
scenery (houses/trees/stabs) is gated to `OutdoorVisible` (drawn only when an exit portal makes
the outdoors visible, and then clipped to it). This is the unification, not a special-case patch.
- Decommission the duplicate `CellVisibility` entity path (or make it return the identical set).
- **Gate (visual):** standing in the cellar, **no houses/trees/outdoor stabs are visible.** The
`[shell]`/`[vis]`/entity probes confirm one visibility set drives all geometry.
### Phase R2 — The binary render decision: indoor = `DrawInside` only
**Retail anchor:** CL-B1 (single decision), `RenderNormalMode @ 0x453aa0`, fact 2 (§5.1).
- Restructure `GameWindow.OnRender` so that when `CellGraph.CurrCell` is an EnvCell, the **full
outdoor draw is NOT issued** — only the indoor flood runs. The outdoor terrain/scenery/sky are
reachable only via the exit-portal path (R3). When outdoors, the existing outdoor path runs.
- Remove the "draw outdoor world, then draw cell shells on top, gated" structure. The render root
is the physics `CurrCell` (already wired, Stage 3); the *consequence* (only-DrawInside-when-inside)
is the new part.
- **Gate (visual):** indoors you no longer see the full-screen outdoor "world background"; you see
the interior (walls/floor/ceiling) and, through the door, the outside (still rough until R3).
### Phase R3 — The seal mechanics (`DrawCells` faithful port) — THE seal
**Retail anchor:** CL-D, `PView::DrawCells @ 0x5a4840` (the three-loop seal sequence), fact 57.
- In the indoor flood, when `outside_view` is non-empty: draw `LScape` (terrain/sky/rain) **clipped
to the doorway** (reuse the Stage-4 sky NDC-clip + the terrain OutsideView clip) → **conditional
depth-only clear** (the Stage-4 Z-clear) scissored to the doorway → then the cells.
- Verify cell shells are **closed** (floor + walls + ceiling from `drawing_bsp`); the `[shell]`
evidence shows geometry is present — confirm the floor face is in the mesh and faces correctly.
- Resolve the terrain *model*: terrain is drawn ONLY through the exit-portal clip, never as a floor
under the interior. Remove the `TerrainClipMode.Skip`-as-floor-removal confusion.
- Self-contained GL state per draw (memory `render-self-contained-gl-state`).
- **Gate (visual):** the cottage interior is **fully sealed** — opaque walls, solid floor, ceiling,
**sky + rain visible through the door only** (no blue hole, no full-screen), no terrain under the
floor, no grey-floor. The cellar is sealed.
### Phase R4 — Per-cell object + particle clipping (no bleed)
**Retail anchor:** CL-F, fact 8; `find_visible_child_cell @ 0x52dc50`; #104.
- Make the object draw **per visible cell** (iterate `cell_draw_list`, draw each cell's objects
clipped to that cell's region) instead of one global entity pass. Entities straddling a portal
are portal-clipped.
- Give particles a cell (`OwnerCellId` from the owning entity's `ParentCellId`) and clip them to
the visible set (#104).
- **Gate (visual):** no NPC / door / smoke bleed through walls; an NPC just outside the door is
visible through the doorway but not through the wall.
### Phase R5 — Outside-looking-in (U.5)
**Retail anchor:** CL-E, `DrawPortal @ 0x5a5ab0`, `ConstructView(CBldPortal) @ 0x5a59a0`, fact 9.
- When outdoors and a building's door/window is in view, render the building's **interior through
that portal's clip** (the mirror of DrawInside, entered from the outdoor cell). Same machinery.
- **Gate (visual):** looking through the cottage door/window from outside shows the **sealed
interior**, not transparent walls.
### Phase R6 — Dungeons
**Retail anchor:** fact 10 (emergent), the `update_count` watermark (fact 12 — #95), CL-C5.
- Validate the all-EnvCell / `seen_outside==0` / no-exit-portal path on a real dungeon: no terrain,
no sky, sealed cells, BFS convergence (the watermark bounds the reprocesses — confirm #95 closed).
- **Gate (visual):** a real dungeon is sealed, no terrain/sky, no FPS collapse from the BFS.
### Phase R7 — Polish + conformance
- Resolve the `CullMode.Landblock→None` double-sided stopgap (the actual winding).
- Textures: confirm no missing textures anywhere (the `[shell]` zh=0 evidence says good; verify
across dungeons).
- Conformance tests (headless): the PView frame product, the seal asserts (handoff §8 apparatus).
- Update roadmap; flip the M1.5 milestone; memory notes.
- **Final gate (visual):** cottage + dungeon + outside-looking-in, all sealed and seamless.
---
## 3. Open questions for the R0 brainstorm
- **Outdoor scenery while a door is open:** when indoors looking out, the visible outdoor scenery
(the cottage across the street) must draw — but clipped to the doorway. Does that come for free
from "LScape through the exit portal" (terrain + scenery both), or does scenery need its own
exit-portal-clipped pass? (Retail draws LScape — which includes scenery — through the clip.)
- **Building shells from outdoors:** when outdoors, the cottage's *exterior* shell must draw (it's a
building). Is that an EnvCell shell drawn from the outdoor root, or part of the outdoor scenery?
Reconcile with R5 (outside-looking-in) so the exterior + the door-interior compose correctly.
- **The `EnvCellRenderer` filter vs per-cell-clip:** today shells use one `Render(filter)` with
per-cell clip slots. R4 wants per-cell object draw. Confirm the EnvCellRenderer + WbDrawDispatcher
can both be driven per-cell from `cell_draw_list` without a global pass.
- **Two cameras (eye vs player cell):** the U.4c flap fix roots visibility at the player cell while
projecting from the eye. Confirm the redesign preserves that (the eye can be outside the player
cell in 3rd person) — `find_visible_child_cell` for the camera child.
- **Scope of the entity-draw restructure:** is per-cell object draw a refactor of `WbDrawDispatcher`
or a new dispatch path? (Per CLAUDE.md: don't break the working mesh pipeline; restructure the
orchestration around it.)
## 4. Risks
- **Big restructure of the render loop** — do it behind the visual gates, one phase at a time;
keep the outdoor path working throughout (it's the 99% case).
- **Per-cell object draw vs MDI batching** — the modern dispatcher batches across entities; a naive
per-cell loop could regress perf. Design the per-cell clip to preserve batching (the clip-slot SSBO
already supports per-instance clip; the cull is the membership, not a per-cell draw call necessarily).
- **Don't reintroduce the abandoned approaches** (handoff §9): no stencil two-pipe, no isInside gate,
no AABB grace-frame.
- **Get the user's eyes early** — every phase ends at a visual gate; never declare a seal off tests.
## 5. The no-shortcuts rules (enforce on every task)
1. If a task tempts a fast-but-wrong path, take the retail-faithful path; note the tradeoff in the commit.
2. No suppression flags, grace periods, or "if (problem) return early" guards at a symptom site.
3. Every AC-specific behavior cites a retail decomp anchor (address + pseudo-C line).
4. Mid-session research over guessing — if the retail behavior is unclear, read the decomp / attach cdb.
5. Each phase ends GREEN (build + tests) AND at a user visual gate. The seal is verified on screen.