feat(render): Phase A8 — indoor visibility + streaming fixes batch

Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:14:50 +02:00
parent e415bb3863
commit 5dc4140c11
38 changed files with 3965 additions and 277 deletions

View file

@ -0,0 +1,206 @@
# Issue #78 + cellar-stairs visibility culling — investigation report
**Date:** 2026-05-25 PM (continuation session)
**Status:** REPORT-ONLY. Awaiting user (a) camera-rotation falsification test and (b) approach selection before any code work.
**Predecessor handoff:** [`docs/research/2026-05-25-issue-100-shipped-and-culling-handoff.md`](2026-05-25-issue-100-shipped-and-culling-handoff.md)
---
## Symptom
Two visible defects share one root cause:
1. **Cellar-stairs (observed 2026-05-25 PM, evidence for #78):** standing in a Holtburg cottage cellar with the camera at certain angles, the outdoor terrain mesh renders as a sharp-edged grass rectangle covering the cellar stair geometry. **Clears when camera moves closer** (cottage walls + stair treads geometrically occlude). Gameplay unaffected — player can walk up/down normally.
2. **Inn-wall stabs (#78, filed 2026-05-19):** standing inside the Holtburg Inn looking at the floor or walls, the user sees other buildings in the distance at their correct world position + scale, visible THROUGH the floor and walls.
The user has NOT yet run the camera-rotation falsification test (Phase 1a of the handoff). Until they do, the diagnosis below is "high confidence" but not certain.
Sibling: **#95** (dungeon portal-graph blowup) is the same visibility subsystem but a different specific failure (over-inclusion). scen5 log shows `visibleCells` per cell reaching **295** (worse than the 135-145 filed).
---
## Hypotheses (ranked)
### H1 — Indoor-camera gate missing on outdoor render passes (HIGH confidence)
**Mechanism:** `TerrainModernRenderer.Draw` and `WbDrawDispatcher` render outdoor geometry unconditionally regardless of whether the camera is inside an EnvCell. Retail and WorldBuilder both gate the outdoor passes by the indoor portal-walk result. acdream does neither.
**Evidence FOR (strong):**
- Retail anchor verified: `PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709` gates `LScape::draw` (outdoor terrain dispatch) by `if (outside_view.view_count > 0)`. `outside_view.view_count` is only incremented during the indoor portal BFS (`PView::ConstructView`) when a portal targets `other_cell_id == 0xFFFFFFFF` (outdoor sentinel). When no portal sees outside, the entire outdoor pass is skipped.
- Retail's per-mesh draw (`RenderDeviceD3D::DrawMesh` line 429245) iterates `Render::PortalList->view_count` and skips meshes that straddle 0 sub-views. **No stencil** — retail uses screen-space polygon clipping via `PView::GetClip`.
- WB anchor verified: `VisibilityManager.RenderInsideOut` (lines 73-239) uses **stencil**: mark current-building portals stencil=1, punch portal regions to far depth, draw EnvCells unconditionally, then `terrain/scenery/statics` gated by `glStencilFunc(Equal, 1, 0x01)`. The top-level loop already skips the unconditional terrain draw via `if (!isInside) terrainManager.Render(...)` at GameScene.cs:965.
- acdream audit verified the gate is missing: `WbDrawDispatcher.cs:360-362` gates by `entity.ParentCellId.HasValue && !visibleCellIds.Contains(...)`. When `ParentCellId == null` (outdoor stabs, scenery, live-spawned entities), the boolean short-circuits to `cellInVis = true` — the entity passes regardless of `visibleCellIds`.
- `TerrainModernRenderer.Draw` (lines 191-208) only does per-slot frustum cull. No `visibleCellIds` parameter, no indoor-camera awareness.
- Patch geometry size (~24 m × 24 m rectangle) matches a terrain cell footprint — that's a polygon, not a precision artifact.
- "Clears when closer" matches geometric occlusion: cottage walls + stair treads come to occlude the offending terrain cells screen-space as the camera approaches. A 1 cm depth-buffer Z-fight (#100's nudge) at 2-5 m camera distance with 24-bit depth has sub-millimeter resolving power; precision is not the bottleneck.
**Evidence AGAINST:**
- User has not yet run the camera-rotation test. If the patch flickers/shimmers when rotating the camera in place, the diagnosis pivots to Z-precision.
**How to falsify:** Stand at the spot showing the cellar-stairs artifact, look at the grass patch, rotate the camera slowly without moving the character. Polygon-stable edges that track predictably with the view = culling (H1). Flickering / shimmering = Z-precision (H2).
### H2 — Residual Z-fight from #100's nudge (LOW confidence)
The 1 cm shader nudge from issue #100 might be insufficient at certain Z values or with shader precision quirks.
**Evidence FOR:** Same code area was just touched.
**Evidence AGAINST:** Predecessor research already established 1 cm @ 24-bit depth has sub-mm resolving at gameplay camera distances. Patch is rectangular polygon, not thin Z-fight strip. "Clears when closer" reverses precision direction.
**How to falsify:** Same camera-rotation test.
### H3 — #95 portal-traversal blowup is independent of H1 (HIGH confidence it IS independent)
**Mechanism:** `CellVisibility.GetVisibleCells` BFS over portals lacks termination/cap-depth logic. Network hubs expose 100+ outbound portals to disconnected dungeons, all marked visible. scen5 log shows up to 295 cells in one visible set.
**Evidence FOR independence:**
- H1 is an **asymmetric over-render** (outdoor passes ignore indoor state).
- H3 is a **symmetric over-inclusion** (BFS doesn't terminate properly).
- A fix to H1 would gate WHEN to render outdoor; H3's fix is to bound WHICH indoor cells the BFS includes.
- Different code paths: H1 lives in `TerrainModernRenderer.Draw` + `WbDrawDispatcher`; H3 lives in `CellVisibility.GetVisibleCells`.
**Conclusion:** H1 and H3 should be **separate fixes**. Closing H1 will close cellar-stairs + the outdoor-stab side of #78 but NOT close #95. The next phase should plan H1 in scope and decide whether H3 fits in the same milestone (M1.5).
---
## What we've ruled out
- **It's not the #100 cell-collapse bug returning.** `hiddenTerrainCells` plumbing was fully removed in `a64e6f2`; terrain mesh now correctly renders everywhere on the landblock per retail. The new artifact's mechanism is "outdoor geometry visible at all when indoor," not "incorrect terrain mesh shape."
- **It's not a depth-precision issue (high confidence, pending falsification).** Patch shape + "clears closer" both contradict Z-fight.
- **It's not a `ParentCellId` propagation bug.** Audit confirmed that interior cell static objects (`GameWindow.BuildInteriorEntitiesForStreaming:5476`) and cell-mesh entities (line 5416) both receive non-null `ParentCellId = envCellId`. The dispatcher's existing filter already correctly culls them when the camera is in a different building. The bug is the OPPOSITE direction (outdoor entities w/ `ParentCellId == null` always pass).
- **It's not WB extraction divergence.** Phase O extracted ~33 WB files into `src/AcDream.App/Rendering/Wb/` but the `VisibilityManager` / `RenderInsideOut` pipeline was NOT extracted — that code never existed in our tree.
- **It's not a missing camera-cell signal at the render layer.** `cameraInsideCell`, `visibility.VisibleCellIds`, and `visibility.HasExitPortalVisible` are all already computed in `GameWindow.cs:6970-6984` and live in scope at the two `Draw` call sites (lines 7074 + 7110). No new plumbing required.
---
## Approach options for the fix
Three viable approaches, with tradeoffs:
### Approach A — WB-style stencil (recommended for first ship)
Port `VisibilityManager.RenderInsideOut`'s stencil pipeline to acdream. Two-pass render: (1) mark current-building portal silhouettes in stencil, (2) gate outdoor passes by `glStencilFunc(Equal, 1, 0x01)`.
**Pros:**
- Closest to acdream's existing modern GL pipeline (we already use stencil for nothing else; adding one stencil bit is cheap).
- WB is acdream's documented rendering base (per CLAUDE.md). Cross-reference checked against retail confirms WB's intent matches retail's, just via a different mechanism.
- Handles the "see outside through open door" case correctly — terrain renders through portal silhouettes only.
- Reusable for both outdoor terrain AND outdoor entities (single stencil gate applies to all subsequent draws).
**Cons:**
- Multi-pass render adds GPU cost (small — one stencil pass per current-building's portals).
- Requires a portal-mesh upload pipeline (WB has one in `PortalRenderManager.cs:488-628`; we'd port it).
- More LOC than Approach C.
**Estimated scope:** 4-6 tasks, 1-2 weeks of implementation + verification.
### Approach B — Retail-faithful polygon-clip sub-views
Port `PView::ConstructView` + `PView::GetClip` + `Render::PortalList` from retail. Per-mesh viewport set to clipped portal polygon.
**Pros:**
- 100% retail-faithful.
**Cons:**
- Requires per-draw viewport scissor changes — current rendering uses bindless + MDI with one viewport per pass. Wedging per-mesh viewport in would break the modern pipeline's batching.
- Multi-week port. Out of scope for one session.
**Estimated scope:** 8-12 tasks, 4-6 weeks. Defer to a future milestone if needed.
### Approach C — Ship-now binary gate
When `cameraInsideCell && !visibility.HasExitPortalVisible`, skip outdoor terrain pass entirely and gate `WbDrawDispatcher` to exclude `ParentCellId == null` entities.
**Pros:**
- Smallest change. ~2-3 tasks. Closes the cellar-stairs symptom and the sealed-interior side of #78 immediately.
- All required state already computed (`HasExitPortalVisible` from `CellVisibility.GetVisibleCells` line 404).
**Cons:**
- Under-renders when player can see outside through an open door/window (renders nothing instead of clipping correctly). This is regressive vs. today for the doorway-view case.
- Per CLAUDE.md "no workarounds": this *is* a symptom-gate rather than a root-cause fix. **Would need explicit user approval.** Approach A is the correct shape; Approach C is a temporary patch.
**Estimated scope:** 2-3 tasks, 1-2 days.
---
## Recommended next step
1. **User runs the camera-rotation falsification test (~60 seconds).** Spawn at Holtburg, walk into a cottage cellar, find the angle showing the grass patch, rotate the camera in place without moving. Report what happens.
- Polygon-stable → confirms H1, proceed.
- Flickering → pivots to H2, this report needs major revision.
2. **If H1 confirmed: user picks Approach A vs C.** Recommendation: **Approach A (WB-style stencil)**. Per CLAUDE.md's "no workarounds" rule, the right thing is to port the stencil pipeline, not gate at the symptom site. Approach C is offered only if the user wants to close cellar-stairs immediately and defer doorway-view correctness as known-incomplete; that's an explicit workaround that needs user sign-off.
3. **#95 should NOT be in scope for this work.** Different mechanism, different code path. File continues as separate work in M1.5.
4. **Phase identifier:** the handoff proposes A8 (visibility) alongside A6 (physics) and A7 (lighting). I'll defer naming to the user.
5. **CLAUDE.md update for #100 ship:** the handoff calls this out as pending. Recommendation: add a brief #100 ship entry mentioning the cellar-stairs finding linked to #78. Out of scope for investigate mode; will happen at the start of the implementation session.
---
## What this is NOT
This is NOT a #100 regression. The terrain Z-nudge ship works correctly; the new artifact has a different root cause (indoor-camera gate on outdoor passes was already missing pre-#100#100 just made it more visible by removing the terrain-cell hide mechanism that incidentally masked it inside building footprints).
This is NOT a depth-precision fix. The 1cm nudge is correctly sized; larger nudges would break coplanar-floor disambiguation elsewhere.
This is NOT a `ParentCellId` data fix. Interior entities are correctly tagged.
This is NOT covered by Phase O's WB extraction. The visibility-management code was deliberately NOT extracted.
---
## Reference appendix
### Retail anchors (acclient_2013_pseudo_c.txt)
| Line | Symbol | Role |
|---|---|---|
| 92635 | `SmartBox::RenderNormalMode` | Per-frame top-level dispatcher (indoor vs outdoor branch) |
| 267912 | `LScape::draw` | Outdoor terrain dispatch |
| 311397 | `CEnvCell::find_visible_child_cell` | Point-in-visible-cell query |
| 311878 | `CEnvCell::grab_visible_cells` | Loads outdoor on `seen_outside` |
| 427843 | `RenderDeviceD3D::DrawInside` | Indoor entry point |
| 429245 | `RenderDeviceD3D::DrawMesh` | **Per-mesh portal-sub-view loop** |
| 430027 | `RenderDeviceD3D::DrawBlock` | Outdoor landblock dispatch |
| 432709 | **`PView::DrawCells`** | **The `outside_view.view_count > 0` gate** |
| 433750 | `PView::ConstructView` | BFS portal walk |
### WorldBuilder anchors
| File:Line | Role |
|---|---|
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` | `RenderInsideOut` — full stencil pipeline |
| Same file:241-359 | `RenderOutsideIn` — outdoor branch |
| Same file:47-71 | `PrepareVisibility` — visible cell set |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs:880-1008` | Main render dispatch (lines 965, 988 are the gates) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:488-628` | Portal mesh upload |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/CameraController.cs:142-174` | Camera-cell tracking (portal raycasts) |
| `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/PortalStencil.frag:7-16` | Stencil shader (writes `gl_FragDepth = 1.0`) |
### acdream extension points (audit-verified)
| File:Line | Current behavior | Extension required |
|---|---|---|
| `src/AcDream.App/Rendering/CellVisibility.cs:222-232` | Returns `VisibilityResult` with `VisibleCellIds`, `HasExitPortalVisible`, `CameraCell` | None — state already in place |
| `src/AcDream.App/Rendering/GameWindow.cs:6970-6984` | Computes `cameraInsideCell` and `playerInsideCell` per frame | None — values already in scope |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:360-374` | Gates by `ParentCellId ∈ visibleCellIds`; outdoor entities (null) always pass | Add second gate: when `cameraInsideCell == true` and entity is outdoor (`ParentCellId == null`), require stencil pass or skip entirely |
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs:191-208` | Frustum-only cull; renders all loaded landblocks | Add parameter for stencil pass / indoor-camera state |
| `src/AcDream.App/Rendering/GameWindow.cs:7074` | `_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb)` | Add `cameraInsideCell` (or equivalent) parameter |
| `src/AcDream.App/Rendering/GameWindow.cs:7110` | `WbDrawDispatcher.Draw(... visibleCellIds: visibility?.VisibleCellIds, ...)` | Add `cameraInsideCell` parameter |
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs:75-77` | Existing probe flag registry (mirror of `PhysicsDiagnostics`) | Add `ProbeVisibilityEnabled` from `ACDREAM_PROBE_VIS=1` |
### Issues family map
| ID | Symptom | Closes with H1 fix? |
|---|---|---|
| #78 | Outdoor stabs visible through inn floor/walls | YES (same root cause) |
| Cellar-stairs (NEW) | Outdoor terrain visible inside cottage cellar | YES (same root cause; new evidence for #78) |
| #95 | Portal-graph visibility blowup (visibleCells up to 295) | NO — independent (different code path) |
| #79/#80/#81/#93/#94 | Indoor lighting bugs | Maybe — #93 explicitly suspects "indoor visibility culling for lights" sub-cause; lighting subsystem may share infrastructure with visibility-gate but not directly impacted |
### Workflow notes (from CLAUDE.md "How to operate")
- "No workarounds without explicit approval" — Approach C is a workaround; Approach A is the correct shape.
- Visual verification is the user's job; can't be automated.
- Phase ID for visibility work is undecided. User picks at implementation-session start.
- Per the milestones doc, this is M1.5 scope; cellar-stairs is on the M1.5 critical path because it blocks the building/cellar half of the M1.5 demo.

View file

@ -0,0 +1,137 @@
# Phase A8 re-plan — entity taxonomy investigation
**Date:** 2026-05-26
**Phase:** A8 — Indoor-cell visibility culling RE-PLAN
**Predecessor handoff:** [docs/research/2026-05-26-a8-revert-handoff.md](2026-05-26-a8-revert-handoff.md)
**Status:** Report-only. Awaiting user approval of recommended fix-shape before Phase 2 (plan writing).
**Empirical context (added during investigation):** the bug exists on `main` too — verified by side-by-side launch of `main` vs `HEAD = fef6c61`. Both branches show outdoor buildings/terrain visible through the walls of a cottage when standing inside. The bug is **fundamental**, not a regression in this worktree's 149-commit divergence. The A8 framing in the predecessor handoff stands.
---
## TL;DR
The retail data model, WorldBuilder's data model, and the comment at `GameWindow.cs:5175-5178` all agree on a single architectural fact: **building shells are tagged distinctly from outdoor scenery at the data layer.** acdream's `LandblockLoader` reads both `LandBlockInfo.Objects` (scenery) and `LandBlockInfo.Buildings` (shells) into the same `WorldEntity` pool with no tag, destroying the distinction. The fix is to add `WorldEntity.IsBuildingShell: bool` at the loader, propagate it through hydration, and use it in the `WbDrawDispatcher.EntitySet` partition. This is **retail-faithful** (matches `BuildInfo` array) and **WB-faithful** (matches `SceneryInstance.IsBuilding`).
GL state order from the A8 Round 3 learning (MarkAndPunch BEFORE indoor draw) is confirmed correct by reading WorldBuilder's `VisibilityManager.RenderInsideOut`.
Far-side-portal (WB "Step 5", 3-stencil-bit) is deferred. First-ship approximation: only stencil-mark the **camera's own cell's** portals, not BFS-extended `VisibleCellIds`.
---
## The seven entity classes in acdream's runtime
| # | Class | `ParentCellId` | `Id` prefix | `ServerGuid` | Source field |
|---|---|---|---|---|---|
| 1 | Cell mesh | set | `0x40xxxxxx` | 0 | `EnvCell.EnvironmentId` |
| 2 | Cell static object | set | `0x40xxxxxx` | 0 | `EnvCell.StaticObjects` |
| 3 | **Building shell stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Buildings`** |
| 4 | **Outdoor scenery stab** | **null** | `0xC0xxxxxx` | 0 | **`LandBlockInfo.Objects`** |
| 5 | Procedural scenery | null | `0x80xxxxxx` | 0 | `SceneryGenerator` (terrain table) |
| 6a | Live animated | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
| 6b | Live static | null | `0x10xxxxxx` | ≠0 | `CreateObject` packet |
**Classes 3 and 4 are indistinguishable at runtime today** (identical field shape after hydration). This is the load-bearing wrong assumption from the A8 attempt.
### Code anchors (acdream)
- `src/AcDream.Core/World/LandblockLoader.cs:62-71` — Objects (Class 4) loop
- `src/AcDream.Core/World/LandblockLoader.cs:74-87` — Buildings (Class 3) loop, **same `nextId++` counter, same WorldEntity shape**
- `src/AcDream.App/Rendering/GameWindow.cs:5129-5137` — hydration pass-through, no distinction preserved
- `src/AcDream.App/Rendering/GameWindow.cs:5175-5178` — the comment that proves the distinction is intentional in dat:
> *"Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are static scenery placeholders themselves (rocks, tree clusters) that retail does NOT use to suppress scenery generation."*
---
## How retail tags buildings (cross-reference 1)
`CLandBlock::init_buildings` (`acclient_2013_pseudo_c.txt:313854-313920`) reads `CLandBlockInfo::buildings[]` — a **separate `BuildInfo**` array**, NOT a flag bit or ID-range scheme.
- `CLandBlockInfo.num_buildings` + `buildings[]` array (`acclient.h:31893-31905`)
- `BuildInfo` struct: `building_id`, `building_frame`, `num_portals`, `CBldPortal** portals` (`acclient.h:32035-32042`)
- Buildings hydrate via `CBuildingObj::makeBuilding()` (line 313879) and register into the landblock's `stablist[]` (per-landblock visible-cell set, line 313910)
- Visibility uses **stablist (portal PVS)**, NOT AABB-encloses-camera. `CEnvCell::grab_visible` walks `stab_list[i]` directly.
Conclusion: **retail explicitly distinguishes the two via separate dat arrays.** This is the data-model truth we should match.
## How WorldBuilder tags buildings (cross-reference 2)
WB uses **two manager classes** sharing one mesh pool:
- `StaticObjectRenderManager` — handles BOTH `LandBlockInfo.Objects` and `LandBlockInfo.Buildings`, tagging each `SceneryInstance.IsBuilding` (`StaticObjectRenderManager.cs:334-400`).
- `SceneryRenderManager` — handles ONLY procedural terrain-derived scenery (different class entirely, doesn't share the dat path).
Tagging happens at **hydration time** in `GenerateForLandblockAsync` (lines 315-427). The instance is then split into separate `StaticPartGroups` vs `BuildingPartGroups` for draw dispatch.
`BuildingPortalGPU` (`PortalRenderManager.cs:687-701`) holds `EnvCellIds: HashSet<uint>` populated at landblock generation (line 549) — the "this building contains these EnvCells" association. The set is **never re-computed at render time**.
WB's `RenderInsideOut` GL state order (`VisibilityManager.cs:73-239`):
1. Stencil bit 1 ← portal polygons (color/depth masks off)
2. `gl_FragDepth = 1.0` ← portal polygons (depth mask on, depth-func = Always)
3. **Interior EnvCells render WITHOUT stencil restriction** ← key step
4. Stencil-restricted (`Equal, 1`): terrain + scenery + buildings render only at portal silhouettes
5. (Step 5) 3-stencil-bit pipeline for cross-building visibility — DEFER
**WB's order = MarkAndPunch (Step 1 + 2) FIRST, then indoor cells (Step 3).** This matches A8 Round 3's correction. The handoff's GL-state-order conclusion stands.
---
## Recommended fix-shape (synthesized)
### Stage 1: Tag at hydration (`IsBuildingShell` flag)
Add `WorldEntity.IsBuildingShell: bool` (default false). In `LandblockLoader.cs`:
- Objects loop (line 62): `IsBuildingShell = false`
- Buildings loop (line 74): `IsBuildingShell = true`
In `GameWindow.cs:5129-5137` (hydration): copy `IsBuildingShell` from `e` to the hydrated entity. One-line change.
### Stage 2: Refine `WbDrawDispatcher.EntitySet` partition
Replace today's binary `IndoorOnly`/`OutdoorOnly` with:
- `IndoorPass``ParentCellId.HasValue || IsBuildingShell` (Classes 1, 2, 3)
- `OutdoorScenery``!ParentCellId.HasValue && !IsBuildingShell && (ServerGuid == 0)` (Classes 4, 5)
- `LiveDynamic``ServerGuid != 0` (Classes 6a, 6b)
`WalkEntitiesInto` updates one branch (the partition predicate). 26 dispatcher tests will need their fixture entities tagged correctly; otherwise behavior is the same.
### Stage 3: Re-wire render frame with WB's order
When camera is inside a cell:
1. Draw terrain (color in framebuffer)
2. **MarkAndPunch** (stencil = 1 + depth = 1.0 at portal silhouettes)
3. `WbDrawDispatcher.Draw(set: IndoorPass)` — cell mesh + cell statics + building shells. Stencil disabled, depth test normal. These write depth ON TOP of the 1.0 punch, correctly occluding the next stencil-gated pass.
4. Re-draw terrain (color writes only) with `StencilFunc(Equal, 1)` — terrain visible only at portal silhouettes.
5. `WbDrawDispatcher.Draw(set: OutdoorScenery)` with `StencilFunc(Equal, 1)` — outdoor scenery visible only at portal silhouettes.
6. `WbDrawDispatcher.Draw(set: LiveDynamic)` — stencil disabled, depth test on. Live entities draw freely; depth occludes them by walls and cell meshes already in the depth buffer.
When camera is outside: stencil work skipped entirely. Today's all-entities single draw stands (or substitute the three EntitySet calls with stencil disabled — depth still sorts them correctly).
### Stage 4: Far-side-portal approximation (defer Step 5)
Stencil-mark **only the camera's own cell's portals** in Step 2, not the BFS-extended `VisibleCellIds`. This trades cross-cell-portal visibility (rare visually) for correctness in the common case (no "see-through-wall on the other side of the room"). Track as a known limitation; revisit if visual gate flags it.
---
## Reasons for confidence
1. **Triple-cited**: retail (`BuildInfo` array), WB (`IsBuilding` flag), acdream's own code comment (5175-5178) all agree on the distinction.
2. **Tagging cost is microscopic** — one bool on `WorldEntity`, one branch in `LandblockLoader`. No new types, no new managers, no field migration.
3. **`EntitySet` enum is already in place** (dormant from Tasks 1-6). Refactor is reshaping its semantics, not introducing it.
4. **GL state order is validated** by both Round 3 of the A8 attempt and WB's reference. No remaining ambiguity.
5. **Live-dynamic separation handles the Round 1 character-disappears bug** (handoff §Round 1). They draw last, stencil disabled, depth-tested against everything else.
## Open questions for user approval
1. Use `IsBuildingShell` flag (recommended) vs separate `0xC1xxxxxx` ID-namespace? Flag is more explicit, retail-faithful, and trivially greppable. ID-namespace is one less field but invisible at the call site.
2. Defer Step 5 (far-side portals) and stencil-mark only camera's own cell? Recommendation: yes — ship simple, file follow-up.
3. Live-dynamic entities (Class 6b: dropped items) — draw in `LivePass` or accept "invisible from inside" until a richer flag exists? Recommendation: `LivePass`. They're rare visually, and the player benefits from seeing dropped items through the floor (gameplay nicety, not retail violation).
4. Cellar-stairs grass overlay from OUTSIDE: NOT A8 scope (no stencil runs when camera is outside). Open question for a future "deep-cell terrain occlusion" phase. Confirm we file this separately, not bundled.
---
## Reference anchors (still valid from predecessor handoff)
- WB stencil: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239`
- WB building-cell association: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551`
- Retail building init: `docs/research/named-retail/acclient_2013_pseudo_c.txt:313854-313920`
- Retail building struct: `docs/research/named-retail/acclient.h:31893-31905`, `:32035-32042`, `:32094-32103`
- acdream LandblockLoader: `src/AcDream.Core/World/LandblockLoader.cs:62-87`
- acdream hydration: `src/AcDream.App/Rendering/GameWindow.cs:5093-5148`, `:5175-5178`

View file

@ -0,0 +1,168 @@
# Phase A8 Cellar-Flap Handoff - 2026-05-28
## Status
The remaining cottage/cellar artifact is still visible. The user sees a short-lived green terrain-like rectangle/flap over the cellar entrance or floor opening when entering/exiting a building and when the chase camera angle changes.
This thread hit the project rule: 3 visual-gated fixes failed for this specific artifact. Stop here. Do not ship a fourth speculative renderer change. The next step must be source-provenance instrumentation or an architecture comparison.
The client was stopped after the failed visual gate to avoid build/file locks.
## Last Good Context
The broader A8 indoor renderer is much improved and shippable aside from this artifact:
- Theory A front-face alignment plus per-batch `CullMode` fixed major missing/inside-out geometry.
- The `InstanceData` stride fix explained the texture explosion/distortion root cause.
- Building shell scoping, portal bounds, streaming/promotion fixes, and FPS fixes remain important.
- The current artifact is narrow: a green terrain-like flap at/near cellar entrance transitions.
## Failed Fixes In This Micro-Loop
### 1. Camera branch gate relaxation
Hypothesis: strict `PointInCell` camera-inside-building gating was causing the renderer to switch branches while the chase camera crossed walls/portals.
Change tried: allow the inside-out branch to continue based on `CameraCell.BuildingId`, without the strict `PointInCell` check.
Result: visual gate failed. User still saw the artifact. Change was reverted back to strict `PointInCell`.
Evidence ruled out: the artifact is not solely caused by the render branch dropping out when the camera is slightly outside the cell.
### 2. Portal stencil `pos.w` clamp
Hypothesis: near-zero clip W for portal stencil triangles was exploding the screen-space mask as the camera crossed a portal plane.
Change tried: restored WorldBuilder-style clamp in `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`:
```glsl
vec4 pos = uViewProjection * vec4(aPosition, 1.0);
if (abs(pos.w) < 0.001)
pos.w = pos.w < 0.0 ? -0.001 : 0.001;
gl_Position = pos;
```
Result: visual gate failed. User still saw the artifact.
Interpretation: this may still be WB parity and may be worth keeping, but it is not the root cause of the cellar flap.
### 3. Visible-cell portal mask
Hypothesis: `RenderInsideOutAcdream` was punching outdoor terrain through all exit portals in the camera building, not just exits reached by the current portal traversal. A window/door portal could project over the cellar opening and let terrain draw through it.
Changes tried:
- Added `CellVisibility.TryGetCell(uint, out LoadedCell?)`.
- Added `IndoorCellStencilPipeline.DrawUploadedPortalMesh(...)` to draw the already-uploaded visible-cell portal mesh.
- Changed `RenderInsideOutAcdream` Step 1/2 to upload portal triangles from `visibleCellIds`, not `camBuildings`.
- Wrapped Step 4 terrain/outdoor scenery draw inside `if (didInsideStencil)`.
- Added pure math test `BuildTriangles_OnlyIncludesProvidedVisibleCells`.
Verification before launch:
```powershell
dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --no-restore
dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter FullyQualifiedName~IndoorCellStencilPipelineTests --no-restore
```
Both passed after shutting down build servers. The first parallel test attempt only failed because `VBCSCompiler` locked `AcDream.App.dll` while a parallel build was running.
Visual result: failed. User still saw the flap.
Evidence ruled out: the artifact is not explained only by building-wide vs visible-cell exit portal masking.
## Current Uncommitted Changes From This Micro-Loop
These are not visually validated as a fix:
- `src/AcDream.App/Rendering/Shaders/portal_stencil.vert`
- WB-style `pos.w` clamp.
- `src/AcDream.App/Rendering/CellVisibility.cs`
- `TryGetCell`.
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
- `DrawUploadedPortalMesh`.
- `src/AcDream.App/Rendering/GameWindow.cs`
- visible-cell portal mask in `RenderInsideOutAcdream`.
- Step 4 fail-closed when no portal mesh uploaded.
- `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`
- visible-cell-only triangle generation test.
Do not commit these as "the fix" unless a later source-provenance check proves they are correct and harmless. If the next architecture route goes elsewhere, revert or split them honestly.
## What We Know About The Cellar Entrance
Prior inspection found:
- The cellar transition around Holtburg cottage cells `0xA9B40143 -> 0x0146 -> 0x0147` is indoor-to-indoor.
- The relevant transition portals are not literal `OtherCellId == 0xFFFF` outside exits.
- The portal cap polygons inspected there are `NoPos`.
- That made "cell mesh uploads the outdoor floor polygon" unlikely, but not proven impossible for the visible green patch.
## What The Failed Fixes Suggest
The green pixels may not be caused by the portal mask at all. We need to prove which render pass writes them before touching behavior again.
Candidate explanations now:
1. **The green patch is not Step 4 terrain.**
It could be an EnvCell surface with the wrong texture/material, stale texture binding, or an indoor static object surface resolving to a grass texture.
2. **The patch is Step 4 terrain, but the stencil/depth mask source is not the exit portal list.**
It may be a stale stencil/depth state leak, a Step 3/4 depth ordering issue, or a missing retail occlusion/scissor lifecycle detail.
3. **The cellar opening has a missing or late-loaded indoor object.**
If a hatch/stair/floor object should cover or occlude that area and is missing for one or more frames, outdoor/terrain pixels behind it become visible.
4. **Camera clipping is exposing an intentionally hidden surface.**
Retail camera collision is incomplete in acdream. If the camera clips through the wall/floor volume, the renderer may be showing a view retail never permits. This does not explain all cases by itself, because the user also sees it during transitions, but it must be separated from a renderer bug.
## Next Step - Evidence Only
Add a provenance diagnostic, not a fix. One visual launch should answer what writes the green flap.
Recommended diagnostic:
- Add an env-gated pass tint or disable switch for only the inside-out Step 4 terrain draw.
- Add a separate tint/disable for Step 4 `OutdoorScenery`.
- Add a tint/disable for Step 3 EnvCell opaque.
- Keep default behavior unchanged.
Example env names:
```text
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_TERRAIN=1
ACDREAM_A8_DIAG_DISABLE_INSIDE_STEP4_OUTDOOR=1
ACDREAM_A8_DIAG_DISABLE_INSIDE_ENVCELL_OPAQUE=1
```
Run only one diagnostic at a time:
- If disabling Step 4 terrain removes the green flap, the issue is still terrain leaking through the indoor view mask.
- If disabling Step 4 terrain does not remove it, stop looking at portal exits and inspect EnvCell/static texture/material assignment for the specific surface.
- If disabling EnvCell opaque removes it, dump the exact `CellStruct` polygon/material under the camera/screenshot area.
Do not ship the diagnostic switches. Strip them after the source is known.
## Launch/Test Notes
Last clean visual launch:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_A8_INDOOR_BRANCH = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug
```
The log files from the last launch were empty because this was a clean no-probe run:
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.out.log`
- `a8-visible-cell-portalmask-cellar-flap-20260528-161702.err.log`
## Stop Rule
This artifact has now consumed three visual failures in this micro-loop. Next session should not make another fix until it has hard evidence identifying the pass/polygon/source texture responsible for the green pixels.

View file

@ -0,0 +1,176 @@
# A8 cellar-flap — option-2 handoff + brainstorming kickoff (2026-05-28 PM)
## Purpose
The cellar flap is the **last** A8 indoor-rendering defect. Its root cause is
fully understood (offline-confirmed). The targeted fix (option 1) was tried,
**failed**, and the failure revealed a deeper architectural coupling. The
decision is to fix it the **retail-faithful way (option 2: WB-style recursive
portal visibility)** via a fresh `superpowers:brainstorming` session. This doc
is the single pickup point for that session.
## Current tree state (do NOT reset)
- Worktree: `.claude/worktrees/strange-albattani-3fc83c/`, branch
`claude/strange-albattani-3fc83c`, tip `e415bb3` — **all A8 work is
uncommitted** in a dirty tree.
- Build green. App tests pass (90 baseline; the 3 option-1 tests were removed).
- The option-1 code (`PortalMeshBuilder.CollectSameLevelPortalCells` +
`IsVerticalPortal` + 3 tests + the GameWindow call-site change) has been
**reverted/removed** — tree is back to the working-with-cellar-flap baseline.
- `tools/A8CellAudit` gained a `portals` mode this session (offline cell/portal
dumper) — **kept**, it's the investigation workhorse.
### What WORKS in the dirty tree (the valuable A8 batch — keep)
- EnvCellRenderer SSBO **stride fix** (mat4 upload, not 80-byte InstanceData).
- WB-style global `FrontFace(CW)` + per-batch `CullMode` through MDI.
- `EntitySet` partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
`BuildingShellAnchorCellId` scoping.
- `RenderOutsideInAcdream` (look into buildings from outside).
- `CollectVisiblePortalBuildings` frustum cull of portal bounds.
- Streaming near/far priority queues + `PromoteToNear` + the
`LandblockEntriesWithoutAnimatedIndex` hot-path fix (fixed bridge/wall/collision
regressions after travel).
- Temporary `ACDREAM_A8_DIAG_*` flags (strip before any commit).
### What DOESN'T work
- **Cellar flap** + the broader inside-out fragility (see the coupling below).
> **Decision point for the human:** the working A8 batch is large and
> uncommitted. Consider committing it (after stripping the `ACDREAM_A8_DIAG_*`
> flags) so the option-2 work starts from a clean baseline. Deferred per
> "don't commit yet," but flagged.
## The cellar flap — root cause (confirmed)
Full evidence: [`docs/research/2026-05-28-a8-cellar-flap-root-cause.md`](2026-05-28-a8-cellar-flap-root-cause.md).
Short version: the inside-out stencil mask flat-marks the exit portals
(windows/doors) of **every** visibility-BFS-reached cell. From the cellar
(`0xA9B40171`, **zero** exit portals), the BFS reaches the ground-floor cells
(`0x16F`, `0x170`) up the stairwell and marks **their** windows. Step 4 then
paints the whole outdoor world through those silhouettes wherever the cellar's
stairwell hole leaves them un-occluded. There is no constraint tying a deeper
cell's exit portal to the portal chain (the narrow stairwell) it was reached
through.
## ⭐ THE KEY FINDING — `didInsideStencil` double-duty coupling
This is the expensive lesson; do not re-pay it.
`RenderInsideOutAcdream` Step 4 (GameWindow.cs ~11167) wraps **both** the
terrain draw **and** the entire `OutdoorScenery` dispatcher draw (which includes
neighbor **building shells**, scenery, and the depth-repair pass) in:
```csharp
if (didInsideStencil) { ... terrain + OutdoorScenery ... }
```
where `didInsideStencil == (camera-side-filtered exit-portal mask is non-empty)`.
So the portal mask is doing **two jobs at once**:
- **Job A (intended):** gate "paint terrain/sky *through* the portal openings."
- **Job B (accidental):** decide "draw exterior geometry (shells/scenery/depth-repair) **at all**."
**Why option 1 failed:** option 1 correctly shrank the mask (same-level cells
only) so the cellar's mask went empty → `didInsideStencil=false` → **Step 4
skipped entirely** → exterior shells + terrain vanished → "walls transparent,
sky behind, terrain gone." The old flat mask (all visN cells) *papered over*
this by almost always keeping the mask non-empty.
**Consequence for ANY fix:** correctly scoping/clipping the portal mask is not
enough on its own — it will empty the mask in legitimate cases (looking at an
interior wall, sealed cellar) and kill exterior rendering. **Job A and Job B
must be decoupled** so exterior geometry draws regardless of whether any portal
is currently visible. This is true for option 2 as much as option 1.
## Decision: option 2 (WB-faithful recursive portal visibility)
Chosen over option 1 (decouple-only) because:
- The project mandate is faithful WB/retail porting; option 1 is a structural
deviation from WB's RenderInsideOut, and prior "cleaner redesign" deviations
were reverted.
- Option 2 handles every case (cellar, stacked floors, deep dungeons) without
per-case special-casing.
- It is large enough to deserve design-first (brainstorm), not a mid-session patch.
Note: option 2 still has to solve the Job-A/Job-B decoupling above — it's not
optional.
## Open design questions for the brainstorm (resolve BEFORE coding)
1. **Does WB even render a sealed sub-cell (cellar) via inside-out?** Check how
WB derives `_buildingsWithCurrentCell` (VisibilityManager.PrepareVisibility +
PortalRenderManager.GetBuildingPortalsByCellId). If WB *excludes* a cell with
no exit portals from the inside-out path, the "fix" may be a classification
change, not recursion. **Verify against WB source — don't assume recursion exists.**
2. **How does WB ACTUALLY constrain per-portal visibility?** Re-read
`VisibilityManager.cs` (RenderInsideOut/RenderOutsideIn) + `PortalRenderManager.cs`
end-to-end. Is the clipping (a) recursive portal traversal, (b) the 3-bit
stencil Step-5 pipeline, (c) pure Step-3 depth occlusion, or (d) the BSP
portal-graph in PrepareVisibility? Our port copied the *flat* Steps 1-4; the
constraint mechanism may live in code we didn't port.
3. **Job-A/Job-B decoupling.** Design how exterior geometry (shells + scenery +
depth-repair) draws independent of the portal mask, while terrain-through-portal
stays stencil-gated. This must land regardless of the recursion design.
4. **Stencil-bit budget + occlusion-query lifecycle** if the full WB Step-5
cross-building path is adopted (currently gated off via `ACDREAM_A8_STEP5`).
## Key source references for the brainstorm
WB (the algorithm being ported):
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`
`PrepareVisibility` (47-71), `RenderInsideOut` (73-239), `RenderOutsideIn` (241+).
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs`
`RenderBuildingStencilMask`, `GetVisibleBuildingPortals`, `GetBuildingPortalsByCellId`.
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs`.
acdream (the current port):
- `src/AcDream.App/Rendering/GameWindow.cs``RenderInsideOutAcdream`
(~11012), `RenderOutsideInAcdream` (~196), the Step-4 `didInsideStencil` gate
(~11167). **This is where the Job-A/Job-B coupling lives.**
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs``PortalMeshBuilder`
(camera-side filter), `RenderBuildingStencilMask`, `DrawUploadedPortalMesh`.
- `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` — building cell-set BFS
(mirrors WB PortalService; cellar IS expanded into building 0xA).
Retail oracle (if WB is ambiguous):
- `docs/research/named-retail/acclient_2013_pseudo_c.txt``CObjCell::find_visible_child_cell`
(≈311397), `PView::DrawCells` (≈432709). Retail uses screen-space polygon-clip
scissor recursion — the conceptual ancestor of "clip each portal to the chain."
Offline tooling:
- `tools/A8CellAudit``dotnet run -- portals <cellId...>` dumps a cell's
CellPortals (exit vs interior); `-- buildings <lb> <radius>` dumps
building→cell grouping. Reproduces the whole investigation in seconds, no launch.
## Brainstorming kickoff prompt (copy-paste into a fresh session)
> Use the `superpowers:brainstorming` skill. We're designing the retail-faithful
> fix for the A8 "cellar flap" — the last A8 indoor-rendering defect.
>
> Read first, in order:
> 1. `docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md` (this doc) —
> current state, the confirmed root cause, the `didInsideStencil` double-duty
> finding, the decision, and the open design questions.
> 2. `docs/research/2026-05-28-a8-cellar-flap-root-cause.md` — the offline evidence.
> 3. The WB + acdream source references listed in the handoff.
>
> The goal: design how acdream's indoor visibility should render outside-through-
> portals **correctly clipped to the portal chain** (so a sealed cellar shows no
> terrain, a windowed room shows its own windows, deep rooms show only the sliver
> visible through the doorway chain) **AND** decouple "draw exterior geometry at
> all" from "is the portal mask non-empty" (the coupling that made the targeted
> fix regress).
>
> Brainstorm MUST resolve the 4 open design questions in the handoff before any
> code — especially Q1 (does WB even render a sealed cellar inside-out?) and Q2
> (what is WB's ACTUAL per-portal clipping mechanism — verify against source,
> don't assume recursion). Output a written design/plan; do not start coding
> until the design is agreed.
>
> Process rules still in force: no workarounds/band-aids; faithful WB/retail
> port; one visual gate only when a complete fix is ready; the broken indoor
> branch is behind `ACDREAM_A8_INDOOR_BRANCH=1` (default off = pre-A8 visual).
> The dirty tree has valuable uncommitted A8 work — decide whether to commit it
> (strip `ACDREAM_A8_DIAG_*` first) before starting.

View file

@ -0,0 +1,120 @@
# A8 cellar-flap — structured debugging root cause (2026-05-28 PM)
## Method
Systematic-debugging Phases 1-3, all evidence gathered **offline** via the
`tools/A8CellAudit` tool (extended with a `portals` mode) — no live launches
needed. Deterministic, instant, reproducible.
## Phase 1 — evidence
Scenario (from `launch-a8-probe-normal-20260528-194536.out.log`):
- Camera in cell `0xA9B40171`, `inside=True really=True`.
- `camBldgs=[0xA]`, `visN=7 [0x16F,0x170,0x171,0x172,0x173,0x174,0x175]`.
- Portal stencil mask = 12 verts (not the old over-punch case).
- Bisection (prior session): writer is **Step 4 content**; disabling Step-2
punch does **not** fix it.
Offline audit findings:
**Building grouping** (`A8CellAudit buildings 0xA9B40000`):
```
buildingOrdinal=10 registryId=0xA model=0x01002232 portalCells=[0xA9B4016F,0xA9B40170]
```
Building `0xA`'s LandBlockInfo seed = `{0x16F, 0x170}`. `BuildingLoader` then
BFS-expands through interior portals → all 7 cells (incl. the cellar). The
BFS matches WB's `PortalService` (same algorithm), so the grouping is not the
divergence.
**Exit-portal ownership** (`A8CellAudit portals ...`):
| Cell | exit portals (0xFFFF) | interior | role |
|------|----|----|------|
| `0x16F` | **1** | 1 | ground floor (window/door) |
| `0x170` | **1** | 1 | ground floor (window/door) |
| `0x171` (camera) | **0** | 3 | cellar |
| `0x172``0x175` | **0** | 12 | cellar rooms |
So the 12-vert mask = `0x16F` exit (6v) + `0x170` exit (6v). **The cellar
camera (zero exit portals) is marking the two ground-floor windows.**
**Topology**:
```
0x171.portal[0] -> 0x170 (stairwell/hatch, polyId 54)
0x170.portal[1] -> 0x171 (polyId 5)
0x170.portal[0] -> EXIT (window/door to outside, polyId 4)
```
Cellar connects directly up to ground floor `0x170`; `0x16F` is one further hop.
**Occluder geometry** (`A8CellAudit 0xA9B40170` / `0xA9B40171`):
- `0x170` floor poly `0x0002` (n.Z=+1) **emits** — the cellar's ceiling/occluder exists.
- `0x171` has a ceiling `0x0003` (n.Z=-1, emits) AND three `NoPos` polys
`0x0036/0x0037/0x0038` (surface `0x080000DF`) that do **not** emit —
`0x0038` is a ceiling-plane poly = the **stairwell hole** up to the ground floor.
## Phase 2 — pattern vs WB
WB `RenderInsideOut` marks the building's exit portals (flat — same as us) and
relies on **Step-3 cell depth** to occlude them: terrain only survives where the
punched/cleared far-depth isn't overwritten by rendered cell geometry.
Our code matches that structure. The difference that produces the visible flap:
WB's outside view through a portal is the world geometrically behind that
portal; from a cellar, the only un-occluded opening is the **stairwell hole**
(`0x0038`, not rendered). Through that hole, stencil=1 (ground-floor window
marked) and depth=far → **Step 4 draws the entire outdoor world (terrain +
buildings) through the hole**, not a window-sized sliver. The two ground-floor
windows are 12 BFS hops above the camera and should contribute essentially
nothing from the cellar, but their full silhouettes are marked.
"Disable Step-2 punch doesn't fix it" is explained: the leak pixels are the
stairwell hole, which has **cleared (far) depth** regardless of the punch
because no cell geometry covers it — terrain passes `DepthFunc.Less` either way.
## Phase 3 — single hypothesis (root cause)
**The inside-out exit-portal stencil mask is built by flat-marking the exit
portals of every visibility-BFS-reached cell. From the cellar, the BFS reaches
the ground-floor cells, whose windows get full-silhouette-marked. Where the
cellar's stairwell hole leaves those silhouettes un-occluded, Step 4 paints the
whole outdoor world through them. There is no constraint tying a deeper cell's
exit portal to the portal chain (here: the narrow stairwell) through which its
cell became visible.**
This is a flat-vs-constrained masking gap. Not a depth bug (occluders emit and
render), not the Step-2 punch, not the camera-side filter (the cellar camera is
geometrically on the interior side of a ground-floor window's plane, so the
per-portal filter passes it).
## Phase 4 — fix options
1. **Camera-cell-scoped mask (minimal, conservative).** Mark only the camera
cell's own exit portals. Cellar (0 exit portals) → empty mask → no leak;
windowed room → marks its own windows. **Risk:** loses daylight through an
*adjacent* cell's window seen across a doorway in multi-cell ground-floor
rooms (e.g. the inn) — a visible-but-minor regression, and the flat approach
was wrong there anyway.
2. **Vertical-portal-aware scoping (targeted).** Don't propagate exit-portal
marking across a floor/ceiling (vertical-normal) portal. The cellar→ground
stairwell is a horizontal-plane portal; suppressing inheritance across it
stops the cellar from marking ground-floor windows while preserving
same-level multi-cell rooms. Needs per-portal polygon-normal classification.
3. **WB recursive/constrained portal masking (faithful, largest).** Constrain
each deeper portal's stencil to the screen region of the portal chain leading
to it. Correct for all cases (cellar + multi-cell rooms) but a substantial
port of WB's recursive RenderInsideOut.
**Recommendation:** option 2 is the best correctness/effort trade — it fixes the
cellar without the inn regression risk of option 1, and is a principled scoping
rule (don't inherit a different vertical level's exterior openings) rather than a
band-aid. Option 3 remains the eventual faithful target if cross-level portal
visibility ever needs to be exact.
## Reproduction / verification assets
- `tools/A8CellAudit` `portals` mode (added this session) dumps any cell's
`CellPortals` offline. `A8CellAudit buildings <lb> <radius>` dumps
building→cell grouping. These make the whole investigation re-runnable in
seconds with zero launches.