From ea60d1fb7dc13d3882f99e72e878fc245e4dfa88 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 09:29:14 +0200 Subject: [PATCH] =?UTF-8?q?docs(spec):=20Phase=20A8=20=E2=80=94=20full=20W?= =?UTF-8?q?B=20RenderInsideOut=20+=20RenderOutsideIn=20port=20(design)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-brainstormed after RR0 falsification showed R3+R3.5 introduced Issues A+C (rendering all 16 BFS-reachable cells at full screen extent caused co-planar Z-fight + grace-state leak from outside view). The prior design's "WB-faithful restructure" was insufficient — it kept the BFS-wide cell rendering. Retail and WB both solve indoor visibility with per-portal recursive culling. This design ports WB's full pipeline: - RenderInsideOut Steps 1-5 (including 3-stencil-bit cross-building) - RenderOutsideIn (cottage interiors visible through windows from outside) - Per-building cell association (Building + BuildingRegistry, plus LoadedCell.BuildingId for O(1) cell→building lookups) - Single strict cameraInsideBuilding gate (no grace for render path) - Stencil-gated sky inside indoor branch (acdream enhancement) 12 tasks (RR1-RR12), 8-10 sessions estimated. M1.5 indoor scope ships fully. Supersedes: docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md docs/superpowers/plans/2026-05-26-phase-a8-restructure.md (both will be footer-marked in RR1 cleanup) Reverts in RR1: R3 (60f07bc), R3.5 v1 (38d5374), R3.5 v2 (2bfeafd). R1+R2 (data layer + dispatcher partition) stay — orthogonal infrastructure. RR2 spike resolves the BuildingInfo data shape + interior-portal walk algorithm against WB PortalRenderManager:518-551 before RR3 implements. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-26-phase-a8-wb-full-port-design.md | 451 ++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md diff --git a/docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md b/docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md new file mode 100644 index 0000000..d11ff1e --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md @@ -0,0 +1,451 @@ +# Phase A8 — Full WorldBuilder RenderInsideOut + RenderOutsideIn port (design) + +**Date:** 2026-05-26 (PM, post-RR0 re-brainstorm) +**Phase:** A8 — Indoor-cell visibility culling — **SCOPE EXPANSION** after RR0 falsified the prior design's "stencil-mask only" assumption. +**Status:** Design approved 2026-05-26. Ready for `superpowers:writing-plans`. +**Branch:** `claude/strange-albattani-3fc83c` (worktree) +**Supersedes:** [docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md](2026-05-26-phase-a8-restructure-design.md) (footer-marked) and [docs/superpowers/plans/2026-05-26-phase-a8-restructure.md](../plans/2026-05-26-phase-a8-restructure.md) (footer-marked). + +**Required predecessor reading:** +- [docs/research/2026-05-26-a8-rr0-falsification-findings.md](../../research/2026-05-26-a8-rr0-falsification-findings.md) — the falsification spike that triggered this re-brainstorm +- [docs/research/2026-05-26-a8-r3.5-restructure-handoff.md](../../research/2026-05-26-a8-r3.5-restructure-handoff.md) — full R3.5 saga +- [docs/research/2026-05-26-a8-entity-taxonomy.md](../../research/2026-05-26-a8-entity-taxonomy.md) — entity taxonomy (R1+R2 ship-shape; still valid) +- [references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs) — the proven reference; both `RenderInsideOut` (lines 73-239) and `RenderOutsideIn` (lines 241+) must be ported + +--- + +## TL;DR + +The prior design (`2026-05-26-phase-a8-restructure-design.md`) assumed Issues A and C from R4 visual verification were either pre-existing or fixable by removing a depth-clear workaround. The **RR0 falsification spike disproved both assumptions**: A and C are caused by R3's stencil pipeline wire-in — specifically by rendering **16 cells of a Holtburg cottage cluster simultaneously at full screen extent**. R3 implements WB's Step 4 (stencil-gate outdoor) but skips WB's Step 3 architecture (render only the camera-building's cells, not BFS-extended). + +The fix is to **port WB's full `RenderInsideOut` and `RenderOutsideIn`** with per-building cell scoping, including Step 5 (3-stencil-bit cross-building visibility). This requires: + +1. New data model: `Building` data class + `BuildingRegistry` per landblock + `LoadedCell.BuildingId` field (Option C of the data-model brainstorm — both directions indexed for O(1) lookups). +2. New code path: `BuildingLoader` walks `LandBlockInfo.Buildings` at landblock load time, computes per-building cell sets via the portal graph. +3. Render-frame restructure: single strict `cameraInsideBuilding` gate (replaces both `cameraInsideCell` lenient and `cameraReallyInside` strict); `IndoorPass` walks the camera-building's cells (not full BFS); stencil-gated sky for windows; Step 5 for cross-building visibility; `RenderOutsideIn` for outdoor camera looking into cottage windows. +4. Revert R3, R3.5 v1, R3.5 v2 (the half-port). Keep R1 (`IsBuildingShell` flag) and R2 (`EntitySet` partition) — they remain useful infrastructure. + +Twelve tasks (RR1–RR12), ~8-10 sessions (1.5-2 weeks calendar). M1.5 indoor-world acceptance scope ships fully. + +--- + +## Why the scope expanded + +Per RR0 evidence: + +| Branch | Issue C | Issue A | #78 | +|---|---|---|---| +| HEAD (R3.5 v2) | YES | YES | (fixed by R3) | +| R3 baseline (60f07bc) | YES | YES | (fixed by R3) | +| main (7034be9, no A8) | NO | NO flicker | YES, constant | + +R3's stencil-mask approach **fixes #78 by stencil-gating outdoor visibility** — but the underlying "render all 16 BFS-reachable cells at full screen" is structurally wrong. Co-planar cell meshes (cottage floor + cellar ceiling, both with +0.02m EnvCell render lift) Z-fight. During exit-grace, the same 16-cell pool leaks from outside view. R3 is half a WB port. Retail and WB both solve this with **per-portal recursive culling** (retail via polygon-clip scissor, WB via per-building stencil scoping). + +The "minimum viable indoor visibility" turns out to be larger than the prior design assumed. We are committing to the full port now rather than iterating through more half-fixes. + +--- + +## Brainstorm outcomes + +| # | Question | Decision | +|---|---|---| +| Q1 | Scope: include WB Step 5 (cross-building visibility)? | **Yes — full RenderInsideOut including Step 5** | +| Q2 | Data model for per-building cell association? | **Option C — Building registry + cell-side BuildingId field (both directions O(1))** | +| Q3 | Outdoor→indoor visibility (RenderOutsideIn)? | **Yes — include in this phase** | +| (implicit) | What about R3 + R3.5 v1 + R3.5 v2? | Revert (3 commits); R1+R2 stay | +| (implicit) | Grace mechanism for render path? | Drop in render frame (use strict `cameraInsideBuilding`); grace stays alive in `CellVisibility` for non-render consumers | + +--- + +## Architecture + +### One new gate flag (replaces the two-flag asymmetry) + +```csharp +bool cameraInsideBuilding = visibility?.CameraCell is not null + && CellVisibility.PointInCell(camPos, visibility.CameraCell) + && visibility.CameraCell.BuildingId is not null; +``` + +Strict (PointInCell), no grace, AND requires the camera's cell to actually be a building cell. A cell with `BuildingId == null` is an outdoor cell or dungeon cell not tagged by `LandBlockInfo.Buildings`; those flow through the outdoor render path. + +Drives: sky pre-scene, initial terrain, stencil-pipeline branch entry, weather post-scene, sky-PES debug. `playerInsideCell` (used for lighting in third-person chase mode) stays separate — different semantics (player position, not camera). + +### Two render paths + +**`cameraInsideBuilding == false` — outdoor path (matches WB `RenderOutsideIn`):** + +1. Sky drawn normally +2. Terrain drawn normally +3. **`RenderOutsideIn`**: for each visible building in the frustum (per `BuildingRegistry.All()` filtered by frustum): + - Stencil-mark its exit portals (single-bit stencil 1) + - Render its `EnvCellIds` through stencil (visible only at portal silhouettes) + - Use occlusion query (prev-frame result) to skip buildings whose portals weren't visible last frame +4. `Draw(set: All)` outdoor entities (existing pre-A8 behavior) +5. Weather + +**`cameraInsideBuilding == true` — indoor path (matches WB `RenderInsideOut`):** + +1. **Skip** initial sky (gated on `!cameraInsideBuilding`) +2. **Skip** initial terrain (gated on `!cameraInsideBuilding`) +3. **No depth-clear** (the old depth-clear-if-inside block is deleted) +4. **MarkAndPunch** — stencil bit 1 + far-depth at the camera-buildings' exit portals (lookup via `registry.GetBuildingsContainingCell(visibility.CameraCell.CellId)`) +5. **IndoorPass** — render the camera-buildings' cells via `WbDrawDispatcher.Draw(cellIds: camBuildings.SelectMany(b => b.EnvCellIds))`. Stencil off, DepthFunc.Less. **This is the structural fix.** +6. **EnableOutdoorPass** — stencil read `Equal(1, 0x01)` +7. **Stencil-gated sky** — `_skyRenderer.RenderSky` with DepthMask off (acdream enhancement; closes the "fog haze through windows" complaint) +8. **Stencil-gated terrain re-draw** +9. **Stencil-gated `OutdoorScenery`** +10. **Step 5 — for each "other building"** (visible per frustum but NOT containing camera): + - Read prev-frame occlusion query result + - Begin new occlusion query + - Mark stencil bit 2 at this other-building's portals (`StencilFunc.Equal(3, 0x01)`, `StencilMask(0x02)`) — bit 2 set only where bit 1 already set + - End occlusion query + - Clear depth where bits 1+2 set (re-draw portal mesh with `DepthFunc.Always`, `StencilFunc.Equal(3, 0x03)`) + - Render this other-building's `EnvCellIds` through stencil == 3 + - Reset bit 2 for the next iteration +11. **DisableStencil** +12. **LiveDynamic** — player + NPCs + dropped items, depth-test only + +--- + +## Data model + +### `Building` (new class) + +`src/AcDream.App/Rendering/Wb/Building.cs` + +```csharp +namespace AcDream.App.Rendering.Wb; + +public sealed class Building +{ + public required uint BuildingId { get; init; } + public required HashSet EnvCellIds { get; init; } + public required IReadOnlyList ExitPortalPolygons { get; init; } + + // Step 5 occlusion-query state (mutable, per-frame): + public uint QueryId; // GL query id, lazily created + public bool QueryStarted; // true after first BeginQuery + public bool WasVisible; // result from prev-frame query; drives whether to render this frame +} +``` + +### `BuildingRegistry` (new class, per-landblock) + +`src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` + +```csharp +public sealed class BuildingRegistry +{ + // Index 1: cell-id → list of buildings containing that cell. + // Returns a list because a cell may be shared between buildings (rare; matches WB's API shape). + private readonly Dictionary> _byCellId = new(); + + // Index 2: building-id → Building. + private readonly Dictionary _byBuildingId = new(); + + public void Add(Building b) { /* keep both indexes in sync */ } + + public IReadOnlyList GetBuildingsContainingCell(uint cellId) => + _byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty(); + + public Building? GetById(uint buildingId) => + _byBuildingId.TryGetValue(buildingId, out var b) ? b : null; + + public IEnumerable All() => _byBuildingId.Values; + + public int Count => _byBuildingId.Count; +} +``` + +### `LoadedCell.BuildingId` (extension) + +`src/AcDream.App/Rendering/CellVisibility.cs` + +```csharp +public sealed class LoadedCell +{ + // ... existing fields ... + + /// + /// 2026-05-26 (Phase A8): the building this cell belongs to, if any. + /// Set at landblock load time by from + /// LandBlockInfo.Buildings. Null when the cell isn't part of any + /// building (outdoor surface cells, or dungeon cells not enumerated + /// in Buildings). + /// + public uint? BuildingId { get; init; } +} +``` + +### `BuildingLoader` (new static) + +`src/AcDream.App/Rendering/Wb/BuildingLoader.cs` + +Pure factory. Inputs: `LandBlockInfo` + an existing dictionary `IReadOnlyDictionary cellsByCellId`. Outputs: a `BuildingRegistry`. + +Algorithm: +1. For each `BuildingInfo` in `info.Buildings`: + a. Allocate a sequential `BuildingId`. + b. Walk its `Portals` (list of `CBldPortal`-equivalent items in DatReaderWriter). Each portal points to one indoor cell (`OtherCellId`). + c. Build the building's `EnvCellIds` set by adding each portal's `OtherCellId` AND any cells reachable from those via **interior portals only** (no exit portals — those connect to outdoor). Walk the portal graph BFS-style; mark visited; stop when no new cells found OR hitting an exit portal. + d. Build the building's `ExitPortalPolygons` by collecting portal polygons from each cell whose `OtherCellId == 0xFFFF` (existing `PortalMeshBuilder.BuildTriangles` logic, but per-building scoped). +2. Return the registry. + +**Open question RR2 resolves:** does WB's per-building cell set include ALL cells reachable from the building's entry portals (via any interior portal), or only the cells DIRECTLY in `BuildingInfo.Portals`? This needs verifying against WB's code (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551`). The algorithm above assumes "all interior-reachable cells" which is the conservative/correct interpretation; RR2's spike confirms before RR3 implements. + +### Cell-side mutation + +`LoadedCell` is a `sealed class` with `init` properties. To set `BuildingId` after `BuildingLoader` runs, we either: +- Add an internal setter (and document the lifecycle: "set once during landblock load, never mutated after") +- Or recreate the `LoadedCell` instances (expensive — many references) +- Or move `BuildingId` to a side-table on `BuildingRegistry` (gives up cell-side O(1) lookup → Option A, not chosen) + +**Decision: internal setter** (`{ get; internal set; }`). Documented as "set exactly once by `BuildingLoader.Apply(registry, cells)` immediately after `LandblockLoader` produces the cells; never mutated thereafter." The narrowness keeps it safe. + +--- + +## Render-frame data flow (frame-by-frame) + +### Outdoor frame (`cameraInsideBuilding == false`) + +Pre-frame: `glClear(Color | Depth)` at frame start (line 6900). Stencil **also cleared** in this restructure (was not before; the missing stencil clear is potentially load-bearing — see Risk Register). + +``` +1. Sky pre-scene (existing _skyRenderer.RenderSky) + - Writes color + depth at far plane +2. Terrain + - Writes color + depth at terrain Z +3. RenderOutsideIn — for each visible building: + - Stencil bit 1 at building's exit portals + - Stencil-gated render of building's EnvCells (occlusion-query-driven) + - Reset stencil between iterations +4. Draw(set: All) — outdoor entities (trees, stabs, dynamic) +5. Weather post-scene +``` + +### Indoor frame (`cameraInsideBuilding == true`) + +``` +Pre-frame: glClear(Color | Depth | Stencil) + +1. Skip sky pre-scene (gated on !cameraInsideBuilding) +2. Skip terrain (gated on !cameraInsideBuilding) +3. (No depth-clear — block deleted) + +4. MarkAndPunch + - Stencil bit 1 + depth=1.0 at camera-buildings' exit portal silhouettes + - GL state on exit: stencil off, depth normal, masks all + +5. IndoorPass — WbDrawDispatcher.Draw(set: IndoorPass, cellIds: camBuildings.SelectMany(b => b.EnvCellIds)) + - Walks ONLY the camera-buildings' cells (typically 1-3 cells for a Holtburg cottage cluster) + - + building shells (IsBuildingShell from R1) + - Stencil off, DepthFunc.Less + - Writes cottage wall/floor/ceiling depth that protects against outdoor leak + +6. EnableOutdoorPass + - StencilFunc.Equal(1, 0x01), StencilMask 0x00 + +7. Stencil-gated sky + - DepthMask off; _skyRenderer.RenderSky; DepthMask on + - Sky color writes through punched depth=1.0 only where stencil bit 1 is set (portal silhouettes) + - DepthMask off so the punched depth survives for step 8 + +8. Stencil-gated terrain re-draw + - Terrain Z (with f48c74a -0.01 nudge) overwrites the punched 1.0 at portal pixels + - Color: terrain near-field through window; beyond terrain horizon, sky color from step 7 survives + +9. Stencil-gated OutdoorScenery + - WbDrawDispatcher.Draw(set: OutdoorScenery) + - Stabs/procedural scenery visible at portal silhouettes + +10. Step 5 — for each other building in otherBuildings: + a. Read prev-frame occlusion query result → b.WasVisible + b. Begin new occlusion query + c. Mark stencil bit 2 at this building's portals + - StencilFunc.Equal(3, 0x01), StencilMask 0x02 + - Replace where bit 1 already set + d. End occlusion query + e. Clear depth where stencil == 3 (re-draw portals with DepthFunc.Always) + f. Render this building's EnvCells where stencil == 3 + - WbDrawDispatcher.Draw(set: IndoorPass, cellIds: building.EnvCellIds) + g. Reset bit 2 for next iteration + - Replace where bit 2 set with bit 2 cleared + +11. DisableStencil + +12. LiveDynamic — player + NPCs + dropped items + - Stencil off, depth-test only +``` + +--- + +## Components & file map + +| File | Status | Purpose | +|---|---|---| +| `src/AcDream.App/Rendering/Wb/Building.cs` | **NEW** | `Building` data class (cell ids + portal polygons + occlusion-query state) | +| `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` | **NEW** | Per-landblock registry; two indexes (`byCellId`, `byBuildingId`) | +| `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` | **NEW** | Static factory: reads `LandBlockInfo.Buildings` + interior-portal walk; produces registry; sets `LoadedCell.BuildingId` | +| `src/AcDream.App/Rendering/CellVisibility.cs` (`LoadedCell`) | MODIFY | Add `BuildingId: uint?` (init + internal setter) | +| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` | **EXTEND** | Add 3-bit mode for Step 5; add occlusion-query helpers; building-portal upload helper | +| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MINOR | New overload `Draw(... cellIds: IEnumerable, ...)` to walk an explicit cell list (used by IndoorPass + Step 5) | +| `src/AcDream.App/Rendering/GameWindow.cs` | RESTRUCTURE | Render frame: single `cameraInsideBuilding` gate; new indoor branch with Steps 1-5; new outdoor branch with RenderOutsideIn pass; landblock-load wires `BuildingLoader` | +| `src/AcDream.App/Rendering/Shaders/portal_stencil.{vert,frag}` | LIKELY OK | Re-used as-is for Step 5 (uniform/state changes are in the C# pipeline class, not the shader) | +| `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs` | **NEW** | Mock `LandBlockInfo`; assert cell mapping + interior-portal walk + exit-portal extraction | +| `tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs` | **NEW** | Two-way indexing invariants | +| `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` | EXTEND | 3-bit mode + occlusion-query state machine | +| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` | EXTEND | New `Draw(cellIds:)` overload tests | +| `docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md` | FOOTER-MARK | Mark as SUPERSEDED by this doc; brief note pointing here | +| `docs/superpowers/plans/2026-05-26-phase-a8-restructure.md` | FOOTER-MARK | Same — SUPERSEDED | +| `docs/ISSUES.md` | UPDATE (in RR12) | Move #78 to closed; close #102 (subsumed by Step 5); file new follow-ups if any | +| `CLAUDE.md` | UPDATE (in RR12) | Update A8 paragraph to SHIPPED with full description | + +**Commits to revert at the start of execution (RR1):** +- `2bfeafd` R3.5 v2 (depth-clear gate) +- `38d5374` R3.5 v1 (stencil-branch gate) +- `60f07bc` R3 (stencil-pipeline wire-in) + +**Commits kept:** +- `ed72704` R1 — IsBuildingShell tag (still useful — drives EntitySet partition + Step 5 building-shell rendering) +- `55f26f2` R2 — EntitySet partition (still useful — IndoorPass/OutdoorScenery/LiveDynamic semantics unchanged) +- Tasks 1-6 infrastructure (PortalPolygons field, IndoorCellStencilPipeline, portal_stencil shaders, ProbeVisibilityEnabled) + +**Uncommitted local change to commit before reverts:** the `[vis]` probe wired in GameWindow.cs (RenderingDiagnostics gate). Useful long-term diagnostic; commit as a standalone preserving improvement. + +--- + +## Testing strategy + +### Unit tests (TDD where feasible) + +- **`BuildingLoaderTests`** — fixture `LandBlockInfo` with synthetic `Buildings` array; assert: + - Single-portal building maps to one cell + - Multi-portal building maps to multiple cells + - Interior-portal walk reaches all connected cells + - Exit portal extraction collects right polygons + - Empty Buildings → empty registry +- **`BuildingRegistryTests`** — add/lookup two-way invariants; cell shared between buildings handled; Count accurate. +- **`IndoorCellStencilPipelineTests`** — 3-bit mode portal-triangle math (existing pattern); occlusion-query state machine if mockable (BeginQuery / EndQuery / read-back lifecycle). +- **`WbDrawDispatcherEntitySetTests`** — new `Draw(cellIds:)` overload: explicit cell list filters indoor entities correctly; combined with EntitySet partition works as expected. + +### Visual verification matrix (per RR8, RR10, RR12) + +| Scenario | Acceptance | +|---|---| +| Cottage interior (ground floor) | Walls solid; no see-through to outside; **sky visible through windows** (Issue B closure) | +| Cottage cellar | Walls solid; **cottage floor SOLID, no transparent floor showing cellar** (Issue C closure); no grass overlay through stairs from inside | +| Holtburg Inn (multi-room) | All walls solid; **no see-through to adjacent rooms** (no cross-room portal leak); sky through windows | +| Dungeon (no buildings) | Corridor walls solid; indoor lighting; cells without BuildingId render via fallback path | +| Exit transition (indoor → outdoor) | Clean — **no through-ground flicker** (Issue A closure); no missing walls on adjacent cottages | +| Entry transition (outdoor → indoor) | Clean — no transparent floor; no texture flicker | +| Cross-building (Step 5) | Stand inside Holtburg Inn, look through window at a cottage across the street with the cottage's window facing you; **cottage interior visible through its own window through the inn's window** | +| Looking-into-windows from outside (RenderOutsideIn) | Stand outside a cottage, look at its window; **cottage interior visible** (cottage walls, furniture, NPCs inside) | +| No regression on #100 | No transparent rectangles around cottages | + +### Risk register + +| Risk | Likelihood | Mitigation | +|---|---|---| +| `LandBlockInfo.Buildings` portal data shape differs from WB's expectations | Medium | **RR2 spike** dumps Holtburg landblock's Buildings array structure before RR3 designs/implements | +| Interior-portal walk algorithm has subtle rules I don't capture in BuildingLoader | Medium | RR2 reads WB `PortalRenderManager:518-551` verbatim; algorithm matches WB | +| 3-bit stencil + occlusion-query has GPU-specific quirks | Medium | RR6 mirrors WB's exact GL state sequence; tests on dev machine; comments document WB line refs | +| Occlusion queries cause CPU stall if read same-frame | Low | WB uses prev-frame results (asynchronous); we mirror this | +| Step 5 logic complex, multiple iterations needed | High | **RR8 gate** isolates Steps 1-4 first; if A+C+#78 closed at RR8, Step 5 can ship as a follow-on without blocking M1.5 indoor acceptance | +| Some Holtburg cottages don't have `BuildingInfo` entries (older content) | Medium | BuildingLoader handles missing data: cells without a Building get `BuildingId == null`, flow through the outdoor render path | +| Stencil-buffer clear missing at frame start (line 6900 currently only clears Color+Depth) | High | Restructure adds `ClearBufferMask.StencilBufferBit` to the per-frame clear. Validate no other code relied on stencil persisting between frames | +| `LoadedCell` immutability broken by `internal set` on BuildingId | Low | Documented narrowly — BuildingLoader.Apply is the sole writer, runs once after LandblockLoader | +| RR2 spike reveals BuildingInfo structure incompatible with the design | Low-Medium | Re-brainstorm gate at RR2 outcome; design doc updated; subsequent tasks adapted | + +### Falsifiability + +The visual matrix is the acceptance test. If any scenario fails at RR8 or RR12, the failure mode determines next step: +- **Issue C or A reproduces** → IndoorPass partition wrong; debug with `[vis]` probe; check `camBuildings.EnvCellIds` actually narrows to the camera's cottage cluster +- **Sky-through-window fails** → step 7 GL state issue; check DepthMask + stencil interaction +- **Cross-building (Step 5) fails** → likely 3-bit stencil math wrong; compare per-iteration GL state against WB line-by-line +- **RenderOutsideIn fails** → check building-frustum culling + per-building portal mesh upload + +Each failure has a deterministic next investigation step. We avoid the "speculative fix → another iteration" anti-pattern. + +--- + +## Task breakdown + +| # | Task | Sub-skill | Est. session | Outputs | +|---|---|---|---|---| +| **RR1** | Cleanup: commit `[vis]` probe (uncommitted); revert R3+R3.5 v1+R3.5 v2; footer-mark old design + plan as SUPERSEDED | (mechanical) | 0.5 | 1 probe commit, 3 revert commits, 2 footer-marks | +| **RR2** | **Spike**: dump `LandBlockInfo.Buildings` for Holtburg cottages (e.g. landblock 0xA9B40000); read WB `PortalRenderManager:518-551`; write findings doc `2026-05-2X-a8-buildings-data-shape.md` locking the data shape + interior-portal walk algorithm | (research) | 0.5-1 | Findings doc | +| **RR3** | TDD-implement `Building`, `BuildingRegistry`, `BuildingLoader` (+ unit tests) | impl | 1 | 3 new files + 2 test files | +| **RR4** | Wire `BuildingRegistry` into landblock load + `LoadedCell.BuildingId` propagation | impl | 0.5 | GameWindow.cs ctor wiring + LoadedCell change | +| **RR5** | Extend `WbDrawDispatcher` with `Draw(cellIds:)` overload (TDD) | impl | 0.5 | Dispatcher + test extension | +| **RR6** | Extend `IndoorCellStencilPipeline` for 3-bit mode + occlusion-query helpers | impl | 1 | Pipeline class extension + tests | +| **RR7** | Restructure render frame: WB-faithful Steps 1-4 + stencil-gated sky + outdoor branch (no Step 5 yet) + initial sky/terrain gates | impl | 1 | GameWindow.cs render-frame block rewrite | +| **RR8** | **Visual verification gate**: Steps 1-4 working (cottage / cellar / inn / dungeon / sky-through-windows / clean transitions). Closes #78 + R4 Issues A+C. | (visual) | 0.5 | RR8 findings doc; gate decision | +| **RR9** | Implement Step 5 (cross-building visibility) | impl | 1-2 | Pipeline + GameWindow.cs additions | +| **RR10** | **Visual verification gate**: Step 5 working (inn→window→cottage across street) | (visual) | 0.5 | RR10 findings doc | +| **RR11** | Implement `RenderOutsideIn` (outdoor camera looking into cottage windows) | impl | 1 | Outdoor-branch addition | +| **RR12** | Final visual matrix + ship docs (close #78; update CLAUDE.md A8 paragraph; supersede old docs in their footers) | (visual + docs) | 0.5 | ISSUES.md + CLAUDE.md commits | + +**Total estimate: 8-10 sessions** (~1.5-2 weeks calendar). + +**Gate decisions:** +- After **RR2** outcome: if `LandBlockInfo.Buildings` structure incompatible → re-brainstorm. +- After **RR8** outcome: if any of #78, Issue A, Issue C still reproduces → debug into RR7 implementation BEFORE proceeding to RR9 (Step 5 amplifies bugs). +- After **RR10** outcome: if Step 5 visibly broken → file as a known issue and proceed to RR11+RR12. Step 5 is a polish-tier feature; Steps 1-4 carry M1.5. + +--- + +## Alternatives considered + +### Alt 1 — `cameraInsideCell` (lenient) gate everywhere + +Re-use the existing lenient flag. Means grace frames stay treated as "inside" for the render frame. Reintroduces every grace-state bug seen in R3.5. Rejected: the entire RR0 analysis points to dropping grace for the render path. + +### Alt 2 — `LoadedCell.BuildingId` as the SOLE data source (Option B from brainstorm) + +Cells carry building-id; no registry; rebuilds the per-building cell set per frame by scanning all cells. Performance fine at our scale, but the API becomes awkward (no `building.EnvCellIds` to iterate). Rejected per the data-model brainstorm. + +### Alt 3 — Per-cell Z-offset to break co-planar Z-fight + +Apply `glPolygonOffset` differently per cell to resolve the cottage-floor / cellar-ceiling tie. Doesn't address the structural "render 16 cells at full screen" issue — just patches one of its symptoms. Rejected as another half-fix in the R3 lineage. + +### Alt 4 — Retail polygon-clip scissor port + +Most faithful to retail (`PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709`). Implements per-portal screen-space clipping recursively. Multi-week scope. WB's stencil approach is a known-good equivalent observable behavior; preferring WB matches "rendering: WB is acdream's base" per CLAUDE.md. + +### Alt 5 — Defer all A8 work; live with #78 on main + +Revert R1+R2+R3+R3.5; ship M1.5 without indoor visibility fix; come back later. Rejected per user direction ("Try to expand A8 scope to a full WB port now"). + +--- + +## Open follow-ups (post-RR12) + +- **#78** — closed by this phase +- **#102** (cross-cell-portal far-side visibility, WB Step 5 deferral) — closed by Step 5 in this phase +- **#103** (cellar terrain Z-fight from outside, out-to-in artifact) — pre-existing; may be addressed by `RenderOutsideIn` if the cellar entrance is treated as a building portal; verify in RR12 +- **Grace-mechanism cleanup** — non-render consumers may still benefit from grace in `CellVisibility.FindCameraCell`; auditing them is out of scope. Future cleanup phase. + +--- + +## Self-review notes + +- **Placeholder scan:** no TBDs/TODOs/incomplete sections. RR2 explicitly resolves the "open question" about WB's interior-portal walk algorithm via the spike + findings doc; that's a CONCRETE next step, not a placeholder. +- **Internal consistency:** `cameraInsideBuilding` semantics stated once and used consistently across all sections. `BuildingId` lifecycle stated once (set at load by `BuildingLoader.Apply`, never mutated thereafter). Step numbering (1-12) consistent between architecture, data-flow, and task breakdown sections. +- **Scope check:** twelve tasks, ~8-10 sessions, decomposed by surface (data model → dispatcher → pipeline → render frame → Step 5 → RenderOutsideIn → ship). Each task is single-PR-sized. No scope sprawl. +- **Ambiguity check:** + - "render only the camera-buildings' cells" — explicit about USING the registry, not BFS + - "stencil-gated sky with DepthMask off + on" — explicit about depth-buffer effect + - "RR2 spike resolves the interior-portal walk algorithm" — explicit about what's settled vs deferred + - "RR8 gate decision" — explicit about what failure→action mapping looks like + +--- + +## Notes for the writing-plans skill + +When this design enters `superpowers:writing-plans`: +- Each RR# task above maps to one plan task. +- TDD where feasible (RR3, RR5, RR6 have clear unit-test surfaces). +- GL integration tasks (RR7, RR9, RR11) are visual-verification-only. +- Spike (RR2) is research-only; output is a findings doc, no production code. +- Add to the spec reviewer prompt for RR7, RR9, RR11: *"Does the implementation match WB's `RenderInsideOut`/`RenderOutsideIn` at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330` line-by-line? Reviewer must cross-reference."*