acdream/docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md
Erik 651e7e22fb docs(plan): Phase A8 — full WB RenderInsideOut + RenderOutsideIn port plan
12-task implementation plan (RR1-RR12, 94 step checkboxes total):
  RR1  — Cleanup: commit [vis] probe; revert R3+R3.5 v1+v2; supersede old docs
  RR2  — Spike: confirm BuildingInfo shape + WB interior-portal walk algorithm
  RR3  — Implement Building + BuildingRegistry + BuildingLoader (TDD, 10 tests)
  RR4  — Wire registry into landblock load + LoadedCell.BuildingId
  RR5  — WbDrawDispatcher.Draw(cellIds:) overload (TDD)
  RR6  — IndoorCellStencilPipeline 3-bit + occlusion-query helpers
  RR7  — Render frame: WB Steps 1-4 + outdoor branch + stencil-gated sky
  RR8  — Visual verification gate: Steps 1-4 close #78 + Issues A+C
  RR9  — Step 5 (3-stencil-bit cross-building + occlusion queries)
  RR10 — Visual verification gate: Step 5
  RR11 — RenderOutsideIn (cottage interiors through windows from outside)
  RR12 — Final visual matrix + ship docs (close #78, #102; update CLAUDE.md)

Each task: bite-sized 2-5 min steps; exact code snippets; commit per task.
Visual gates at RR8, RR10, RR12 ensure each layer works before adding the
next. Risk register handles RR2 data-shape uncertainty + RR9/RR11 frustum
API adaptation.

Estimated 8-10 sessions (~1.5-2 weeks calendar). Closes M1.5 indoor world
acceptance scope.

Design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:57:45 +02:00

104 KiB
Raw Blame History

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):

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 fee878fdcf69a1)

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

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
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)
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)
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)
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:

git add src/AcDream.App/Rendering/GameWindow.cs
git revert --continue --no-edit
  • RR1-S6: Verify net state vs R2 baseline
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
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:


---

**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:


---

**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
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
grep -rn "class BuildingInfo\|struct BuildingInfo\|record BuildingInfo" references/Chorizite.DatReaderWriter/ 2>/dev/null | head -5

If no match, look in the package source:

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):

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:

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.

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:

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

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:

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
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:

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
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:

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
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:

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
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).

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
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:

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
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
dotnet build -c Debug --nologo
dotnet test --nologo

Expected: build green; test failures within documented flaky window.

  • RR3-S14: Commit
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):

    /// <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
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:

    [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
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:

            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
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:

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:

// 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:

    /// <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
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
dotnet test --nologo

Expected: failures within documented flaky window.

  • RR4-S11: Commit
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:

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
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:

    /// <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
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:

    /// <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
dotnet build -c Debug --nologo
dotnet test --nologo

Expected: build green; tests within flaky window.

  • RR5-S7: Commit
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:

    [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):

    /// <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:

    /// <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
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
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.csglClear 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:

_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);

Change to:

// 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):

            // 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:

            // 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)):

            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:

            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):

            _terrainCpuStopwatch.Restart();
            _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
            _terrainCpuStopwatch.Stop();

Replace with:

            // 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.

            // 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):

            if (!cameraInsideCell)
            {
                _skyRenderer?.RenderWeather(...);
                ...
            }

Replace cameraInsideCell with cameraInsideBuilding in the gate:

            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
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
dotnet test --nologo

Expected: failures within documented flaky window. No NEW failures attributable to the restructure.

  • RR7-S10: Commit
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
dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo

Expected: green.

  • RR8-S2: Launch with [vis] probe
$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
$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:

# 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:

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:

                // 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
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
dotnet test --nologo

Expected: failures within flaky window. (Step 5 is GL integration; no new unit tests.)

  • RR9-S5: Commit
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)

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.

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:

            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
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
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.

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
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:

**#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:

**#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:

**Phase A8 — Indoor-cell visibility culling — SHIPPED 2026-05-2X.**
Closes issue #78 + #102. Full WorldBuilder RenderInsideOut + RenderOutsideIn
port (eleven implementation tasks RR1RR11):

- R1+R2 (ed72704, 55f26f2) — WorldEntity.IsBuildingShell + EntitySet partition.
  Earlier ship; kept as orthogonal infrastructure.
- RR1 — cleanup: commit [vis] probe; revert R3+R3.5 v1+v2 (failed half-port).
- RR2 — spike: confirmed LandBlockInfo.Buildings data shape + interior-portal
  walk algorithm against WB PortalRenderManager:518-551.
- RR3+RR4 — per-landblock data model: Building, BuildingRegistry,
  BuildingLoader. LoadedCell.BuildingId stamped at load. O(1) lookups
  in both directions.
- RR5 — WbDrawDispatcher.Draw(cellIds:) overload for per-building cell
  scoping.
- RR6 — IndoorCellStencilPipeline extended with 3-bit mode + occlusion-
  query helpers + per-building portal upload.
- RR7 — render frame restructured to WB RenderInsideOut Steps 1-4 +
  stencil-gated sky (acdream enhancement); single strict
  cameraInsideBuilding gate; sky/terrain skipped when inside; no
  depth-clear; LiveDynamic last.
- RR8 — visual verification gate: Steps 1-4 closed #78 + R4 Issues A+C.
- RR9 — Step 5 (cross-building 3-stencil-bit pipeline + occlusion queries)
  per WB VisibilityManager.cs:156-232 verbatim.
- RR10 — visual verification gate: Step 5 working.
- RR11 — RenderOutsideIn (cottage interiors through windows from outside)
  per WB VisibilityManager.cs:241-310.

Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg Inn
(multi-room), and dungeon. Sky visible through windows. Cross-building
visibility (Step 5) working at inn→cottage. Cottage interiors visible from
outside (RenderOutsideIn). No regression on #100 (terrain cutout).

Full design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md.
Full plan: docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md.
RR0 falsification findings: docs/research/2026-05-26-a8-rr0-falsification-findings.md.
RR2 data-shape findings: docs/research/2026-05-26-a8-buildings-data-shape.md.

Also update the "Currently working toward" line and any other A8 references in the Milestone Discipline section to reflect ship status.

  • RR12-S8: Commit ship docs
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:

  • Q1 (Step 5 scope: full) → RR9 implements
  • Q2 (data model: Option C cell field + registry) → RR3 ships both indexes + RR4 stamps cells
  • Q3 (RenderOutsideIn: include) → RR11 implements
  • WB-faithful restructure (skip initial sky+terrain, no depth-clear, single strict gate) → RR7
  • Stencil-gated sky (acdream enhancement) → RR7-S6 Step 4a
  • Revert R3+R3.5 v1+v2 → RR1-S3 through RR1-S6
  • Keep R1+R2 → RR1 doesn't touch them
  • Pre-flight spike before implementation → RR2
  • Visual gates at appropriate points → RR8, RR10, RR12
  • Supersede old docs → RR1-S8, RR1-S9
  • 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.