From 651e7e22fb8f30db354cbe473e543b7e957ac717 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 09:57:45 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Phase=20A8=20=E2=80=94=20full=20W?= =?UTF-8?q?B=20RenderInsideOut=20+=20RenderOutsideIn=20port=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12-task implementation plan (RR1-RR12, 94 step checkboxes total): RR1 — Cleanup: commit [vis] probe; revert R3+R3.5 v1+v2; supersede old docs RR2 — Spike: confirm BuildingInfo shape + WB interior-portal walk algorithm RR3 — Implement Building + BuildingRegistry + BuildingLoader (TDD, 10 tests) RR4 — Wire registry into landblock load + LoadedCell.BuildingId RR5 — WbDrawDispatcher.Draw(cellIds:) overload (TDD) RR6 — IndoorCellStencilPipeline 3-bit + occlusion-query helpers RR7 — Render frame: WB Steps 1-4 + outdoor branch + stencil-gated sky RR8 — Visual verification gate: Steps 1-4 close #78 + Issues A+C RR9 — Step 5 (3-stencil-bit cross-building + occlusion queries) RR10 — Visual verification gate: Step 5 RR11 — RenderOutsideIn (cottage interiors through windows from outside) RR12 — Final visual matrix + ship docs (close #78, #102; update CLAUDE.md) Each task: bite-sized 2-5 min steps; exact code snippets; commit per task. Visual gates at RR8, RR10, RR12 ensure each layer works before adding the next. Risk register handles RR2 data-shape uncertainty + RR9/RR11 frustum API adaptation. Estimated 8-10 sessions (~1.5-2 weeks calendar). Closes M1.5 indoor world acceptance scope. Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-26-phase-a8-wb-full-port.md | 2512 +++++++++++++++++ 1 file changed, 2512 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md diff --git a/docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md b/docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md new file mode 100644 index 0000000..45c2df4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md @@ -0,0 +1,2512 @@ +# Phase A8 — Full WorldBuilder RenderInsideOut + RenderOutsideIn Port — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port WorldBuilder's full `RenderInsideOut` (Steps 1-5) and `RenderOutsideIn` indoor visibility pipeline to acdream, replacing the current half-port (R3) with per-building cell scoping. Closes issue #78 (outdoor visible through indoor walls), R4 Issues A + C (transition flicker + transparent floor), and #102 (cross-cell-portal visibility). + +**Architecture:** Add per-landblock `BuildingRegistry` mapping each cell to its building (or null). Render frame branches on a strict `cameraInsideBuilding` gate: indoor path renders only the camera-buildings' cells via WB Steps 1-4 + stencil-gated sky + Step 5 cross-building; outdoor path renders the world + `RenderOutsideIn` to show cottage interiors through windows from the street. Single source of truth for "inside a building" — drop the lenient/strict two-flag asymmetry that caused R3.5 v1+v2's bugs. + +**Tech Stack:** C# .NET 10, Silk.NET (OpenGL 4.3 + GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters + occlusion-query objects), xUnit. + +**Predecessor context (REQUIRED reading before starting):** +- [docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md](../specs/2026-05-26-phase-a8-wb-full-port-design.md) — the approved design this plan implements +- [docs/research/2026-05-26-a8-rr0-falsification-findings.md](../../research/2026-05-26-a8-rr0-falsification-findings.md) — RR0 evidence triggering the scope expansion +- [references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs) — proven reference; `RenderInsideOut` (73-239) + `RenderOutsideIn` (241+) must be ported + +**Infrastructure consumed as-is:** +- R1: `WorldEntity.IsBuildingShell` flag set by `LandblockLoader` (commit `ed72704`) +- R2: `WbDrawDispatcher.EntitySet` partition (commit `55f26f2`) +- Tasks 1-6 infrastructure: `LoadedCell.PortalPolygons`, `IndoorCellStencilPipeline`, `PortalMeshBuilder`, `portal_stencil.vert/.frag`, `RenderingDiagnostics.ProbeVisibilityEnabled` (commits `fee878f` → `dcf69a1`) + +**HEAD at session start:** `ea60d1f` (design doc commit). The `[vis]` probe code wired in RR0 spike is uncommitted; RR1 commits it before reverting R3. + +--- + +## File Structure + +| File | Status | Purpose | +|---|---|---| +| `src/AcDream.App/Rendering/Wb/Building.cs` | NEW | `Building` data class: BuildingId, EnvCellIds, ExitPortalPolygons, occlusion-query state | +| `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` | NEW | Two-way indexed registry (by cellId + by buildingId) | +| `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` | NEW | Static factory: reads LandBlockInfo.Buildings, walks interior portals, computes per-building cell sets | +| `src/AcDream.App/Rendering/CellVisibility.cs` (LoadedCell) | MODIFY | Add `BuildingId: uint?` field with internal setter | +| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` | EXTEND | Add 3-bit stencil mode + occlusion-query helpers + per-building portal upload | +| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | EXTEND | Add Draw overload accepting explicit `IEnumerable` cellIds (instead of visibility-derived set) | +| `src/AcDream.App/Rendering/GameWindow.cs` | RESTRUCTURE | Render-frame block: single `cameraInsideBuilding` gate; indoor path with Steps 1-5; outdoor path with RenderOutsideIn; landblock-load wires BuildingLoader | +| `tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs` | NEW | Building data-class invariants | +| `tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs` | NEW | Two-way indexing invariants | +| `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs` | NEW | LandBlockInfo→registry mapping; interior-portal walk; exit-portal extraction | +| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs` | NEW | New Draw(cellIds:) overload behavior | +| `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` | EXTEND | 3-bit mode + occlusion-query state machine | +| `docs/research/2026-05-2X-a8-buildings-data-shape.md` | NEW (RR2 output) | Spike findings: BuildingInfo data shape + walk algorithm | +| `docs/research/2026-05-2X-a8-rr8-steps-1-4-visual.md` | NEW (RR8 output) | Visual verification log for Steps 1-4 | +| `docs/research/2026-05-2X-a8-rr10-step-5-visual.md` | NEW (RR10 output) | Visual verification log for Step 5 | +| `docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md` | FOOTER-MARK | Mark as SUPERSEDED | +| `docs/superpowers/plans/2026-05-26-phase-a8-restructure.md` | FOOTER-MARK | Mark as SUPERSEDED | +| `docs/ISSUES.md` | UPDATE (RR12) | Move #78 closed; close #102 (subsumed by Step 5); file new follow-ups if any | +| `CLAUDE.md` | UPDATE (RR12) | A8 paragraph: PAUSED → SHIPPED with full description | + +**Commits reverted in RR1:** +- `60f07bc` R3 (stencil-pipeline wire-in) +- `38d5374` R3.5 v1 (stencil-branch gate) +- `2bfeafd` R3.5 v2 (depth-clear gate) + +**Commits kept:** +- R1 `ed72704` IsBuildingShell flag +- R2 `55f26f2` EntitySet partition +- Tasks 1-6 infrastructure (shipped 2026-05-25 under earlier SHAs) + +--- + +## Task RR1: Cleanup — commit `[vis]` probe, revert R3+R3.5 v1+R3.5 v2, supersede old docs + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (commit the existing uncommitted `[vis]` probe code added during RR0 spike) +- Auto-generated by reverts: `src/AcDream.App/Rendering/GameWindow.cs` (revert R3+R3.5 v1+R3.5 v2) +- Modify (footer-mark): `docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md` +- Modify (footer-mark): `docs/superpowers/plans/2026-05-26-phase-a8-restructure.md` + +- [ ] **RR1-S1: Verify uncommitted [vis] probe is in working tree** + +```bash +git diff src/AcDream.App/Rendering/GameWindow.cs | head -40 +``` + +Expected: shows the `RenderingDiagnostics.ProbeVisibilityEnabled` probe block added during RR0 spike. If empty/missing, the probe was already committed elsewhere — skip to RR1-S3. + +- [ ] **RR1-S2: Commit the `[vis]` probe as a standalone diagnostic** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +diag(render): Phase A8 [vis] probe — light up dormant ProbeVisibilityEnabled + +Wires the dormant RenderingDiagnostics.ProbeVisibilityEnabled flag (added +2026-05-25 by Task 6 of the original A8 plan, no probe code) to per-frame +[vis] log lines around the render-frame branch decision. Captures camera +position, cameraInsideCell (lenient grace-aware), the strict PointInCell +result, the visibility CameraCell id, and VisibleCellIds count/list. + +Enable via ACDREAM_PROBE_VIS=1. + +Used during A8 RR0 falsification spike (2026-05-26) — see +docs/research/2026-05-26-a8-rr0-falsification-findings.md. Kept as long- +term diagnostic for the upcoming RR8/RR10 visual verification gates. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **RR1-S3: Revert R3.5 v2 (depth-clear gate)** + +```bash +git revert 2bfeafd --no-edit +``` + +Expected: one new revert commit. `git log -1 --oneline` shows `Revert "fix(render): Phase A8 R3.5 v2 — gate depth-clear on cameraReallyInside too"`. + +- [ ] **RR1-S4: Revert R3.5 v1 (stencil-branch gate)** + +```bash +git revert 38d5374 --no-edit +``` + +Expected: one new revert commit. `Revert "fix(render): Phase A8 R3.5 — gate stencil branch on PointInCell containment"`. + +- [ ] **RR1-S5: Revert R3 (stencil-pipeline wire-in)** + +```bash +git revert 60f07bc --no-edit +``` + +Expected: one new revert commit. `Revert "feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)"`. + +If the revert hits a conflict (because the `[vis]` probe commit modifies the same lines as R3): resolve by KEEPING the `[vis]` probe additions and DROPPING R3's stencil branch + depth-clear + cameraReallyInside computation. The probe must survive R3's revert because the probe sits in the surrounding context, not in R3's added code. + +To resolve the conflict manually: +- Open `src/AcDream.App/Rendering/GameWindow.cs` +- Find the conflict markers around lines ~7011 and ~7174 +- Keep: the `[vis]` probe block, the `cameraInsideCell` declaration +- Drop: the `cameraReallyInside` computation, the depth-clear-if-cameraReallyInside block, the indoor stencil branch, the outdoor `else` branch (revert leaves a single dispatcher call with `EntitySet.All`) + +Then: +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git revert --continue --no-edit +``` + +- [ ] **RR1-S6: Verify net state vs R2 baseline** + +```bash +git diff 55f26f2 HEAD -- src/AcDream.App/Rendering/GameWindow.cs +``` + +Expected output: ONLY the `[vis]` probe additions (around line 7012 + branch markers around the dispatcher call). No `cameraReallyInside`, no stencil branch, no depth-clear-if-inside. The dispatcher call should use `EntitySet.All` (single call, pre-A8 shape). + +If extra diff lines appear, manually un-do those — only the `[vis]` probe should be the delta vs R2. + +- [ ] **RR1-S7: Build + test green** + +```bash +dotnet build -c Debug --nologo +dotnet test --nologo +``` + +Expected: build green (0 warnings, 0 errors). Test failures within documented 14-23 flaky window (pre-existing PhysicsResolveCapture/Diagnostics static-leak). + +- [ ] **RR1-S8: Footer-mark old design doc as SUPERSEDED** + +Append to `docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md`: + +```markdown + +--- + +**SUPERSEDED 2026-05-26 (PM) by [2026-05-26-phase-a8-wb-full-port-design.md](2026-05-26-phase-a8-wb-full-port-design.md).** + +After RR0 falsification ([docs/research/2026-05-26-a8-rr0-falsification-findings.md](../../research/2026-05-26-a8-rr0-falsification-findings.md)) showed R4 Issues A + C were caused by R3 (not pre-existing on main), the "restructure" approach in this design was insufficient — the structural bug is rendering all 16 BFS-reachable cells at full screen extent, not just the depth-clear workaround. The new design ports WB's full RenderInsideOut + RenderOutsideIn with per-building cell scoping. + +This document is retained for historical reference and roadmap discipline. +``` + +- [ ] **RR1-S9: Footer-mark old plan as SUPERSEDED** + +Append to `docs/superpowers/plans/2026-05-26-phase-a8-restructure.md`: + +```markdown + +--- + +**SUPERSEDED 2026-05-26 (PM) by [2026-05-26-phase-a8-wb-full-port.md](2026-05-26-phase-a8-wb-full-port.md).** + +This plan implemented the "WB-faithful restructure" design which RR0 evidence invalidated. The new plan implements the full WorldBuilder RenderInsideOut + RenderOutsideIn port. RR0 was completed and its findings doc is retained. + +This document is retained for historical reference. +``` + +- [ ] **RR1-S10: Commit footer-marks** + +```bash +git add docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md docs/superpowers/plans/2026-05-26-phase-a8-restructure.md +git commit -m "$(cat <<'EOF' +docs: Phase A8 — mark prior restructure design+plan as SUPERSEDED + +Both documents are retained for historical reference. The new full-WB-port +design (2026-05-26-phase-a8-wb-full-port-design.md, ea60d1f) replaces them. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR2: Spike — confirm BuildingInfo data shape + interior-portal walk algorithm + +**Goal:** Before implementing `BuildingLoader`, verify (a) what fields `DatReaderWriter.Types.BuildingInfo` exposes; (b) how WB's `PortalRenderManager` actually computes a building's full cell set from BuildingInfo entries. + +**Files:** +- Create: `docs/research/2026-05-26-a8-buildings-data-shape.md` + +This is research-only. No code shipped. + +- [ ] **RR2-S1: Inspect BuildingInfo struct via DatReaderWriter** + +```bash +grep -rn "class BuildingInfo\|struct BuildingInfo\|record BuildingInfo" references/Chorizite.DatReaderWriter/ 2>/dev/null | head -5 +``` + +If no match, look in the package source: + +```bash +find ~/.nuget/packages/chorizite.datreaderwriter -name "BuildingInfo*.cs" 2>/dev/null | head -3 +``` + +Alternative — read what `LandblockLoader.cs:74-87` references and document those fields. Capture in the findings doc: +- Field names (ModelId? Frame? NumPortals? Portals?) +- Field types +- What `CBldPortal` (or its DRW equivalent) looks like — what's its `OtherCellId`? + +- [ ] **RR2-S2: Read WB PortalRenderManager building-cell association** + +Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551` (or surrounding lines if line numbers shifted — search for `BuildingPortalGPU` constructor and `EnvCellIds` population): + +```bash +grep -n "BuildingPortalGPU\|EnvCellIds" references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs | head -10 +``` + +Document in the findings doc: +- How WB walks BuildingInfo's portals to compute EnvCellIds +- Whether WB walks interior portals BEYOND the BuildingInfo.Portals (e.g., from cell A reached via BuildingInfo.Portals, walk through cell A's interior portals to find more cells) +- Whether WB stops at exit portals (OtherCellId == 0xFFFF) or treats them differently +- Whether occlusion-query state is set up at construction or lazily + +- [ ] **RR2-S3: Live-inspect a Holtburg cottage's BuildingInfo** + +Add a temporary diagnostic in `LandblockLoader.cs:74-87` (the Buildings loop). Inside the loop, log: + +```csharp +Console.WriteLine($"[building-shape] lb=0x{landblockId:X8} idx={i} ModelId=0x{building.ModelId:X8} " + + $"Frame.Origin=({building.Frame.Origin.X:F1},{building.Frame.Origin.Y:F1},{building.Frame.Origin.Z:F1}) " + + $"Portals={building.Portals?.Count ?? 0}"); +if (building.Portals is not null) +{ + foreach (var p in building.Portals) + Console.WriteLine($"[building-shape] portal -> OtherCellId=0x{p.OtherCellId:X8} ... " + + $"(remaining fields: {p.GetType().GetProperties().Length} total)"); +} +``` + +(Use reflection to enumerate property names if the type is uncertain.) + +Build + launch + walk to a Holtburg cottage. Grep `[building-shape]` lines from the log. Document in the findings doc. + +```bash +dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo +# launch then close after seeing the log +grep "\[building-shape\]" launch.log | head -20 +``` + +Then revert the diagnostic — it shouldn't ship: + +```bash +git checkout HEAD -- src/AcDream.Core/World/LandblockLoader.cs +``` + +- [ ] **RR2-S4: Write findings doc** + +Create `docs/research/2026-05-26-a8-buildings-data-shape.md` with: +- Section 1: `BuildingInfo` field shape (verbatim from DatReaderWriter or live-inspection) +- Section 2: Holtburg cottage's actual BuildingInfo dump (entry-portal cells, polygon vertex counts, etc.) +- Section 3: WB's interior-portal walk algorithm — pseudocode +- Section 4: Resolved algorithm for acdream's `BuildingLoader` (translate WB's pseudocode to our types) +- Section 5: Edge cases noted (e.g., cells in multiple buildings; buildings without portals; etc.) + +- [ ] **RR2-S5: Commit findings** + +```bash +git add docs/research/2026-05-26-a8-buildings-data-shape.md +git commit -m "$(cat <<'EOF' +docs(research): Phase A8 RR2 — BuildingInfo data shape + interior-portal walk + +Spike findings before RR3 (BuildingLoader impl). Documents: + - DatReaderWriter.Types.BuildingInfo field shape + - Holtburg cottage BuildingInfo dump (entry portals + polygon counts) + - WB PortalRenderManager interior-portal walk algorithm (pseudo) + - Resolved algorithm for acdream BuildingLoader + - Edge cases (shared cells, missing data, etc.) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **RR2-S6: Gate decision** + +If RR2 confirms the design's assumptions → proceed to RR3. + +If RR2 finds the data shape incompatible (e.g., BuildingInfo doesn't expose Portals; WB's walk algorithm requires data we can't derive) → STOP. Re-brainstorm via `superpowers:brainstorming` to adapt the design. + +--- + +## Task RR3: Implement `Building`, `BuildingRegistry`, `BuildingLoader` (TDD) + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/Building.cs` +- Create: `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` +- Create: `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` +- Create: `tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs` +- Create: `tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs` +- Create: `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs` + +- [ ] **RR3-S1: Write failing tests for `Building` data class** + +Create `tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingTests +{ + [Fact] + public void Building_RequiredFields_PopulateCorrectly() + { + var b = new Building + { + BuildingId = 42, + EnvCellIds = new HashSet { 0xA9B40150u, 0xA9B40151u }, + ExitPortalPolygons = new List + { + new[] { new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(1, 1, 0) }, + }, + }; + + Assert.Equal(42u, b.BuildingId); + Assert.Equal(2, b.EnvCellIds.Count); + Assert.Contains(0xA9B40150u, b.EnvCellIds); + Assert.Single(b.ExitPortalPolygons); + Assert.Equal(3, b.ExitPortalPolygons[0].Length); + } + + [Fact] + public void Building_OcclusionQueryState_DefaultsZero() + { + var b = new Building + { + BuildingId = 0, + EnvCellIds = new HashSet(), + ExitPortalPolygons = new List(), + }; + + Assert.Equal(0u, b.QueryId); + Assert.False(b.QueryStarted); + Assert.False(b.WasVisible); + } +} +``` + +- [ ] **RR3-S2: Run BuildingTests to verify failure** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingTests" --nologo +``` + +Expected: BUILD FAILURE with `'Building' does not exist in namespace 'AcDream.App.Rendering.Wb'`. + +- [ ] **RR3-S3: Implement `Building`** + +Create `src/AcDream.App/Rendering/Wb/Building.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked +/// via the dat-level LandBlockInfo.Buildings entry. Building shells (cottage +/// walls, inn walls — IsBuildingShell=true entities) render unconditionally +/// when the camera is inside this building's cells. The exit portal polygons +/// are stencil-marked so outdoor visibility leaks through portal silhouettes +/// only. +/// +/// Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses +/// the occlusion-query state to skip rendering when the building's portals +/// weren't visible last frame. +/// +public sealed class Building +{ + /// Unique within a landblock; allocated sequentially by BuildingLoader. + public required uint BuildingId { get; init; } + + /// The EnvCells this building owns. Includes all cells reachable + /// from the building's entry portals via interior portals (no exit portals). + public required HashSet EnvCellIds { get; init; } + + /// Exit portal polygons in world space. Each polygon is a triangle + /// fan from vertex 0. Stencil-marked + far-depth-punched at Steps 1+2 of + /// WB's RenderInsideOut. + public required IReadOnlyList ExitPortalPolygons { get; init; } + + // Step 5 occlusion-query state (mutable, per-frame). + public uint QueryId; // GL query object; lazily created on first use + public bool QueryStarted; // true after first BeginQuery; controls read-back + public bool WasVisible; // previous-frame query result; gates rendering this frame +} +``` + +- [ ] **RR3-S4: Run BuildingTests to verify pass** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingTests" --nologo +``` + +Expected: 2 tests pass. + +- [ ] **RR3-S5: Write failing tests for `BuildingRegistry`** + +Create `tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingRegistryTests +{ + private static Building B(uint id, params uint[] cellIds) => new() + { + BuildingId = id, + EnvCellIds = new HashSet(cellIds), + ExitPortalPolygons = new List(), + }; + + [Fact] + public void Empty_NoBuildingsRegistered() + { + var reg = new BuildingRegistry(); + Assert.Equal(0, reg.Count); + Assert.Empty(reg.All()); + Assert.Empty(reg.GetBuildingsContainingCell(0xA9B40150u)); + Assert.Null(reg.GetById(0)); + } + + [Fact] + public void Add_IndexesBothDirections() + { + var reg = new BuildingRegistry(); + var b = B(1, 0xA9B40150u, 0xA9B40151u); + reg.Add(b); + + Assert.Equal(1, reg.Count); + Assert.Same(b, reg.GetById(1)); + Assert.Single(reg.GetBuildingsContainingCell(0xA9B40150u)); + Assert.Single(reg.GetBuildingsContainingCell(0xA9B40151u)); + Assert.Same(b, reg.GetBuildingsContainingCell(0xA9B40150u)[0]); + Assert.Empty(reg.GetBuildingsContainingCell(0xDEADBEEFu)); + } + + [Fact] + public void CellSharedBetweenTwoBuildings_GetBuildingsContainingCellReturnsBoth() + { + var reg = new BuildingRegistry(); + var b1 = B(1, 0xA9B40150u, 0xA9B40151u); + var b2 = B(2, 0xA9B40151u, 0xA9B40152u); // shares 0151 with b1 + reg.Add(b1); + reg.Add(b2); + + var bothAt0151 = reg.GetBuildingsContainingCell(0xA9B40151u); + Assert.Equal(2, bothAt0151.Count); + Assert.Contains(b1, bothAt0151); + Assert.Contains(b2, bothAt0151); + } + + [Fact] + public void All_EnumeratesEveryBuilding() + { + var reg = new BuildingRegistry(); + reg.Add(B(1, 0xA9B40150u)); + reg.Add(B(2, 0xA9B40160u)); + reg.Add(B(3, 0xA9B40170u)); + + var ids = new HashSet(); + foreach (var b in reg.All()) ids.Add(b.BuildingId); + + Assert.Equal(new HashSet { 1, 2, 3 }, ids); + } +} +``` + +- [ ] **RR3-S6: Run tests to verify failure** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingRegistryTests" --nologo +``` + +Expected: build failure with `'BuildingRegistry' does not exist`. + +- [ ] **RR3-S7: Implement `BuildingRegistry`** + +Create `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs`: + +```csharp +using System; +using System.Collections.Generic; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): per-landblock registry of s. +/// Two-way indexed for O(1) cell→building and building-id→building lookups. +/// Built once per landblock at load time by ; +/// no mutations after. +/// +public sealed class BuildingRegistry +{ + // Index 1: cell-id → list of buildings containing that cell. + // Cells may belong to multiple buildings (rare; matches WB's API shape). + private readonly Dictionary> _byCellId = new(); + + // Index 2: building-id → Building. + private readonly Dictionary _byBuildingId = new(); + + /// Adds a building to both indexes. Idempotent if the same Building + /// instance is added twice with the same BuildingId. + public void Add(Building b) + { + if (_byBuildingId.TryGetValue(b.BuildingId, out var existing) && ReferenceEquals(existing, b)) + return; + _byBuildingId[b.BuildingId] = b; + foreach (var cellId in b.EnvCellIds) + { + if (!_byCellId.TryGetValue(cellId, out var list)) + { + list = new List(); + _byCellId[cellId] = list; + } + if (!list.Contains(b)) list.Add(b); + } + } + + /// Returns the buildings containing . + /// Empty list when the cell isn't part of any building (outdoor cells, + /// dungeon cells not tagged by LandBlockInfo.Buildings). + public IReadOnlyList GetBuildingsContainingCell(uint cellId) => + _byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty(); + + /// Returns the building with the given id, or null. + public Building? GetById(uint buildingId) => + _byBuildingId.TryGetValue(buildingId, out var b) ? b : null; + + /// Enumerates every registered building. + public IEnumerable All() => _byBuildingId.Values; + + /// Number of registered buildings. + public int Count => _byBuildingId.Count; +} +``` + +- [ ] **RR3-S8: Run BuildingRegistryTests to verify pass** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingRegistryTests" --nologo +``` + +Expected: 4 tests pass. + +- [ ] **RR3-S9: Write failing tests for `BuildingLoader`** + +Create `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs`. The exact field shapes in this test depend on RR2's findings. The skeleton below uses what we know from `LandblockLoader.cs:74-87` (BuildingInfo has ModelId, Frame.Origin, Frame.Orientation; the portals collection field name + element type come from RR2). + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class BuildingLoaderTests +{ + // Helper: build a minimal LandBlockInfo with one BuildingInfo containing + // the supplied portal entries. The "OtherCellId" values are the cells + // the portal points INTO from outside the building. + private static LandBlockInfo MakeInfo(params (uint modelId, uint[] portalOtherCellIds)[] buildings) + { + var bls = new List(); + foreach (var (modelId, portals) in buildings) + { + var portalList = new List(); + foreach (var ocid in portals) + { + portalList.Add(new BldPortal { OtherCellId = (ushort)(ocid & 0xFFFFu), /* other fields default */ }); + } + bls.Add(new BuildingInfo + { + ModelId = modelId, + Frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }, + Portals = portalList, + }); + } + return new LandBlockInfo + { + Objects = new List(), + Buildings = bls, + }; + } + + [Fact] + public void Empty_NoBuildings_EmptyRegistry() + { + var info = new LandBlockInfo { Objects = new List(), Buildings = new List() }; + var reg = BuildingLoader.Build(info, landblockId: 0xA9B40000u, cellsByCellId: new Dictionary()); + Assert.Equal(0, reg.Count); + } + + [Fact] + public void OneBuilding_OnePortal_MapsToOneCell() + { + // Building points to cell 0x0150 in landblock 0xA9B40000 → full cell id 0xA9B40150 + var info = MakeInfo((modelId: 0x02000123u, portalOtherCellIds: new[] { 0x0150u })); + // For this test we don't need actual LoadedCells — pass an empty dict; + // the loader should still create the Building entry from the BuildingInfo alone. + var reg = BuildingLoader.Build(info, landblockId: 0xA9B40000u, cellsByCellId: new Dictionary()); + Assert.Equal(1, reg.Count); + var building = System.Linq.Enumerable.First(reg.All()); + Assert.Contains(0xA9B40150u, building.EnvCellIds); + } + + [Fact] + public void OneBuilding_MultiplePortals_MapsToMultipleCells() + { + var info = MakeInfo((0x02000123u, new[] { 0x0150u, 0x0151u, 0x0152u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, new Dictionary()); + var building = System.Linq.Enumerable.First(reg.All()); + Assert.Equal(3, building.EnvCellIds.Count); + Assert.Contains(0xA9B40150u, building.EnvCellIds); + Assert.Contains(0xA9B40151u, building.EnvCellIds); + Assert.Contains(0xA9B40152u, building.EnvCellIds); + } + + [Fact] + public void TwoBuildings_AllocateSequentialIds() + { + var info = MakeInfo( + (0x02000001u, new[] { 0x0150u }), + (0x02000002u, new[] { 0x0160u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, new Dictionary()); + Assert.Equal(2, reg.Count); + var ids = new SortedSet(); + foreach (var b in reg.All()) ids.Add(b.BuildingId); + Assert.Equal(new SortedSet { 1, 2 }, ids); // sequential 1,2 + } +} +``` + +**Note for the implementer:** the field names `Portals` and `BldPortal.OtherCellId` are placeholders pending RR2 confirmation. Match whatever RR2's findings doc says. If the type is `IList` or similar, the test code adapts directly. + +- [ ] **RR3-S10: Run BuildingLoaderTests to verify failure** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingLoaderTests" --nologo +``` + +Expected: build failure with `'BuildingLoader' does not exist`. + +- [ ] **RR3-S11: Implement `BuildingLoader`** + +Create `src/AcDream.App/Rendering/Wb/BuildingLoader.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-26): static factory that builds a per-landblock +/// from a 's +/// Buildings array. Walks the building's entry portals and (per RR2 +/// findings) optionally extends each building's cell set through +/// interior portals. +/// +/// Cells in get their +/// set to the matching building id; +/// cells without a matching building stay at BuildingId == null. +/// +public static class BuildingLoader +{ + /// + /// Builds the registry. Sequential building IDs starting at 1 (id 0 + /// reserved for "no building" semantics, but the registry uses + /// uint? on LoadedCell so 0 is a valid value too — start at 1 + /// for clarity). + /// + public static BuildingRegistry Build( + LandBlockInfo info, + uint landblockId, + IReadOnlyDictionary cellsByCellId) + { + var reg = new BuildingRegistry(); + if (info.Buildings is null || info.Buildings.Count == 0) + return reg; + + uint lbMask = landblockId & 0xFFFF0000u; + uint nextId = 1; + + foreach (var b in info.Buildings) + { + var envCellIds = new HashSet(); + var exitPortalPolys = new List(); + + // Step 1: collect entry cells from the BuildingInfo's portals. + // Each BldPortal's OtherCellId is a 16-bit cell-local id; combine + // with the landblock mask for the full cell id. + // + // Note: RR2's findings doc specifies the exact field name for the + // portal list (e.g. "Portals" or "PortalList") and the OtherCellId + // field on BldPortal. If RR2 found WB walks interior portals beyond + // the entry portals, replicate that here. + if (b.Portals is not null) + { + foreach (var portal in b.Portals) + { + if (portal.OtherCellId == 0xFFFF) continue; // exit portal — skip + uint cellId = lbMask | portal.OtherCellId; + envCellIds.Add(cellId); + } + } + + // Step 2: walk interior portals from each entry cell to find the + // building's full cell set. (Per RR2; WB's PortalRenderManager:518-551 + // does this walk.) + if (cellsByCellId.Count > 0) + { + var queue = new Queue(); + foreach (var cid in envCellIds) queue.Enqueue(cid); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (!cellsByCellId.TryGetValue(current, out var cell)) continue; + foreach (var p in cell.Portals) + { + if (p.OtherCellId == 0xFFFF) + { + // Exit portal — collect its polygon for stencil mask. + // (Polygon collection from PortalPolygons matches + // existing PortalMeshBuilder logic.) + continue; + } + uint neighbourId = lbMask | p.OtherCellId; + if (envCellIds.Add(neighbourId)) + queue.Enqueue(neighbourId); + } + } + } + + // Step 3: collect exit portal polygons in world space. + // Iterates each cell's portals; for exit portals (OtherCellId==0xFFFF), + // transforms portal polygon vertices through the cell's WorldTransform. + foreach (var cellId in envCellIds) + { + if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue; + for (int pi = 0; pi < cell.Portals.Count; pi++) + { + if (cell.Portals[pi].OtherCellId != 0xFFFF) continue; + if (pi >= cell.PortalPolygons.Count) continue; + var localPoly = cell.PortalPolygons[pi]; + if (localPoly.Length < 3) continue; + var worldPoly = new Vector3[localPoly.Length]; + for (int v = 0; v < localPoly.Length; v++) + worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform); + exitPortalPolys.Add(worldPoly); + } + } + + var building = new Building + { + BuildingId = nextId++, + EnvCellIds = envCellIds, + ExitPortalPolygons = exitPortalPolys, + }; + reg.Add(building); + + // Step 4: stamp BuildingId on each cell (needs internal setter on + // LoadedCell — added in RR4). + // RR4 wires the call site that loops through reg.All() and assigns + // cell.BuildingId = building.BuildingId for each cell in EnvCellIds. + } + + return reg; + } +} +``` + +- [ ] **RR3-S12: Run BuildingLoaderTests to verify pass** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingLoaderTests" --nologo +``` + +Expected: 4 tests pass. + +If a field-name mismatch surfaces (e.g. RR2 said `PortalList` not `Portals`), update both `BuildingLoader.cs` AND `BuildingLoaderTests.cs` to use the correct name. + +- [ ] **RR3-S13: Full build + test green** + +```bash +dotnet build -c Debug --nologo +dotnet test --nologo +``` + +Expected: build green; test failures within documented flaky window. + +- [ ] **RR3-S14: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/Building.cs src/AcDream.App/Rendering/Wb/BuildingRegistry.cs src/AcDream.App/Rendering/Wb/BuildingLoader.cs tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR3 — Building + BuildingRegistry + BuildingLoader + +New per-landblock data model for WB-style per-building cell scoping: + + Building — BuildingId, EnvCellIds, ExitPortalPolygons, + occlusion-query state (Step 5 lifecycle) + BuildingRegistry — two-way indexed (by cellId + by buildingId); + single source of truth per landblock + BuildingLoader — static factory from LandBlockInfo.Buildings; + walks interior portals to expand cell sets; + collects exit portal polygons in world space + +10 new unit tests cover data invariants + registry indexing + loader +mapping per the algorithm resolved in RR2 findings. + +LoadedCell.BuildingId stamping wired in RR4. Render-time consumption +arrives in RR7 (Steps 1-4) + RR9 (Step 5) + RR11 (RenderOutsideIn). + +Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md +Spike: docs/research/2026-05-26-a8-buildings-data-shape.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR4: Wire BuildingRegistry into landblock load + LoadedCell.BuildingId + +**Files:** +- Modify: `src/AcDream.App/Rendering/CellVisibility.cs` (`LoadedCell` class) — add `BuildingId` field +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — landblock load path: build registry, stamp `LoadedCell.BuildingId` +- Modify (tests): `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs` — add tests that pass LoadedCells and verify BuildingId gets stamped + +- [ ] **RR4-S1: Add `BuildingId` to `LoadedCell`** + +Find `public sealed class LoadedCell` in `src/AcDream.App/Rendering/CellVisibility.cs`. Add the field below `PortalPolygons` (or near other dat-derived fields): + +```csharp + /// + /// Phase A8 (2026-05-26): the building this cell belongs to, if any. + /// Set exactly once by immediately after + /// LandblockLoader produces the cells. Null when the cell isn't part of + /// any building (outdoor surface cells; dungeon cells not enumerated in + /// LandBlockInfo.Buildings). + /// + /// Used by the render frame to derive the camera-buildings set + /// via + /// and route IndoorPass cell scoping. + /// + public uint? BuildingId { get; internal set; } +``` + +- [ ] **RR4-S2: Build to verify the field compiles** + +```bash +dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo 2>&1 | tail -5 +``` + +Expected: green. The internal setter is accessible from the same assembly (BuildingLoader lives in `AcDream.App.Rendering.Wb`). + +- [ ] **RR4-S3: Add a BuildingId-stamping test** + +Append to `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs`: + +```csharp + [Fact] + public void Build_StampsLoadedCellBuildingId() + { + // Fixture: minimal LoadedCell instances representing 2 cottage cells. + var cell150 = new AcDream.App.Rendering.LoadedCell + { + CellId = 0xA9B40150u, + Portals = new List(), + PortalPolygons = new List(), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + LocalBoundsMin = new Vector3(-5, -5, -5), + LocalBoundsMax = new Vector3(5, 5, 5), + ClipPlanes = new List(), + }; + var cell151 = cell150 with { CellId = 0xA9B40151u }; // assumes record-like LoadedCell; if not, build a fresh instance + var cells = new Dictionary + { + { 0xA9B40150u, cell150 }, + { 0xA9B40151u, cell151 }, + }; + + var info = MakeInfo((0x02000123u, new[] { 0x0150u, 0x0151u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, cells); + + Assert.Equal(1, reg.Count); + var b = System.Linq.Enumerable.First(reg.All()); + // Both cells stamped with the building id: + Assert.Equal(b.BuildingId, cell150.BuildingId); + Assert.Equal(b.BuildingId, cell151.BuildingId); + } +``` + +If `LoadedCell` isn't a record (i.e., the `with` syntax doesn't work), build the second cell with a fresh `new LoadedCell { ... }` block. + +- [ ] **RR4-S4: Run the new test to verify failure** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingLoaderTests.Build_StampsLoadedCellBuildingId" --nologo +``` + +Expected: FAIL — the loader doesn't stamp BuildingId yet (we marked it as Step 4 future work in RR3). + +- [ ] **RR4-S5: Add stamping logic to `BuildingLoader.Build`** + +In `src/AcDream.App/Rendering/Wb/BuildingLoader.cs`, after creating the `building` instance and before `reg.Add(building);`, AND/OR after that, stamp BuildingId on each cell. Replace the "Step 4" comment block with: + +```csharp + var building = new Building + { + BuildingId = nextId++, + EnvCellIds = envCellIds, + ExitPortalPolygons = exitPortalPolys, + }; + reg.Add(building); + + // Step 4: stamp BuildingId on each cell (Option C — both directions + // O(1)). The internal setter on LoadedCell.BuildingId is accessible + // because this class lives in the same assembly. + foreach (var cellId in envCellIds) + { + if (cellsByCellId.TryGetValue(cellId, out var cell)) + cell.BuildingId = building.BuildingId; + } +``` + +- [ ] **RR4-S6: Run test to verify pass** + +```bash +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~BuildingLoaderTests" --nologo +``` + +Expected: 5 tests pass (the 4 from RR3 + the new stamping test). + +- [ ] **RR4-S7: Wire BuildingLoader into landblock load path** + +Find the landblock load site in `GameWindow.cs`. Grep: + +```bash +grep -n "BuildLoadedCell\|LandblockLoader\|new LoadedCell\b" src/AcDream.App/Rendering/GameWindow.cs | head -10 +``` + +Locate the function that loads a landblock's cells (likely `BuildInteriorCellsForLandblock` or similar; ~line 5000-5500 range). After the cells dictionary is fully populated AND after `LandblockLoader` produces the WorldEntities, BEFORE returning the loaded landblock, add: + +```csharp +// Phase A8 (2026-05-26): build the per-landblock BuildingRegistry from +// LandBlockInfo.Buildings, stamping LoadedCell.BuildingId for each cell +// in a building's cell set. Cells without a building stay at +// BuildingId == null (outdoor surface cells; dungeon cells not in +// LandBlockInfo.Buildings). +// +// The registry instance is held in the LoadedLandblock so the render +// frame can look up the camera's building each frame without rebuilding. +var buildingRegistry = AcDream.App.Rendering.Wb.BuildingLoader.Build( + landBlockInfo, + landblockId: landblockId, + cellsByCellId: cellsByCellId); +// Store on the loaded landblock — see RR4-S8. +loadedLandblock.BuildingRegistry = buildingRegistry; +``` + +The exact local variable names (`landBlockInfo`, `landblockId`, `cellsByCellId`, `loadedLandblock`) must match the surrounding code — adapt to whatever's actually in scope. + +- [ ] **RR4-S8: Add `BuildingRegistry` field to LoadedLandblock** + +Find the `LoadedLandblock` class (or equivalent — whatever owns the cells per-landblock). Add: + +```csharp + /// + /// Phase A8 (2026-05-26): per-landblock BuildingRegistry built from + /// LandBlockInfo.Buildings. Drives indoor cell scoping in the render + /// frame. May be empty for landblocks with no buildings (e.g. wilderness). + /// + public AcDream.App.Rendering.Wb.BuildingRegistry? BuildingRegistry { get; set; } +``` + +If the class is a record/init-only struct, change the access pattern accordingly (or use a mutable property setter). + +- [ ] **RR4-S9: Build green** + +```bash +dotnet build -c Debug --nologo 2>&1 | tail -5 +``` + +Expected: green. Any "X is not accessible due to its protection level" compile error means an internal setter is being accessed across assemblies — verify BuildingLoader and LoadedCell are both in the `AcDream.App` assembly. + +- [ ] **RR4-S10: Full test green** + +```bash +dotnet test --nologo +``` + +Expected: failures within documented flaky window. + +- [ ] **RR4-S11: Commit** + +```bash +git add src/AcDream.App/Rendering/CellVisibility.cs src/AcDream.App/Rendering/Wb/BuildingLoader.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR4 — wire BuildingRegistry into landblock load + + LoadedCell.BuildingId (init + internal setter) — set exactly once at + landblock load time by BuildingLoader; null when the cell isn't + part of any building. + + GameWindow landblock-load path: builds BuildingRegistry from + LandBlockInfo.Buildings; stamps each cell's BuildingId; stores the + registry on the LoadedLandblock for render-frame lookups. + + New BuildingLoaderTest verifies the stamping path. 5 BuildingLoader + tests total (4 from RR3 + 1 new). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR5: Extend `WbDrawDispatcher` with `Draw(cellIds:)` overload (TDD) + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` +- Create: `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs` + +The existing `WalkEntitiesForTest` helper (~line 1380) takes a `visibleCellIds: HashSet?`. We need a new overload that takes an explicit `IReadOnlyCollection cellIds` — i.e., "render the entities in these cells specifically, not the visibility-derived set." + +- [ ] **RR5-S1: Write failing tests** + +Create `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs`: + +```csharp +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; +using Xunit; + +namespace AcDream.Core.Tests.Rendering.Wb; + +public class WbDrawDispatcherCellIdsOverloadTests +{ + private static WorldEntity CellEnt(uint id, uint cellId) => new() + { + Id = id, ParentCellId = cellId, SourceGfxObjOrSetupId = 0x01000001u, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, Rotation = Quaternion.Identity, + }; + + private static WorldEntity OutdoorScenery(uint id) => new() + { + Id = id, ParentCellId = null, IsBuildingShell = false, SourceGfxObjOrSetupId = 0x01000001u, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, Rotation = Quaternion.Identity, + }; + + private static WorldEntity BuildingShell(uint id) => new() + { + Id = id, ParentCellId = null, IsBuildingShell = true, SourceGfxObjOrSetupId = 0x02000001u, + MeshRefs = new List { new() { GfxObjId = 0x01000001u } }, + Position = Vector3.Zero, Rotation = Quaternion.Identity, + }; + + [Fact] + public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells() + { + var entities = new List + { + CellEnt(0x40000001u, 0xA9B40150u), // in + CellEnt(0x40000002u, 0xA9B40151u), // in + CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list + BuildingShell(0xC0000001u), // always in (IsBuildingShell) + OutdoorScenery(0xC0000002u), // OUT (not building shell, not in list) + }; + var cellIds = new HashSet { 0xA9B40150u, 0xA9B40151u }; + var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( + entities, cellIds, set: WbDrawDispatcher.EntitySet.IndoorPass); + Assert.Equal(3, result.Count); + Assert.Contains(0x40000001u, result); + Assert.Contains(0x40000002u, result); + Assert.Contains(0xC0000001u, result); + Assert.DoesNotContain(0x40000003u, result); + Assert.DoesNotContain(0xC0000002u, result); + } + + [Fact] + public void WalkEntitiesByCellIds_EmptyCellList_StillIncludesBuildingShells() + { + var entities = new List + { + CellEnt(0x40000001u, 0xA9B40150u), + BuildingShell(0xC0000001u), + }; + var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( + entities, new HashSet(), set: WbDrawDispatcher.EntitySet.IndoorPass); + // Cell entities dropped (no cells in list); shells still pass. + Assert.Single(result); + Assert.Contains(0xC0000001u, result); + } +} +``` + +- [ ] **RR5-S2: Run test to verify failure** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherCellIdsOverloadTests" --nologo +``` + +Expected: build failure with `'WalkEntitiesForTestByCellIds' does not exist`. + +- [ ] **RR5-S3: Add the `WalkEntitiesForTestByCellIds` helper** + +In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, find the existing `WalkEntitiesForTest` helper (the one that takes `visibleCellIds: HashSet?`). Add a sibling helper that takes the explicit cell list: + +```csharp + /// + /// Phase A8 RR5 (2026-05-26): pure-data walk for the explicit cellIds + /// overload. Used by RR7's IndoorPass to render only the camera-buildings' + /// cells (instead of the visibility-derived set). + /// + /// Indoor entities (ParentCellId set) gated by membership in + /// . Outdoor entities follow the EntitySet + /// partition only (no cell-list gate). + /// + public static List WalkEntitiesForTestByCellIds( + IEnumerable entities, + IReadOnlyCollection cellIds, + EntitySet set) + { + var result = new List(); + foreach (var entity in entities) + { + if (!EntityMatchesSet(entity, set)) continue; + if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value)) + continue; + result.Add(entity.Id); + } + return result; + } +``` + +- [ ] **RR5-S4: Run tests to verify pass** + +```bash +dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherCellIdsOverloadTests" --nologo +``` + +Expected: 2 tests pass. + +- [ ] **RR5-S5: Add the production `Draw(cellIds:)` overload** + +Find the existing `public void Draw(...)` method in `WbDrawDispatcher.cs` (signature around line 468). Add a sibling overload directly below it: + +```csharp + /// + /// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only + /// entities whose ParentCellId is in , plus + /// outdoor-style entities matching the EntitySet partition. Used by + /// the indoor render branch to scope rendering to the camera-buildings' + /// cells. + /// + /// Mirrors the existing visibleCellIds-based Draw but with an + /// explicit cell list (not the BFS-derived visibility set). + /// + public void Draw( + AcDream.Core.Camera camera, + IEnumerable landblockEntries, + Frustum frustum, + uint? neverCullLandblockId, + IReadOnlyCollection cellIds, + HashSet? animatedEntityIds, + EntitySet set = EntitySet.All) + { + // Implementation: identical structure to the existing Draw, but the + // cell-id filter consults `cellIds` instead of `visibleCellIds`. + // + // The existing WalkEntitiesInto private method takes a HashSet?. + // We adapt: wrap cellIds in a HashSet if it isn't already, OR extend + // WalkEntitiesInto to take the broader IReadOnlyCollection. + HashSet cellIdSet = cellIds is HashSet hs ? hs : new HashSet(cellIds); + // Delegate to existing Draw with cellIdSet as visibleCellIds — the + // semantics are the same from the dispatcher's perspective: filter + // indoor entities by membership in the set. + Draw(camera, landblockEntries, frustum, neverCullLandblockId, + visibleCellIds: cellIdSet, + animatedEntityIds: animatedEntityIds, + set: set); + } +``` + +This overload is a thin wrapper that delegates to the existing visibleCellIds path. The semantic difference (cellIds = explicit camera-buildings cells, not BFS visibility) is captured by the caller, not the dispatcher internals — which is the correct factoring (dispatcher doesn't care WHY a cell is in the set, just whether to include its entities). + +- [ ] **RR5-S6: Build + test green** + +```bash +dotnet build -c Debug --nologo +dotnet test --nologo +``` + +Expected: build green; tests within flaky window. + +- [ ] **RR5-S7: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR5 — WbDrawDispatcher Draw(cellIds:) overload + +Adds a new public overload accepting an explicit IReadOnlyCollection +cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived +visibility set. Used by RR7's IndoorPass to scope indoor rendering to the +camera-buildings' cells, not the full portal BFS (which causes Issues A+C). + +Pure-data test helper WalkEntitiesForTestByCellIds added alongside the +production overload, mirroring the WalkEntitiesForTest pattern. + +The overload internally delegates to the existing visibleCellIds path — +the dispatcher's semantic stays the same; only the caller's intent differs +(explicit cell list vs visibility-derived). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR6: Extend `IndoorCellStencilPipeline` for 3-bit mode + occlusion-query helpers + +**Files:** +- Modify: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` +- Modify: `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` + +The existing pipeline supports single-bit (`StencilMask 0x01`) operations: `MarkAndPunch`, `EnableOutdoorPass`, `DisableStencil`. WB's Step 5 needs 3-bit support (use bit 2 alongside bit 1) plus occlusion-query lifecycle. + +- [ ] **RR6-S1: Write failing tests for occlusion-query state machine** + +Append to `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs`: + +```csharp + [Fact] + public void EnsureOcclusionQueryId_AllocatesOnFirstCall() + { + // Skip if no GL context — this test runs only in GL-equipped environments. + // The Building.QueryId field is uint; 0 means "not yet allocated." + var b = new AcDream.App.Rendering.Wb.Building + { + BuildingId = 1, + EnvCellIds = new System.Collections.Generic.HashSet(), + ExitPortalPolygons = new System.Collections.Generic.List(), + }; + Assert.Equal(0u, b.QueryId); + // Verify the pipeline's static helper that lazily allocates the GL query id. + // The contract: EnsureOcclusionQueryId(gl, ref b.QueryId) returns the + // id, setting it on first call. + // (This test requires a GL context; tagged as integration if needed.) + } +``` + +This test will need refinement when the pipeline's actual API is finalized. The contract being verified: `Building.QueryId` starts at 0, becomes nonzero after first use, persists thereafter. + +If the test infrastructure can't run GL-equipped tests, mark this as integration-only and use mocking. Otherwise it's fine to include but skip via `[Fact(Skip = "GL context required")]` until an integration harness exists. + +- [ ] **RR6-S2: Extend the pipeline with 3-bit mode operations** + +In `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`, add three new methods after the existing `DisableStencil` (~line 200ish): + +```csharp + /// + /// Phase A8 RR6 (2026-05-26): Step 5 — mark stencil bit 2 at portal + /// silhouettes WHERE bit 1 is already set. Subsequent rendering with + /// shows that building's interior + /// in the intersection silhouette (stencil == 3). + /// + /// GL state on entry: assumed to be the state after MarkAndPunch + /// cleanup OR after EnableOutdoorPass — stencil enabled, depth/color masks + /// don't matter (we touch them). + /// + public void MarkBuildingBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount) + { + // Sequence per VisibilityManager.cs:186-189: + // StencilFunc.Equal(3, 0x01) — match where bit 1 is set + // StencilOp(Keep, Keep, Replace) + // StencilMask 0x02 — only write to bit 2 + // ColorMask off; DepthMask off; DepthFunc.Lequal + // Disable CullFace; draw portal triangles + _gl.StencilFunc(StencilFunction.Equal, 3, 0x01); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + _gl.StencilMask(0x02); + _gl.ColorMask(false, false, false, false); + _gl.DepthMask(false); + _gl.Enable(EnableCap.DepthTest); + _gl.DepthFunc(DepthFunction.Lequal); + _gl.Disable(EnableCap.CullFace); + _shader.Bind(); + _gl.UseProgram(_shader.Program); + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, in viewProjection.M11); + _gl.Uniform1(_uWriteFarDepthLoc, 0); // don't punch — color/depth off anyway + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); + } + + /// + /// Phase A8 RR6: punch depth=1.0 where stencil == 3 (Step 5 substep b). + /// Clears the "interior wall depth" that was written during Step 3 at the + /// intersection silhouette so the other-building's interior wins depth + /// when rendered next. + /// + public void PunchDepthAtStencil3(Matrix4x4 viewProjection, int buildingPortalVertexCount) + { + // Sequence per VisibilityManager.cs:201-205: + // StencilFunc.Equal(3, 0x03) + // StencilMask 0x00 (read-only) + // DepthMask on; DepthFunc.Always + _gl.StencilFunc(StencilFunction.Equal, 3, 0x03); + _gl.StencilMask(0x00); + _gl.DepthMask(true); + _gl.DepthFunc(DepthFunction.Always); + // Re-draw the same portal triangles to set depth=1.0 at intersection pixels. + _shader.Bind(); + _gl.UseProgram(_shader.Program); + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, in viewProjection.M11); + _gl.Uniform1(_uWriteFarDepthLoc, 1); // punch far depth + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); + } + + /// + /// Phase A8 RR6: Step 5 — set up state to render the other-building's + /// EnvCells where stencil == 3 (intersection of our building's bit 1 + this + /// other building's bit 2). + /// + public void EnableOtherBuildingPass() + { + // Sequence per VisibilityManager.cs:210-212: + // ColorMask color; DepthFunc.Less; Enable CullFace + // stencil func stays Equal(3, 0x03) + _gl.ColorMask(true, true, true, false); + _gl.DepthFunc(DepthFunction.Less); + _gl.Enable(EnableCap.CullFace); + } + + /// + /// Phase A8 RR6: Step 5 — reset bit 2 to zero so the next other-building + /// iteration starts fresh. Re-draws this building's portals to overwrite + /// bit 2. + /// + public void ResetBit2(Matrix4x4 viewProjection, int buildingPortalVertexCount) + { + // Sequence per VisibilityManager.cs:222-228: + // ColorMask off; DepthMask off; StencilMask 0x02 + // StencilFunc.Always(1, 0x02) → write 0 because ref ANDed with mask 0x02 = 0 + // StencilOp.Replace + _gl.ColorMask(false, false, false, false); + _gl.DepthMask(false); + _gl.StencilMask(0x02); + _gl.StencilFunc(StencilFunction.Always, 1, 0x02); // ref=1; with mask 0x02 -> writes 0 to bit 2 + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + _shader.Bind(); + _gl.UseProgram(_shader.Program); + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, in viewProjection.M11); + _gl.Uniform1(_uWriteFarDepthLoc, 0); + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)buildingPortalVertexCount); + } + + /// + /// Phase A8 RR6: lazily allocate a GL query object id for occlusion testing. + /// Returns the existing id if already allocated. + /// + public uint EnsureOcclusionQueryId(ref uint slot) + { + if (slot == 0) slot = _gl.GenQuery(); + return slot; + } + + /// + /// Phase A8 RR6: read the previous frame's occlusion query result if + /// available. Asynchronous — avoids CPU stall by checking + /// QueryObjectParameterName.ResultAvailable first. + /// + public bool TryReadOcclusionResult(uint queryId, out bool anyPassed) + { + anyPassed = false; + if (queryId == 0) return false; + _gl.GetQueryObject(queryId, QueryObjectParameterName.ResultAvailable, out int available); + if (available == 0) return false; + _gl.GetQueryObject(queryId, QueryObjectParameterName.Result, out int samplesPassed); + anyPassed = samplesPassed > 0; + return true; + } + + public void BeginOcclusionQuery(uint queryId) => + _gl.BeginQuery(QueryTarget.SamplesPassed, queryId); + + public void EndOcclusionQuery() => + _gl.EndQuery(QueryTarget.SamplesPassed); +``` + +- [ ] **RR6-S3: Add a per-building portal upload helper** + +The existing `UploadPortalMesh` takes `IReadOnlyCollection`. For Step 5, we need to upload a SPECIFIC building's portal polygons (not all cells'). Add a sibling overload: + +```csharp + /// + /// Phase A8 RR6: upload a Building's ExitPortalPolygons as a triangle + /// fan for stencil/depth marking. Mirrors the cell-based UploadPortalMesh + /// but operates on the building's pre-computed world-space polygons. + /// + /// Vertex count uploaded (multiple of 3). + public int UploadBuildingPortalMesh(AcDream.App.Rendering.Wb.Building building) + { + int triCount = 0; + foreach (var poly in building.ExitPortalPolygons) + { + if (poly.Length < 3) continue; + triCount += (poly.Length - 2) * 3; + } + if (triCount == 0) { _lastVertexCount = 0; return 0; } + + if (triCount > _vboCapacityVerts) AllocateVbo(triCount); + + // Triangulate. + Span verts = stackalloc Vector3[triCount > 256 ? 256 : triCount]; + // For large counts, fall back to heap-allocated array. + Vector3[]? heapVerts = triCount > 256 ? new Vector3[triCount] : null; + var target = heapVerts ?? verts.ToArray(); + int idx = 0; + foreach (var poly in building.ExitPortalPolygons) + { + if (poly.Length < 3) continue; + var v0 = poly[0]; + for (int i = 1; i < poly.Length - 1; i++) + { + target[idx++] = v0; + target[idx++] = poly[i]; + target[idx++] = poly[i + 1]; + } + } + + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); + unsafe + { + fixed (Vector3* p = target) + _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(idx * sizeof(Vector3)), p); + } + _lastVertexCount = idx; + return idx; + } +``` + +(The `Span`/heap fallback handles small + large building portal counts efficiently. If C# 12+ allows, use collection expressions for cleanliness.) + +- [ ] **RR6-S4: Build + test green** + +```bash +dotnet build -c Debug --nologo +dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~IndoorCellStencilPipelineTests" --nologo +``` + +Expected: build green; existing IndoorCellStencilPipelineTests (5 from prior tasks) pass; the new occlusion-query test passes or is skipped (GL-context-dependent). + +- [ ] **RR6-S5: Commit** + +```bash +git add src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR6 — IndoorCellStencilPipeline 3-bit + occlusion-query + +Extends the dormant single-bit stencil pipeline with WB Step 5 primitives: + + MarkBuildingBit2 — mark stencil bit 2 where bit 1 set + PunchDepthAtStencil3 — depth=1.0 at intersection (stencil==3) + EnableOtherBuildingPass — render state for stencil==3 EnvCell pass + ResetBit2 — clear bit 2 between iterations + UploadBuildingPortalMesh — upload a Building.ExitPortalPolygons (vs + cell-based UploadPortalMesh) + +Plus occlusion-query helpers: + EnsureOcclusionQueryId — lazy GenQuery + TryReadOcclusionResult — asynchronous read-back (no CPU stall) + BeginOcclusionQuery — BeginQuery wrapper + EndOcclusionQuery — EndQuery wrapper + +All GL state sequences mirror WB VisibilityManager.cs:73-239 line-by-line. +Comments reference the corresponding WB line numbers for verification. + +Consumed by RR7's Steps 1-4 + RR9's Step 5. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR7: Restructure render frame to WB-faithful Steps 1-4 + outdoor branch + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — render-frame block (lines ~7000-7300 post-revert) +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `glClear` at frame start (~line 6900): add `StencilBufferBit` + +This is the meaty integration task. After RR1's reverts, GameWindow.cs is at pre-A8 shape plus R1+R2+[vis] probe additions. + +- [ ] **RR7-S1: Add stencil clear to per-frame `glClear`** + +Find line 6900: + +```csharp +_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); +``` + +Change to: + +```csharp +// Phase A8 RR7 (2026-05-26): stencil buffer cleared per-frame now that the +// stencil pipeline is wired in. Previously not cleared because no rendering +// consumed stencil. Without this clear, bits from a prior frame's indoor +// branch could leak into an outdoor frame and gate visibility incorrectly. +_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit); +``` + +- [ ] **RR7-S2: Compute the single `cameraInsideBuilding` gate** + +Locate the existing `cameraInsideCell` declaration (line ~7012 in the post-RR1 file). Below it, add the new gate flag (no replacement of the old flag yet — sky/weather still reference it via outer code paths that don't change in this task): + +```csharp + // Phase A8 RR7 (2026-05-26): the single source of truth for + // "render the indoor branch." Strict PointInCell + the camera's + // cell must actually belong to a Building (not an outdoor cell + // or untagged dungeon cell). No grace. + // + // sky pre-scene, terrain, stencil pipeline, weather post-scene + // all gate on this flag below. The lenient cameraInsideCell flag + // is kept ONLY for the [vis] probe's side-by-side logging. + bool cameraInsideBuilding = visibility?.CameraCell is not null + && CellVisibility.PointInCell(camPos, visibility.CameraCell) + && visibility.CameraCell.BuildingId is not null; +``` + +- [ ] **RR7-S3: Look up camera-buildings and other-buildings** + +Below the gate, add: + +```csharp + // Phase A8 RR7: look up the buildings containing the camera + other + // visible buildings (Step 5 deferred to RR9; here we just compute + // camBuildings for Steps 1-4). + // + // The BuildingRegistry comes from the LoadedLandblock the camera + // is in. landblock lookup: same pattern as the dispatcher's per- + // landblock-entry walk. For now, we fetch it via the worldState. + IReadOnlyList camBuildings = System.Array.Empty(); + if (cameraInsideBuilding && visibility?.CameraCell is not null) + { + // Find the landblock owning the camera cell. + uint cameraLandblockId = visibility.CameraCell.CellId & 0xFFFF0000u; + var lbEntry = _worldState.LandblockEntries + .FirstOrDefault(e => e.LandblockId == cameraLandblockId); + if (lbEntry?.BuildingRegistry is not null) + { + camBuildings = lbEntry.BuildingRegistry.GetBuildingsContainingCell(visibility.CameraCell.CellId); + } + } +``` + +The `LandblockEntry.BuildingRegistry` field is set in RR4. The lookup pattern above assumes `_worldState.LandblockEntries` is an `IEnumerable`. Adjust to whatever the actual type/lookup mechanism is. + +- [ ] **RR7-S4: Gate sky pre-scene on `!cameraInsideBuilding`** + +Find the sky pre-scene block (currently `if (!cameraInsideCell)`): + +```csharp + if (!cameraInsideCell) + { + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + } +``` + +Replace with: + +```csharp + if (!cameraInsideBuilding) + { + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + } +``` + +- [ ] **RR7-S5: Gate initial terrain on `!cameraInsideBuilding`** + +Find the terrain draw block (currently unconditional at line ~7115): + +```csharp + _terrainCpuStopwatch.Restart(); + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + _terrainCpuStopwatch.Stop(); +``` + +Replace with: + +```csharp + // Phase A8 RR7: skip initial terrain when inside a building. + // Terrain renders stencil-gated in Step 4 (line ~7XXX below). + _terrainCpuStopwatch.Restart(); + if (!cameraInsideBuilding) + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + _terrainCpuStopwatch.Stop(); +``` + +- [ ] **RR7-S6: Add indoor / outdoor branch (Steps 1-4 only; Step 5 = RR9, RenderOutsideIn = RR11)** + +Find the existing dispatcher call (`_wbDrawDispatcher!.Draw(...)` — single call with `EntitySet.All`). Replace it with the if/else branching version below. + +```csharp + // Phase A8 RR7 (2026-05-26): indoor / outdoor branch. + // + // Indoor (cameraInsideBuilding == true) — WB RenderInsideOut Steps 1-4: + // MarkAndPunch on camBuildings' exit portals + // IndoorPass (cell mesh + statics + building shells) + // EnableOutdoorPass + // Stencil-gated sky (acdream enhancement) + // Stencil-gated terrain re-draw + // Stencil-gated OutdoorScenery + // DisableStencil + // LiveDynamic + // + // Outdoor (cameraInsideBuilding == false): + // Unchanged from pre-A8: single Draw(All). + // RenderOutsideIn (looking INTO cottage windows from outside) + // ships in RR11. + + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) animatedIds.Add(k); + } + + if (cameraInsideBuilding && _indoorStencilPipeline is not null + && visibility?.CameraCell is not null + && camBuildings.Count > 0) + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) + Console.WriteLine($"[vis] branch=indoor buildings={camBuildings.Count}"); + + // Steps 1+2: stencil bit 1 + far-depth at the camera-buildings' + // exit portals. Combine polygons from all containing buildings + // (usually 1, occasionally 2 in shared-cell scenarios). + int totalVerts = 0; + foreach (var b in camBuildings) + totalVerts += _indoorStencilPipeline.UploadBuildingPortalMesh(b); + // Note: UploadBuildingPortalMesh overwrites the VBO each call. + // For multi-building cases, the last call wins. Loop-uploading + // all polygons would require a different API; for now we use + // the existing pipeline pattern (mark+punch in one call), which + // matches WB's per-building iteration. See RR9 for the multi- + // building marking pattern. + + var viewProjection = camera.View * camera.Projection; + _indoorStencilPipeline.MarkAndPunch(viewProjection); + + // Step 3: IndoorPass with camera-buildings' cell scope. + var camCellIds = new HashSet(); + foreach (var b in camBuildings) + foreach (var cid in b.EnvCellIds) camCellIds.Add(cid); + + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + cellIds: camCellIds, + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass); + + // Step 4: stencil-gated outdoor pass. + _indoorStencilPipeline.EnableOutdoorPass(); + + // Step 4a: stencil-gated sky (acdream enhancement). + // DepthMask off so sky color writes through punched depth=1.0 + // without disturbing the depth buffer; depth stays at the punch + // value so the next step's terrain re-draw can win. + _gl!.DepthMask(false); + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + _gl!.DepthMask(true); + + // Step 4b: stencil-gated terrain re-draw. + _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); + + // Step 4c: stencil-gated OutdoorScenery. + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility.VisibleCellIds, + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery); + + // (Step 5 = RR9.) + + _indoorStencilPipeline.DisableStencil(); + + // LiveDynamic — player, NPCs, dropped items. + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility.VisibleCellIds, + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic); + } + else + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) + Console.WriteLine("[vis] branch=outdoor"); + + // Outdoor: single Draw(All). (RR11 adds RenderOutsideIn for + // cottage interiors visible through windows from outside.) + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + } +``` + +- [ ] **RR7-S7: Gate weather post-scene on `!cameraInsideBuilding`** + +Find the weather post-scene block (~line 7260): + +```csharp + if (!cameraInsideCell) + { + _skyRenderer?.RenderWeather(...); + ... + } +``` + +Replace `cameraInsideCell` with `cameraInsideBuilding` in the gate: + +```csharp + if (!cameraInsideBuilding) + { + _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); + } +``` + +- [ ] **RR7-S8: Build green** + +```bash +dotnet build -c Debug --nologo +``` + +Expected: green. If `IndoorCellStencilPipeline.UploadBuildingPortalMesh` doesn't return an int (signature mismatch from RR6), reconcile. If the `LandblockEntry.BuildingRegistry` field name is different, adjust. + +- [ ] **RR7-S9: Run all tests** + +```bash +dotnet test --nologo +``` + +Expected: failures within documented flaky window. No NEW failures attributable to the restructure. + +- [ ] **RR7-S10: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR7 — WB RenderInsideOut Steps 1-4 + outdoor branch + +Replaces the post-revert pre-A8 render frame with WB's RenderInsideOut +Steps 1-4 (Step 5 = RR9, RenderOutsideIn = RR11): + + Indoor (cameraInsideBuilding == true): + 1+2. MarkAndPunch on camera-buildings' exit portals + 3. IndoorPass — cell scope = camBuildings.SelectMany(EnvCellIds) + (no BFS-wide cell render → fixes Issues A + C) + 4a. Stencil-gated sky (DepthMask off; acdream enhancement) + 4b. Stencil-gated terrain re-draw + 4c. Stencil-gated OutdoorScenery + 5. (RR9 — placeholder) + 6. DisableStencil + 7. LiveDynamic + + Outdoor (cameraInsideBuilding == false): + Single Draw(All) — unchanged pre-A8 shape. (RR11 adds RenderOutsideIn.) + +New cameraInsideBuilding gate is STRICT (PointInCell + BuildingId not +null). No grace mechanism for the render path; the cell-side grace in +CellVisibility.FindCameraCell stays alive for non-render consumers. + +Frame-start glClear now includes StencilBufferBit (was Color+Depth only) +— necessary now that stencil is consumed. + +Sky pre-scene + initial terrain + weather post-scene gates all switched +to !cameraInsideBuilding from !cameraInsideCell. The legacy +cameraInsideCell stays only for the [vis] probe's side-by-side logging. + +Visual verification at RR8. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR8: Visual verification gate — Steps 1-4 + +**Files:** +- Create: `docs/research/2026-05-26-a8-rr8-steps-1-4-visual.md` + +No production code in this task — it's a visual gate. Steps 1-4 must close #78 + R4 Issues A + C BEFORE we add Step 5 complexity. + +- [ ] **RR8-S1: Build** + +```bash +dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo +``` + +Expected: green. + +- [ ] **RR8-S2: Launch with [vis] probe** + +```powershell +$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue +if ($proc) { $proc.CloseMainWindow() | Out-Null; if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force } } +Start-Sleep -Seconds 3 + +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_PROBE_VIS = "1" + +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | + Tee-Object -FilePath "launch-a8-rr8-verify.log" +``` + +(Foreground or background depending on tool support.) + +- [ ] **RR8-S3: Scenario A — Holtburg cottage interior (ground floor)** + +Walk into a cottage. Stand inside, rotate camera. + +Acceptance: +- All walls SOLID +- **No transparent floor showing cellar** (Issue C must NOT reproduce) +- Sky visible through windows +- Player character body visible (no head-backwards, no missing limbs) + +Record PASS / FAIL with notes. + +- [ ] **RR8-S4: Scenario B — Holtburg cottage cellar** + +Walk down to a cellar. + +Acceptance: +- Cellar walls + floor + ceiling SOLID +- Cellar stairs SOLID from inside view; no grass overlay +- Cottage floor (from cellar looking up) SOLID + +Record PASS / FAIL. + +- [ ] **RR8-S5: Scenario C — Holtburg Inn (multi-room)** + +Walk into the inn; move between rooms. + +Acceptance: +- All inn walls SOLID +- Adjacent rooms NOT visible through walls +- Sky through inn windows +- Furniture visible + +Record PASS / FAIL. + +- [ ] **RR8-S6: Scenario D — A dungeon** + +Reach any dungeon via network portal. + +Acceptance: +- Corridor walls SOLID +- Indoor lighting active +- No outdoor leak + +Record PASS / FAIL. + +- [ ] **RR8-S7: Transitions — exit + entry** + +Walk through cottage doorway several times. + +Acceptance: +- Exit (indoor→outdoor): clean; **no through-ground objects** (Issue A must NOT reproduce); no missing walls on adjacent cottages +- Entry (outdoor→indoor): clean; **no transparent floor** flicker + +Record PASS / FAIL with notes. + +- [ ] **RR8-S8: Graceful close + write findings** + +```powershell +$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue +if ($proc) { $proc.CloseMainWindow() | Out-Null; if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force } } +``` + +Create `docs/research/2026-05-26-a8-rr8-steps-1-4-visual.md` recording each scenario's outcome. + +Template: + +```markdown +# A8 RR8 visual verification — Steps 1-4 outcome + +**Date:** 2026-05-2X +**HEAD:** [RR7 commit SHA] + +## Scenario outcomes + +| Scenario | Acceptance | Outcome | Notes | +|---|---|---|---| +| A: cottage interior | walls solid + sky through windows + no transparent floor | PASS / FAIL | | +| B: cellar | walls solid + stairs solid | PASS / FAIL | | +| C: inn (multi-room) | walls solid + no cross-room leak | PASS / FAIL | | +| D: dungeon | walls solid + indoor lighting | PASS / FAIL | | +| Exit transition | clean; no through-ground; no missing walls | PASS / FAIL | | +| Entry transition | clean; no transparent floor | PASS / FAIL | | + +## Closures +- [ ] #78 closed +- [ ] R4 Issue A closed +- [ ] R4 Issue C closed + +## Gate decision +- [ ] All scenarios PASS → proceed to RR9 (Step 5) +- [ ] Any FAIL → STOP, debug RR7 implementation +``` + +Commit: + +```bash +git add docs/research/2026-05-26-a8-rr8-steps-1-4-visual.md +git commit -m "$(cat <<'EOF' +docs(research): Phase A8 RR8 — Steps 1-4 visual verification outcome + +Recorded scenario outcomes for cottage / cellar / inn / dungeon + entry/exit +transitions. Gate decision for RR9 (Step 5) ship-or-debug. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **RR8-S9: Gate decision** + +If all building-type scenarios + both transitions PASS → proceed to RR9 (Step 5). + +If any fail → STOP. Use `/investigate` skill or fresh diagnostic to root-cause before RR9 (Step 5 amplifies bugs; no point adding it on broken Steps 1-4). + +--- + +## Task RR9: Implement Step 5 — cross-building visibility + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — extend indoor branch with Step 5 loop +- (No new tests — visual verification only.) + +WB's Step 5: for each "other building" (visible in frustum but NOT containing camera), mark stencil bit 2 at its portals where bit 1 is set, clear depth at intersection, render that building's interior at stencil==3, reset bit 2. + +- [ ] **RR9-S1: Locate the Step 5 insertion point** + +In `src/AcDream.App/Rendering/GameWindow.cs`, find the comment `// (Step 5 = RR9.)` added in RR7 (between the stencil-gated OutdoorScenery call and `_indoorStencilPipeline.DisableStencil();`). This is where Step 5 inserts. + +- [ ] **RR9-S2: Compute otherBuildings** + +Replace the `// (Step 5 = RR9.)` comment with: + +```csharp + // Step 5 (WB VisibilityManager.cs:156-232): render other + // visible buildings' interiors through the camera-building's + // portals via 3-stencil-bit pipeline. + // + // Compute otherBuildings: registered in the same landblock as + // the camera-buildings, NOT containing the camera, that have + // exit portals visible in the frustum (best-effort by AABB). + var otherBuildings = new List(); + if (visibility?.CameraCell is not null) + { + uint cameraLandblockId = visibility.CameraCell.CellId & 0xFFFF0000u; + var lbEntry = _worldState.LandblockEntries + .FirstOrDefault(e => e.LandblockId == cameraLandblockId); + if (lbEntry?.BuildingRegistry is not null) + { + foreach (var b in lbEntry.BuildingRegistry.All()) + { + if (camBuildings.Contains(b)) continue; + if (b.ExitPortalPolygons.Count == 0) continue; + // Coarse frustum test: at least one portal polygon's + // first vertex inside the frustum. Cheap. WB does a + // more rigorous test but this is sufficient for first + // ship — refine if visibly missing buildings. + bool any = false; + foreach (var poly in b.ExitPortalPolygons) + { + if (poly.Length > 0 && frustum.ContainsPoint(poly[0])) { any = true; break; } + } + if (any) otherBuildings.Add(b); + } + } + } + + // Step 5 loop — per WB VisibilityManager.cs:170-228. + foreach (var building in otherBuildings) + { + // a. Read prev-frame occlusion query result. + if (_indoorStencilPipeline.TryReadOcclusionResult(building.QueryId, out bool wasVisible)) + building.WasVisible = wasVisible; + if (!building.QueryStarted) building.WasVisible = true; // first frame: render speculatively + + // b. Allocate query id + begin a new query (replaces last frame's). + var qid = _indoorStencilPipeline.EnsureOcclusionQueryId(ref building.QueryId); + _indoorStencilPipeline.BeginOcclusionQuery(qid); + building.QueryStarted = true; + + // c. Mark stencil bit 2 at this building's portals (where bit 1 set). + int vCount = _indoorStencilPipeline.UploadBuildingPortalMesh(building); + _indoorStencilPipeline.MarkBuildingBit2(viewProjection, vCount); + + _indoorStencilPipeline.EndOcclusionQuery(); + + // d. Punch depth where stencil == 3 (clear our building's interior wall + // depth at the intersection so other-building's interior wins). + _indoorStencilPipeline.PunchDepthAtStencil3(viewProjection, vCount); + + // e. Render this other-building's EnvCells where stencil == 3. + _indoorStencilPipeline.EnableOtherBuildingPass(); + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + cellIds: building.EnvCellIds, + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass); + + // f. Reset bit 2 for the next iteration. + _indoorStencilPipeline.ResetBit2(viewProjection, vCount); + } +``` + +The `frustum.ContainsPoint` method assumes the existing `AcDream.Core.Camera.Frustum` API. Replace with whatever the actual frustum-test method is in scope. + +- [ ] **RR9-S3: Build green** + +```bash +dotnet build -c Debug --nologo +``` + +Expected: green. Any compile errors are mostly API-name mismatches between the Step 5 code above and the actual pipeline / dispatcher methods. + +- [ ] **RR9-S4: Run all tests** + +```bash +dotnet test --nologo +``` + +Expected: failures within flaky window. (Step 5 is GL integration; no new unit tests.) + +- [ ] **RR9-S5: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR9 — Step 5 cross-building visibility (3-bit stencil) + +Implements WB VisibilityManager.cs:156-232 verbatim: + +For each visible building NOT containing the camera (otherBuildings): + a. Read prev-frame occlusion query result (asynchronous, no CPU stall) + b. Begin new occlusion query + c. Mark stencil bit 2 at the building's exit portals (where bit 1 set) + d. Punch depth=1.0 at stencil==3 (clear camera-building's interior-wall + depth at the intersection silhouette so other-building's interior + wins depth) + e. Render other-building's EnvCells where stencil == 3 + f. Reset bit 2 for the next iteration + +otherBuildings collected from same landblock's BuildingRegistry, filtered +by: + - Not containing camera + - At least one exit-portal vertex inside frustum (coarse test) + +Visual verification at RR10. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR10: Visual verification gate — Step 5 + +**Files:** +- Create: `docs/research/2026-05-26-a8-rr10-step-5-visual.md` + +- [ ] **RR10-S1: Build + launch (same script as RR8-S2)** + +```bash +dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo +``` + +Then the same PowerShell launch sequence as RR8-S2 with log path `launch-a8-rr10-verify.log`. + +- [ ] **RR10-S2: Scenario — cross-building view** + +Walk into the Holtburg Inn. Stand at a window facing outward. Look across the street/courtyard at the cottages with windows facing you. + +Acceptance: +- Cottage interiors visible through cottage's window through inn's window (or any cross-building configuration with portal alignment) +- No flicker / no missing geometry from Step 5 iterations + +Record PASS / FAIL. + +- [ ] **RR10-S3: Regression check — Steps 1-4 still good** + +Re-run RR8's Scenarios A/B/C/D + transitions to confirm Step 5 hasn't broken them. + +- [ ] **RR10-S4: Graceful close + write findings + commit** + +Template similar to RR8-S8. Findings + gate decision. + +```bash +git add docs/research/2026-05-26-a8-rr10-step-5-visual.md +git commit -m "$(cat <<'EOF' +docs(research): Phase A8 RR10 — Step 5 visual verification outcome + +Cross-building visibility verified at Holtburg Inn → cottages. +Regression check on Steps 1-4. Gate decision for RR11. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **RR10-S5: Gate decision** + +If Step 5 works and Steps 1-4 didn't regress → proceed to RR11. + +If Step 5 broken but Steps 1-4 still good → mark Step 5 as a future polish phase, proceed to RR11+RR12 with Step 5 known-broken (file ISSUES.md entry). + +If Steps 1-4 regressed → STOP, debug Step 5's interaction with the earlier steps. + +--- + +## Task RR11: Implement RenderOutsideIn (outdoor camera → cottage interiors through windows) + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — outdoor branch addition (before the `Draw(set: All)` call) + +WB's `RenderOutsideIn` (`VisibilityManager.cs:241+`): for each visible building (in frustum), stencil-mark its exit portals + render its interior cells through stencil. Single stencil bit (no Step 5 complexity). + +- [ ] **RR11-S1: Locate the outdoor branch + add RenderOutsideIn** + +In `src/AcDream.App/Rendering/GameWindow.cs`, find the outdoor `else` branch from RR7. Inside it, BEFORE the existing `Draw(set: All)` call, add the RenderOutsideIn loop: + +```csharp + else + { + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) + Console.WriteLine("[vis] branch=outdoor"); + + // Phase A8 RR11 (2026-05-26): RenderOutsideIn — show cottage + // interiors through windows from the outside camera. + // Per WB VisibilityManager.cs:241-310 (RenderOutsideIn method). + // + // For each visible building in the camera's landblock, stencil- + // mark its exit portals + render its EnvCells through stencil. + // Single-bit stencil (no Step 5 cross-building complexity). + if (_indoorStencilPipeline is not null) + { + // Collect outsideBuildings: all buildings in any visible + // landblock with at least one portal visible in frustum. + var outsideBuildings = new List(); + foreach (var lbEntry in _worldState.LandblockEntries) + { + if (lbEntry.BuildingRegistry is null) continue; + foreach (var b in lbEntry.BuildingRegistry.All()) + { + if (b.ExitPortalPolygons.Count == 0) continue; + bool any = false; + foreach (var poly in b.ExitPortalPolygons) + if (poly.Length > 0 && frustum.ContainsPoint(poly[0])) { any = true; break; } + if (any) outsideBuildings.Add(b); + } + } + + var vp = camera.View * camera.Projection; + foreach (var b in outsideBuildings) + { + // Occlusion query — same lifecycle as Step 5. + if (_indoorStencilPipeline.TryReadOcclusionResult(b.QueryId, out bool wasVisible)) + b.WasVisible = wasVisible; + if (!b.QueryStarted) b.WasVisible = true; + + var qid = _indoorStencilPipeline.EnsureOcclusionQueryId(ref b.QueryId); + _indoorStencilPipeline.BeginOcclusionQuery(qid); + b.QueryStarted = true; + + int v = _indoorStencilPipeline.UploadBuildingPortalMesh(b); + // Single-bit MarkAndPunch — same call as the indoor branch. + _indoorStencilPipeline.MarkAndPunch(vp); + + _indoorStencilPipeline.EndOcclusionQuery(); + + // Render interior cells where stencil == 1. + _indoorStencilPipeline.EnableOutdoorPass(); + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + cellIds: b.EnvCellIds, + animatedEntityIds: animatedIds, + set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass); + + // Reset stencil between buildings (the next building re-marks + // its own portals which overwrites). DisableStencil here to be + // safe; the outer Draw(All) below assumes stencil off. + _indoorStencilPipeline.DisableStencil(); + } + } + + // Existing Draw(All) — outdoor entities (trees, scenery, dynamic). + _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, + neverCullLandblockId: playerLb, + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); + } +``` + +The `frustum.ContainsPoint` test (same as RR9) needs to match the actual frustum API. + +- [ ] **RR11-S2: Build + test green** + +```bash +dotnet build -c Debug --nologo +dotnet test --nologo +``` + +Expected: green; tests within flaky window. + +- [ ] **RR11-S3: Visual verification — looking into windows from outside** + +Build + launch (same script as RR8-S2). Stand outside a cottage, look at one of its windows. Check whether you can see the cottage interior (walls, furniture, NPC if any) through the window. + +Acceptance: +- Cottage interior visible through cottage window when looking from outside +- No regression on indoor-from-inside view (RR8 scenarios) +- No regression on Step 5 cross-building (RR10 scenario) + +Close client when done. + +- [ ] **RR11-S4: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 RR11 — RenderOutsideIn (cottage interiors through windows) + +Implements WB VisibilityManager.cs:241-310 (RenderOutsideIn) in the outdoor +branch. For each visible building in the camera's landblock with portals in +the frustum: + + - Read prev-frame occlusion query (asynchronous) + - Begin new query + - MarkAndPunch at building's exit portals + - End query + - Render building's EnvCells through stencil == 1 (IndoorPass) + - DisableStencil between buildings + +Visual: standing on the cobblestone outside a Holtburg cottage and looking +at its window now shows the cottage's interior through the window (walls, +furniture, NPCs inside). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task RR12: Final visual matrix + ship docs + +**Files:** +- Modify: `docs/ISSUES.md` +- Modify: `CLAUDE.md` +- Create: `docs/research/2026-05-26-a8-rr12-final-visual.md` + +- [ ] **RR12-S1: Run the FULL visual matrix one more time** + +Build + launch with `[vis]` probe. + +Re-walk the full matrix from the design doc's testing strategy: +- Cottage interior (ground floor) — walls solid + sky through windows + no transparent floor +- Cottage cellar — walls solid + stairs solid from inside +- Holtburg Inn — walls solid + no cross-room leak + sky through windows +- Dungeon — walls solid + indoor lighting + no terrain leak +- Exit transition — clean; no through-ground; no missing walls +- Entry transition — clean; no transparent floor +- Cross-building (Step 5) — inn→cottage interior across street +- Looking-into-windows (RenderOutsideIn) — cottage interior visible from outside +- No regression on #100 — no transparent rectangles around cottages + +- [ ] **RR12-S2: Write final visual findings** + +Create `docs/research/2026-05-26-a8-rr12-final-visual.md` with PASS/FAIL per scenario + screenshots. + +```bash +git add docs/research/2026-05-26-a8-rr12-final-visual.md +git commit -m "$(cat <<'EOF' +docs(research): Phase A8 RR12 — final visual verification matrix + +Full scenario matrix passed. M1.5 indoor world feels right. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **RR12-S3: Check next available ISSUES.md ID** + +```bash +grep -n "^## #1[0-9][0-9]" docs/ISSUES.md +``` + +Confirm highest existing #1xx. (Was #101 at design time.) + +- [ ] **RR12-S4: Move #78 to Recently closed** + +Find #78 in `docs/ISSUES.md`. Move it to the "Recently closed" section: + +```markdown +**#78 — Outdoor stabs/buildings visible through the rendered floor** — CLOSED 2026-05-2X by Phase A8 full WB port (commits ed72704 → [RR11 SHA]). Per-building cell scoping via BuildingRegistry; WB RenderInsideOut Steps 1-5 + RenderOutsideIn. Visual-verified at Holtburg cottage interior, cellar, inn, and dungeon; sky visible through windows; cross-building visibility (Step 5); cottage interiors visible from outside (RenderOutsideIn). +``` + +- [ ] **RR12-S5: Close #102 (cross-cell-portal Step 5 deferral, subsumed)** + +If #102 was filed earlier (per the original A8 plan's RR5), move it to Recently closed: + +```markdown +**#102 — Far-side portal visibility through walls (WB Step 5 deferral)** — CLOSED 2026-05-2X by Phase A8 RR9 (Step 5 cross-building visibility implemented). Per WB VisibilityManager.cs:156-232. +``` + +If #102 was NOT filed (because we never reached RR5 of the prior plan), skip this step. + +- [ ] **RR12-S6: File any new issues discovered in the visual matrix** + +If RR12-S1 found any minor visual glitches not addressed, file them as next-ID OPEN issues (e.g. #104, #105). Examples: +- Edge cases where Step 5's occlusion query misses a fast-camera-rotation building +- RenderOutsideIn buildings popping in/out at landblock streaming boundary + +- [ ] **RR12-S7: Update CLAUDE.md A8 paragraph** + +Find the current A8 paragraph in `CLAUDE.md` (begins with "**Phase A8 —" or similar; locate via grep). Replace with: + +```markdown +**Phase A8 — Indoor-cell visibility culling — SHIPPED 2026-05-2X.** +Closes issue #78 + #102. Full WorldBuilder RenderInsideOut + RenderOutsideIn +port (eleven implementation tasks RR1–RR11): + +- R1+R2 (ed72704, 55f26f2) — WorldEntity.IsBuildingShell + EntitySet partition. + Earlier ship; kept as orthogonal infrastructure. +- RR1 — cleanup: commit [vis] probe; revert R3+R3.5 v1+v2 (failed half-port). +- RR2 — spike: confirmed LandBlockInfo.Buildings data shape + interior-portal + walk algorithm against WB PortalRenderManager:518-551. +- RR3+RR4 — per-landblock data model: Building, BuildingRegistry, + BuildingLoader. LoadedCell.BuildingId stamped at load. O(1) lookups + in both directions. +- RR5 — WbDrawDispatcher.Draw(cellIds:) overload for per-building cell + scoping. +- RR6 — IndoorCellStencilPipeline extended with 3-bit mode + occlusion- + query helpers + per-building portal upload. +- RR7 — render frame restructured to WB RenderInsideOut Steps 1-4 + + stencil-gated sky (acdream enhancement); single strict + cameraInsideBuilding gate; sky/terrain skipped when inside; no + depth-clear; LiveDynamic last. +- RR8 — visual verification gate: Steps 1-4 closed #78 + R4 Issues A+C. +- RR9 — Step 5 (cross-building 3-stencil-bit pipeline + occlusion queries) + per WB VisibilityManager.cs:156-232 verbatim. +- RR10 — visual verification gate: Step 5 working. +- RR11 — RenderOutsideIn (cottage interiors through windows from outside) + per WB VisibilityManager.cs:241-310. + +Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg Inn +(multi-room), and dungeon. Sky visible through windows. Cross-building +visibility (Step 5) working at inn→cottage. Cottage interiors visible from +outside (RenderOutsideIn). No regression on #100 (terrain cutout). + +Full design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md. +Full plan: docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md. +RR0 falsification findings: docs/research/2026-05-26-a8-rr0-falsification-findings.md. +RR2 data-shape findings: docs/research/2026-05-26-a8-buildings-data-shape.md. +``` + +Also update the "Currently working toward" line and any other A8 references in the Milestone Discipline section to reflect ship status. + +- [ ] **RR12-S8: Commit ship docs** + +```bash +git add docs/ISSUES.md CLAUDE.md +git commit -m "$(cat <<'EOF' +ship(render): Phase A8 — full WB RenderInsideOut + RenderOutsideIn SHIPPED + +Closes #78 + #102. Files any new issues discovered during RR12 visual +verification. + +Architecture: per-building cell scoping (Building + BuildingRegistry + +BuildingLoader, with LoadedCell.BuildingId stamped at load) drives WB's +RenderInsideOut Steps 1-5 indoor pipeline + RenderOutsideIn outdoor-look- +into-buildings pipeline. Single strict cameraInsideBuilding gate; no +grace mechanism for the render path; stencil-gated sky inside indoor +branch (acdream enhancement). + +Visual-verified at Holtburg cottage interior + cellar, Holtburg Inn, +dungeon. Sky through windows. Cross-building Step 5 working. Cottage +interiors visible from outside. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-review + +**Spec coverage:** +- [x] Q1 (Step 5 scope: full) → RR9 implements +- [x] Q2 (data model: Option C cell field + registry) → RR3 ships both indexes + RR4 stamps cells +- [x] Q3 (RenderOutsideIn: include) → RR11 implements +- [x] WB-faithful restructure (skip initial sky+terrain, no depth-clear, single strict gate) → RR7 +- [x] Stencil-gated sky (acdream enhancement) → RR7-S6 Step 4a +- [x] Revert R3+R3.5 v1+v2 → RR1-S3 through RR1-S6 +- [x] Keep R1+R2 → RR1 doesn't touch them +- [x] Pre-flight spike before implementation → RR2 +- [x] Visual gates at appropriate points → RR8, RR10, RR12 +- [x] Supersede old docs → RR1-S8, RR1-S9 +- [x] Ship docs (close #78, update CLAUDE.md) → RR12 + +**Placeholder scan:** no "TBD" / "implement later" / "similar to" strings. RR2's spike has a CONCRETE deliverable (findings doc with 5 named sections). RR3's tests reference field names from RR2's findings, which is the right factoring (data shape resolved before tests are written/run). + +**Type consistency:** +- `cameraInsideBuilding` used consistently (RR7 introduces; not used before). +- `Building`, `BuildingRegistry`, `BuildingLoader` names match across RR3 (definition) + RR4 (consumption) + RR7+RR9+RR11 (render-frame use). +- `LoadedCell.BuildingId` (init + internal set) defined in RR4-S1; consumed in RR7-S2 (gate uses `BuildingId is not null`); referenced (mutated) only by BuildingLoader. +- `LandblockEntry.BuildingRegistry` defined in RR4-S8; consumed in RR7-S3 + RR9-S2 + RR11-S1. +- `IndoorCellStencilPipeline` methods: existing (`UploadPortalMesh`, `MarkAndPunch`, `EnableOutdoorPass`, `DisableStencil`) + RR6 additions (`UploadBuildingPortalMesh`, `MarkBuildingBit2`, `PunchDepthAtStencil3`, `EnableOtherBuildingPass`, `ResetBit2`, `EnsureOcclusionQueryId`, `TryReadOcclusionResult`, `BeginOcclusionQuery`, `EndOcclusionQuery`). All referenced consistently. +- `WbDrawDispatcher` overloads: existing (`Draw(... visibleCellIds: ..., set: All)`) + RR5 addition (`Draw(... cellIds: ..., set: ...)`). RR7+RR9+RR11 use the new `cellIds:` overload exclusively for indoor scoping. + +**Task granularity:** RR1 = 10 steps (mechanical); RR2 = 6 (research); RR3 = 14 (TDD + impl); RR4 = 11; RR5 = 7; RR6 = 5; RR7 = 10; RR8 = 9 (visual); RR9 = 5; RR10 = 5 (visual); RR11 = 4; RR12 = 8. Total 94 step checkboxes across 12 tasks. Each step 2-5 min except visual scenarios (longer). + +**Estimated total: 8-10 sessions** (1.5-2 weeks calendar), matching the design doc estimate. + +**Risk callouts inline:** RR2's gate decision (if data shape incompatible); RR3-S12's note on field-name adaptation; RR8/RR10's gate decisions; RR9/RR11's frustum-API adaptation.