acdream/docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md
Erik 651e7e22fb docs(plan): Phase A8 — full WB RenderInsideOut + RenderOutsideIn port plan
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) <noreply@anthropic.com>
2026-05-27 09:57:45 +02:00

2512 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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<uint>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<uint> { 0xA9B40150u, 0xA9B40151u },
ExitPortalPolygons = new List<Vector3[]>
{
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<uint>(),
ExitPortalPolygons = new List<Vector3[]>(),
};
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;
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
public sealed class Building
{
/// <summary>Unique within a landblock; allocated sequentially by BuildingLoader.</summary>
public required uint BuildingId { get; init; }
/// <summary>The EnvCells this building owns. Includes all cells reachable
/// from the building's entry portals via interior portals (no exit portals).</summary>
public required HashSet<uint> EnvCellIds { get; init; }
/// <summary>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.</summary>
public required IReadOnlyList<Vector3[]> 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<uint>(cellIds),
ExitPortalPolygons = new List<Vector3[]>(),
};
[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<uint>();
foreach (var b in reg.All()) ids.Add(b.BuildingId);
Assert.Equal(new HashSet<uint> { 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;
/// <summary>
/// Phase A8 (2026-05-26): per-landblock registry of <see cref="Building"/>s.
/// Two-way indexed for O(1) cell→building and building-id→building lookups.
/// Built once per landblock at load time by <see cref="BuildingLoader"/>;
/// no mutations after.
/// </summary>
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<uint, List<Building>> _byCellId = new();
// Index 2: building-id → Building.
private readonly Dictionary<uint, Building> _byBuildingId = new();
/// <summary>Adds a building to both indexes. Idempotent if the same Building
/// instance is added twice with the same BuildingId.</summary>
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<Building>();
_byCellId[cellId] = list;
}
if (!list.Contains(b)) list.Add(b);
}
}
/// <summary>Returns the buildings containing <paramref name="cellId"/>.
/// Empty list when the cell isn't part of any building (outdoor cells,
/// dungeon cells not tagged by LandBlockInfo.Buildings).</summary>
public IReadOnlyList<Building> GetBuildingsContainingCell(uint cellId) =>
_byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty<Building>();
/// <summary>Returns the building with the given id, or null.</summary>
public Building? GetById(uint buildingId) =>
_byBuildingId.TryGetValue(buildingId, out var b) ? b : null;
/// <summary>Enumerates every registered building.</summary>
public IEnumerable<Building> All() => _byBuildingId.Values;
/// <summary>Number of registered buildings.</summary>
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<BuildingInfo>();
foreach (var (modelId, portals) in buildings)
{
var portalList = new List<BldPortal>();
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<Stab>(),
Buildings = bls,
};
}
[Fact]
public void Empty_NoBuildings_EmptyRegistry()
{
var info = new LandBlockInfo { Objects = new List<Stab>(), Buildings = new List<BuildingInfo>() };
var reg = BuildingLoader.Build(info, landblockId: 0xA9B40000u, cellsByCellId: new Dictionary<uint, AcDream.App.Rendering.LoadedCell>());
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<uint, AcDream.App.Rendering.LoadedCell>());
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<uint, AcDream.App.Rendering.LoadedCell>());
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<uint, AcDream.App.Rendering.LoadedCell>());
Assert.Equal(2, reg.Count);
var ids = new SortedSet<uint>();
foreach (var b in reg.All()) ids.Add(b.BuildingId);
Assert.Equal(new SortedSet<uint> { 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<CBldPortal>` 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;
/// <summary>
/// Phase A8 (2026-05-26): static factory that builds a per-landblock
/// <see cref="BuildingRegistry"/> from a <see cref="LandBlockInfo"/>'s
/// Buildings array. Walks the building's entry portals and (per RR2
/// findings) optionally extends each building's cell set through
/// interior portals.
///
/// <para>Cells in <paramref name="cellsByCellId"/> get their
/// <see cref="LoadedCell.BuildingId"/> set to the matching building id;
/// cells without a matching building stay at <c>BuildingId == null</c>.</para>
/// </summary>
public static class BuildingLoader
{
/// <summary>
/// Builds the registry. Sequential building IDs starting at 1 (id 0
/// reserved for "no building" semantics, but the registry uses
/// <c>uint?</c> on LoadedCell so 0 is a valid value too — start at 1
/// for clarity).
/// </summary>
public static BuildingRegistry Build(
LandBlockInfo info,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> 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<uint>();
var exitPortalPolys = new List<Vector3[]>();
// 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<uint>();
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) <noreply@anthropic.com>
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
/// <summary>
/// Phase A8 (2026-05-26): the building this cell belongs to, if any.
/// Set exactly once by <see cref="Wb.BuildingLoader"/> 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).
///
/// <para>Used by the render frame to derive the camera-buildings set
/// via <see cref="Wb.BuildingRegistry.GetBuildingsContainingCell"/>
/// and route IndoorPass cell scoping.</para>
/// </summary>
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<AcDream.App.Rendering.CellPortalInfo>(),
PortalPolygons = new List<Vector3[]>(),
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
LocalBoundsMin = new Vector3(-5, -5, -5),
LocalBoundsMax = new Vector3(5, 5, 5),
ClipPlanes = new List<AcDream.App.Rendering.CellClipPlane>(),
};
var cell151 = cell150 with { CellId = 0xA9B40151u }; // assumes record-like LoadedCell; if not, build a fresh instance
var cells = new Dictionary<uint, AcDream.App.Rendering.LoadedCell>
{
{ 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
/// <summary>
/// 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).
/// </summary>
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) <noreply@anthropic.com>
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<uint>?`. We need a new overload that takes an explicit `IReadOnlyCollection<uint> 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<MeshRef> { 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<MeshRef> { 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<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero, Rotation = Quaternion.Identity,
};
[Fact]
public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells()
{
var entities = new List<WorldEntity>
{
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<uint> { 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<WorldEntity>
{
CellEnt(0x40000001u, 0xA9B40150u),
BuildingShell(0xC0000001u),
};
var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
entities, new HashSet<uint>(), 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<uint>?`). Add a sibling helper that takes the explicit cell list:
```csharp
/// <summary>
/// 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).
///
/// <para>Indoor entities (ParentCellId set) gated by membership in
/// <paramref name="cellIds"/>. Outdoor entities follow the EntitySet
/// partition only (no cell-list gate).</para>
/// </summary>
public static List<uint> WalkEntitiesForTestByCellIds(
IEnumerable<AcDream.Core.World.WorldEntity> entities,
IReadOnlyCollection<uint> cellIds,
EntitySet set)
{
var result = new List<uint>();
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
/// <summary>
/// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only
/// entities whose ParentCellId is in <paramref name="cellIds"/>, plus
/// outdoor-style entities matching the EntitySet partition. Used by
/// the indoor render branch to scope rendering to the camera-buildings'
/// cells.
///
/// <para>Mirrors the existing visibleCellIds-based Draw but with an
/// explicit cell list (not the BFS-derived visibility set).</para>
/// </summary>
public void Draw(
AcDream.Core.Camera camera,
IEnumerable<LandblockEntry> landblockEntries,
Frustum frustum,
uint? neverCullLandblockId,
IReadOnlyCollection<uint> cellIds,
HashSet<uint>? 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<uint>?.
// We adapt: wrap cellIds in a HashSet if it isn't already, OR extend
// WalkEntitiesInto to take the broader IReadOnlyCollection<uint>.
HashSet<uint> cellIdSet = cellIds is HashSet<uint> hs ? hs : new HashSet<uint>(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<uint>
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) <noreply@anthropic.com>
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<uint>(),
ExitPortalPolygons = new System.Collections.Generic.List<System.Numerics.Vector3[]>(),
};
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
/// <summary>
/// Phase A8 RR6 (2026-05-26): Step 5 — mark stencil bit 2 at portal
/// silhouettes WHERE bit 1 is already set. Subsequent rendering with
/// <see cref="EnableOtherBuildingPass"/> shows that building's interior
/// in the intersection silhouette (stencil == 3).
///
/// <para>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).</para>
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Phase A8 RR6: lazily allocate a GL query object id for occlusion testing.
/// Returns the existing id if already allocated.
/// </summary>
public uint EnsureOcclusionQueryId(ref uint slot)
{
if (slot == 0) slot = _gl.GenQuery();
return slot;
}
/// <summary>
/// Phase A8 RR6: read the previous frame's occlusion query result if
/// available. Asynchronous — avoids CPU stall by checking
/// QueryObjectParameterName.ResultAvailable first.
/// </summary>
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<LoadedCell>`. For Step 5, we need to upload a SPECIFIC building's portal polygons (not all cells'). Add a sibling overload:
```csharp
/// <summary>
/// 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.
/// </summary>
/// <returns>Vertex count uploaded (multiple of 3).</returns>
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<Vector3> 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) <noreply@anthropic.com>
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<AcDream.App.Rendering.Wb.Building> camBuildings = System.Array.Empty<AcDream.App.Rendering.Wb.Building>();
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<LandblockEntry>`. 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<uint>? animatedIds = null;
if (_animatedEntities.Count > 0)
{
animatedIds = new HashSet<uint>(_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<uint>();
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<AcDream.App.Rendering.Wb.Building>();
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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<AcDream.App.Rendering.Wb.Building>();
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 RR1RR11):
- 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) <noreply@anthropic.com>
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.