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>
2512 lines
104 KiB
Markdown
2512 lines
104 KiB
Markdown
# 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 RR1–RR11):
|
||
|
||
- R1+R2 (ed72704, 55f26f2) — WorldEntity.IsBuildingShell + EntitySet partition.
|
||
Earlier ship; kept as orthogonal infrastructure.
|
||
- RR1 — cleanup: commit [vis] probe; revert R3+R3.5 v1+v2 (failed half-port).
|
||
- RR2 — spike: confirmed LandBlockInfo.Buildings data shape + interior-portal
|
||
walk algorithm against WB PortalRenderManager:518-551.
|
||
- RR3+RR4 — per-landblock data model: Building, BuildingRegistry,
|
||
BuildingLoader. LoadedCell.BuildingId stamped at load. O(1) lookups
|
||
in both directions.
|
||
- RR5 — WbDrawDispatcher.Draw(cellIds:) overload for per-building cell
|
||
scoping.
|
||
- RR6 — IndoorCellStencilPipeline extended with 3-bit mode + occlusion-
|
||
query helpers + per-building portal upload.
|
||
- RR7 — render frame restructured to WB RenderInsideOut Steps 1-4 +
|
||
stencil-gated sky (acdream enhancement); single strict
|
||
cameraInsideBuilding gate; sky/terrain skipped when inside; no
|
||
depth-clear; LiveDynamic last.
|
||
- RR8 — visual verification gate: Steps 1-4 closed #78 + R4 Issues A+C.
|
||
- RR9 — Step 5 (cross-building 3-stencil-bit pipeline + occlusion queries)
|
||
per WB VisibilityManager.cs:156-232 verbatim.
|
||
- RR10 — visual verification gate: Step 5 working.
|
||
- RR11 — RenderOutsideIn (cottage interiors through windows from outside)
|
||
per WB VisibilityManager.cs:241-310.
|
||
|
||
Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg Inn
|
||
(multi-room), and dungeon. Sky visible through windows. Cross-building
|
||
visibility (Step 5) working at inn→cottage. Cottage interiors visible from
|
||
outside (RenderOutsideIn). No regression on #100 (terrain cutout).
|
||
|
||
Full design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md.
|
||
Full plan: docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md.
|
||
RR0 falsification findings: docs/research/2026-05-26-a8-rr0-falsification-findings.md.
|
||
RR2 data-shape findings: docs/research/2026-05-26-a8-buildings-data-shape.md.
|
||
```
|
||
|
||
Also update the "Currently working toward" line and any other A8 references in the Milestone Discipline section to reflect ship status.
|
||
|
||
- [ ] **RR12-S8: Commit ship docs**
|
||
|
||
```bash
|
||
git add docs/ISSUES.md CLAUDE.md
|
||
git commit -m "$(cat <<'EOF'
|
||
ship(render): Phase A8 — full WB RenderInsideOut + RenderOutsideIn SHIPPED
|
||
|
||
Closes #78 + #102. Files any new issues discovered during RR12 visual
|
||
verification.
|
||
|
||
Architecture: per-building cell scoping (Building + BuildingRegistry +
|
||
BuildingLoader, with LoadedCell.BuildingId stamped at load) drives WB's
|
||
RenderInsideOut Steps 1-5 indoor pipeline + RenderOutsideIn outdoor-look-
|
||
into-buildings pipeline. Single strict cameraInsideBuilding gate; no
|
||
grace mechanism for the render path; stencil-gated sky inside indoor
|
||
branch (acdream enhancement).
|
||
|
||
Visual-verified at Holtburg cottage interior + cellar, Holtburg Inn,
|
||
dungeon. Sky through windows. Cross-building Step 5 working. Cottage
|
||
interiors visible from outside.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <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.
|