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>
104 KiB
Phase A8 — Full WorldBuilder RenderInsideOut + RenderOutsideIn Port — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port WorldBuilder's full RenderInsideOut (Steps 1-5) and RenderOutsideIn indoor visibility pipeline to acdream, replacing the current half-port (R3) with per-building cell scoping. Closes issue #78 (outdoor visible through indoor walls), R4 Issues A + C (transition flicker + transparent floor), and #102 (cross-cell-portal visibility).
Architecture: Add per-landblock BuildingRegistry mapping each cell to its building (or null). Render frame branches on a strict cameraInsideBuilding gate: indoor path renders only the camera-buildings' cells via WB Steps 1-4 + stencil-gated sky + Step 5 cross-building; outdoor path renders the world + RenderOutsideIn to show cottage interiors through windows from the street. Single source of truth for "inside a building" — drop the lenient/strict two-flag asymmetry that caused R3.5 v1+v2's bugs.
Tech Stack: C# .NET 10, Silk.NET (OpenGL 4.3 + GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters + occlusion-query objects), xUnit.
Predecessor context (REQUIRED reading before starting):
- docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md — the approved design this plan implements
- docs/research/2026-05-26-a8-rr0-falsification-findings.md — RR0 evidence triggering the scope expansion
- references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330 — proven reference;
RenderInsideOut(73-239) +RenderOutsideIn(241+) must be ported
Infrastructure consumed as-is:
- R1:
WorldEntity.IsBuildingShellflag set byLandblockLoader(commited72704) - R2:
WbDrawDispatcher.EntitySetpartition (commit55f26f2) - Tasks 1-6 infrastructure:
LoadedCell.PortalPolygons,IndoorCellStencilPipeline,PortalMeshBuilder,portal_stencil.vert/.frag,RenderingDiagnostics.ProbeVisibilityEnabled(commitsfee878f→dcf69a1)
HEAD at session start: ea60d1f (design doc commit). The [vis] probe code wired in RR0 spike is uncommitted; RR1 commits it before reverting R3.
File Structure
| File | Status | Purpose |
|---|---|---|
src/AcDream.App/Rendering/Wb/Building.cs |
NEW | Building data class: BuildingId, EnvCellIds, ExitPortalPolygons, occlusion-query state |
src/AcDream.App/Rendering/Wb/BuildingRegistry.cs |
NEW | Two-way indexed registry (by cellId + by buildingId) |
src/AcDream.App/Rendering/Wb/BuildingLoader.cs |
NEW | Static factory: reads LandBlockInfo.Buildings, walks interior portals, computes per-building cell sets |
src/AcDream.App/Rendering/CellVisibility.cs (LoadedCell) |
MODIFY | Add BuildingId: uint? field with internal setter |
src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs |
EXTEND | Add 3-bit stencil mode + occlusion-query helpers + per-building portal upload |
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs |
EXTEND | Add Draw overload accepting explicit IEnumerable<uint> cellIds (instead of visibility-derived set) |
src/AcDream.App/Rendering/GameWindow.cs |
RESTRUCTURE | Render-frame block: single cameraInsideBuilding gate; indoor path with Steps 1-5; outdoor path with RenderOutsideIn; landblock-load wires BuildingLoader |
tests/AcDream.App.Tests/Rendering/Wb/BuildingTests.cs |
NEW | Building data-class invariants |
tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs |
NEW | Two-way indexing invariants |
tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs |
NEW | LandBlockInfo→registry mapping; interior-portal walk; exit-portal extraction |
tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs |
NEW | New Draw(cellIds:) overload behavior |
tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs |
EXTEND | 3-bit mode + occlusion-query state machine |
docs/research/2026-05-2X-a8-buildings-data-shape.md |
NEW (RR2 output) | Spike findings: BuildingInfo data shape + walk algorithm |
docs/research/2026-05-2X-a8-rr8-steps-1-4-visual.md |
NEW (RR8 output) | Visual verification log for Steps 1-4 |
docs/research/2026-05-2X-a8-rr10-step-5-visual.md |
NEW (RR10 output) | Visual verification log for Step 5 |
docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md |
FOOTER-MARK | Mark as SUPERSEDED |
docs/superpowers/plans/2026-05-26-phase-a8-restructure.md |
FOOTER-MARK | Mark as SUPERSEDED |
docs/ISSUES.md |
UPDATE (RR12) | Move #78 closed; close #102 (subsumed by Step 5); file new follow-ups if any |
CLAUDE.md |
UPDATE (RR12) | A8 paragraph: PAUSED → SHIPPED with full description |
Commits reverted in RR1:
60f07bcR3 (stencil-pipeline wire-in)38d5374R3.5 v1 (stencil-branch gate)2bfeafdR3.5 v2 (depth-clear gate)
Commits kept:
- R1
ed72704IsBuildingShell flag - R2
55f26f2EntitySet 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, thecameraInsideCelldeclaration - Drop: the
cameraReallyInsidecomputation, the depth-clear-if-cameraReallyInside block, the indoor stencil branch, the outdoorelsebranch (revert leaves a single dispatcher call withEntitySet.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 itsOtherCellId? -
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:
BuildingInfofield 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
Buildingdata 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(LoadedCellclass) — addBuildingIdfield -
Modify:
src/AcDream.App/Rendering/GameWindow.cs— landblock load path: build registry, stampLoadedCell.BuildingId -
Modify (tests):
tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs— add tests that pass LoadedCells and verify BuildingId gets stamped -
RR4-S1: Add
BuildingIdtoLoadedCell
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
BuildingRegistryfield 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
WalkEntitiesForTestByCellIdshelper
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.cs—glClearat frame start (~line 6900): addStencilBufferBit
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
cameraInsideBuildinggate
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 theDraw(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 RR1–RR11):
- R1+R2 (ed72704, 55f26f2) — WorldEntity.IsBuildingShell + EntitySet partition.
Earlier ship; kept as orthogonal infrastructure.
- RR1 — cleanup: commit [vis] probe; revert R3+R3.5 v1+v2 (failed half-port).
- RR2 — spike: confirmed LandBlockInfo.Buildings data shape + interior-portal
walk algorithm against WB PortalRenderManager:518-551.
- RR3+RR4 — per-landblock data model: Building, BuildingRegistry,
BuildingLoader. LoadedCell.BuildingId stamped at load. O(1) lookups
in both directions.
- RR5 — WbDrawDispatcher.Draw(cellIds:) overload for per-building cell
scoping.
- RR6 — IndoorCellStencilPipeline extended with 3-bit mode + occlusion-
query helpers + per-building portal upload.
- RR7 — render frame restructured to WB RenderInsideOut Steps 1-4 +
stencil-gated sky (acdream enhancement); single strict
cameraInsideBuilding gate; sky/terrain skipped when inside; no
depth-clear; LiveDynamic last.
- RR8 — visual verification gate: Steps 1-4 closed #78 + R4 Issues A+C.
- RR9 — Step 5 (cross-building 3-stencil-bit pipeline + occlusion queries)
per WB VisibilityManager.cs:156-232 verbatim.
- RR10 — visual verification gate: Step 5 working.
- RR11 — RenderOutsideIn (cottage interiors through windows from outside)
per WB VisibilityManager.cs:241-310.
Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg Inn
(multi-room), and dungeon. Sky visible through windows. Cross-building
visibility (Step 5) working at inn→cottage. Cottage interiors visible from
outside (RenderOutsideIn). No regression on #100 (terrain cutout).
Full design: docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md.
Full plan: docs/superpowers/plans/2026-05-26-phase-a8-wb-full-port.md.
RR0 falsification findings: docs/research/2026-05-26-a8-rr0-falsification-findings.md.
RR2 data-shape findings: docs/research/2026-05-26-a8-buildings-data-shape.md.
Also update the "Currently working toward" line and any other A8 references in the Milestone Discipline section to reflect ship status.
- RR12-S8: Commit ship docs
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:
cameraInsideBuildingused consistently (RR7 introduces; not used before).Building,BuildingRegistry,BuildingLoadernames 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 usesBuildingId is not null); referenced (mutated) only by BuildingLoader.LandblockEntry.BuildingRegistrydefined in RR4-S8; consumed in RR7-S3 + RR9-S2 + RR11-S1.IndoorCellStencilPipelinemethods: existing (UploadPortalMesh,MarkAndPunch,EnableOutdoorPass,DisableStencil) + RR6 additions (UploadBuildingPortalMesh,MarkBuildingBit2,PunchDepthAtStencil3,EnableOtherBuildingPass,ResetBit2,EnsureOcclusionQueryId,TryReadOcclusionResult,BeginOcclusionQuery,EndOcclusionQuery). All referenced consistently.WbDrawDispatcheroverloads: existing (Draw(... visibleCellIds: ..., set: All)) + RR5 addition (Draw(... cellIds: ..., set: ...)). RR7+RR9+RR11 use the newcellIds: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.