diff --git a/docs/superpowers/plans/2026-05-28-phase-a8-wb-render-inside-out-port.md b/docs/superpowers/plans/2026-05-28-phase-a8-wb-render-inside-out-port.md new file mode 100644 index 0000000..a86db06 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-phase-a8-wb-render-inside-out-port.md @@ -0,0 +1,1831 @@ +# Phase A8 — WB RenderInsideOut 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 `VisibilityManager.RenderInsideOut` algorithm into acdream's render frame so indoor cells render correctly with stencil-gated outdoor visibility through portals. Replaces the four reverted RR7 variants from 2026-05-27. + +**Architecture:** Extract WB's render-time data model (`RenderPass`, `Frustum`, `SceneryInstance`, `EnvCellLandblock`, `EnvCellVisibilitySnapshot`) verbatim into our tree. Build a new `EnvCellRenderer` class that mirrors WB's `EnvCellRenderManager.Render(filter:)` and `PrepareRenderBatches` byte-for-byte, but registers its per-cell instances from acdream's existing landblock streaming pipeline (`BuildInteriorEntitiesForStreaming` at `GameWindow.cs:5367+`) instead of from WB's editor `LandscapeDocument`. Extend `IndoorCellStencilPipeline` with a low-level `RenderBuildingStencilMask(building, vp, writeFarDepth)` matching WB's `PortalRenderManager.RenderBuildingStencilMask` API. Replicate `VisibilityManager.RenderInsideOut` Steps 1-5 verbatim in `GameWindow.cs`'s render method as a new method `RenderInsideOutAcdream`. The cell-as-WorldEntity hack at `GameWindow.cs:5417-5428` is removed — cell meshes flow through `EnvCellRenderer` only. + +**Tech Stack:** C# .NET 10; Silk.NET.OpenGL 4.3+ with bindless textures + `glMultiDrawElementsIndirect`; WB-extracted `ObjectMeshManager` + `WbDrawDispatcher` infrastructure (already in tree). DatReaderWriter 2.1.x NuGet for dat type access. + +**WB ground-truth references (the algorithm we are porting):** +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` — `RenderInsideOut` (Steps 1-5) +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:247-373` — `PrepareRenderBatches` +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:395-511` — `Render(RenderPass, HashSet?)` +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:990-1103` — instance batching context (for inline RenderModernMDI helper) +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484` — `RenderBuildingStencilMask` + +**Process rules (carved from the 2026-05-27 RR7 saga):** +1. No visual-gate launch until probe data confirms indoor branch fires + envcells walked + stencil mask non-empty. "Looks good" without diagnostic correlation is not verification. +2. No partial WB ports. Steps 1-5 ship together in one render-frame restructure. +3. No conceptual adaptations. Where our infrastructure has an existing analog (e.g., `ObjectMeshManager.TryGetRenderData`), use it 1:1; do not "improve." +4. Trust-but-verify after every subagent. Read the actual diff before declaring done. +5. Single visual gate when build + tests + probes are all green. + +--- + +## File map + +| Path | Status | Purpose | +|---|---|---| +| `src/AcDream.App/Rendering/Wb/WbRenderPass.cs` | **NEW** | WB `RenderPass` enum (renamed to avoid conflict if we ever add another). | +| `src/AcDream.App/Rendering/Wb/WbFrustum.cs` | **NEW** | WB `Frustum` class — 98 LOC verbatim. | +| `src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs` | **NEW** | WB `SceneryInstance` struct (renamed scope-narrow) + `EnvCellLandblock` (stripped from `ObjectLandblock`). | +| `src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs` | **NEW** | WB `VisibilitySnapshot` (renamed). | +| `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` | **NEW** | The core port. Implements WB's `EnvCellRenderManager.Render(filter:)` + `PrepareRenderBatches` + a stripped-down `RenderModernMDI`. ~700 LOC. | +| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` | **EXTEND** | Add `RenderBuildingStencilMask(building, vp, writeFarDepth)` low-level method matching WB's API. | +| `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | (a) `BuildInteriorEntitiesForStreaming` — replace cell-as-WorldEntity creation with `EnvCellRenderer.RegisterCell(...)`; (b) `_envCellRenderer` field + init in ctor; (c) `RenderInsideOutAcdream` new method; (d) render-frame call site swap. | +| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | **MINOR** | Add `ProbeEnvCellEnabled` flag (already has `ProbeVisibilityEnabled` from RR7's [vis] probe; we layer on top). | +| `tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs` | **NEW** | Snapshot batching + filter behavior (no GL — pure data tests). | +| `tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs` | **NEW** | Frustum primitive tests. | +| `tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs` | **NEW** | Per-cell registration + bounds union. | +| `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` | **EXTEND** | Add `RenderBuildingStencilMask` exists + math contract (existing 9 stencil tests stay). | + +**No external project dependencies added.** All extractions live in our tree alongside the existing WB-extracted code from Phase O. + +--- + +## Architecture deep-dive: the data flow split + +**Pre-A8 (broken — what RR7 inherited):** + +``` +Landblock load + → BuildInteriorEntitiesForStreaming + → CellMesh.Build(envCell, cellStruct) → cellSubMeshes + → _pendingCellMeshes[envCellId] = cellSubMeshes ← populated but never consumed + → WorldEntity { MeshRefs = [MeshRef(envCellId, ...)] } ← envCellId NOT a real GfxObj id + → _worldState.AddEntity(...) + → ObjectMeshManager.PrepareMeshDataAsync runs internally for some entity refs + +Render frame + → WbDrawDispatcher.Draw(set: IndoorPass, cellIds: ...) walks WorldEntities including cell-shells + → For each entity.MeshRef: looks up TryGetRenderData(MeshRef.GfxObjId) + → MeshRef.GfxObjId is envCellId (e.g. 0xA9B40143) — no matching GfxObj in dats + → Cell renders nothing; floor is fog color +``` + +**Post-A8 (the WB-faithful path):** + +``` +Landblock load + → BuildInteriorEntitiesForStreaming + → CellMesh.Build(...) (kept for physics — feeds _physicsDataCache) + → For each EnvCell with non-null EnvironmentId: + _envCellRenderer.RegisterCell(landblockKey, envCellId, envCell, cellStruct, transform) + - Internally: calls ObjectMeshManager.PrepareEnvCellGeomMeshDataAsync(deduplicatedGeomId, envId, cellStructure, surfaces) + (this populates ObjectMeshManager's _renderData with the actual cell mesh) + - Internally: also calls PrepareMeshDataAsync for each StaticObject in the cell + - Builds SceneryInstance records (one per cell + one per stab in cell) for the landblock + → Cell-as-WorldEntity creation DELETED (the broken path) + +Per frame: _envCellRenderer.Update() + → Drains any pending registrations into the active landblocks dict + → Marks NeedsPrepare = true + +Per frame: PrepareRenderBatches(viewProj, camPos) [called once before render frame begins] + → Frustum-culls each landblock's TotalEnvCellBounds + → For visible landblocks, frustum-tests per-cell EnvCellBounds + → Builds VisibilitySnapshot.BatchedByCell: cellId → gfxObjId → InstanceData[] + → Atomic swap under _renderLock + +Render frame + → If cameraInsideBuilding (strict — see gate semantics below): + RenderInsideOutAcdream(...) + Step 1: stencil bit 1 at camera-building portals (uses IndoorCellStencilPipeline.RenderBuildingStencilMask) + Step 2: depth-punch at camera-building portals + Step 3: ColorMask on, stencil off, DepthFunc.Less, sceneryShader.Bind() + _envCellRenderer.Render(pass1RenderPass, _currentEnvCellIds) + Step 4: stencil-gated terrain + scenery + static objects (via existing _terrain.Render + dispatcher.Draw(set: OutdoorScenery)) + Step 5: per other-building 3-bit stencil pipeline (cross-building visibility) + → Else (outdoor): + Existing outdoor render path (RenderOutsideIn deferred to a later phase; sky + terrain + dispatcher.Draw(set: All)) + → LiveDynamic always last: dispatcher.Draw(set: LiveDynamic) with stencil disabled +``` + +**Gate semantics — `cameraInsideBuilding`:** + +```csharp +bool cameraInsideBuilding = + visibility?.CameraCell is not null + && CellVisibility.PointInCell(camPos, visibility.CameraCell) // STRICT — no grace + && visibility.CameraCell.BuildingId is not null; // RR4-stamped +``` + +Strict (no grace), AND requires `BuildingId != null`. A cell tagged null is an outdoor surface cell or a dungeon cell not enumerated in `LandBlockInfo.Buildings` — those flow through the outdoor render path. + +--- + +## Probe trail (mandatory before visual gate) + +Probes gate on `ACDREAM_PROBE_VIS=1` (existing flag) **OR** new `ACDREAM_PROBE_ENVCELL=1`. The new flag activates only the `[envcells]` family; `[stencil]`, `[draworder]`, `[buildings]` ride on `ACDREAM_PROBE_VIS=1`. Both flags can be runtime-toggled via DebugPanel. + +| Tag | Frequency | Format | Fires from | +|---|---|---|---| +| `[envcells]` | per frame, indoor branch only | `[envcells] cells={N} tris={M} ourBldgs={B1} otherBldgs={B2} filterCnt={F}` | `EnvCellRenderer.Render` exit | +| `[stencil]` | per `RenderBuildingStencilMask` call | `[stencil] op={mark\|punch} bld={bldId} verts={N}` | `IndoorCellStencilPipeline.RenderBuildingStencilMask` | +| `[draworder]` | per indoor frame | `[draworder] step={1\|2\|3\|4\|5x} stencil={on\|off} depthFn={cmp} colorMask={rgba}` | `RenderInsideOutAcdream` step boundaries | +| `[buildings]` | per indoor frame | `[buildings] camCell={cellId:X8} camBldgs={[ids]} otherBldgs={[ids]} totalKnown={N}` | `RenderInsideOutAcdream` entry | + +**Mandatory probe acceptance criteria before visual launch:** +- `[buildings] camBldgs={...}` non-empty when player is inside a Holtburg cottage (CellId in 0xA9B4014x range). +- `[envcells] cells>=1 tris>=1 filterCnt>=1` for at least one frame in that scenario. +- `[stencil] verts>0` for at least one mark+punch pair per camera-building. +- `[draworder]` shows exactly five step transitions per indoor frame (Step 1 → 2 → 3 → 4 → 5). + +--- + +## Task breakdown + +### Task 1: Extract `RenderPass` enum + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/WbRenderPass.cs` +- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs:1-22` + +- [ ] **Step 1: Create the enum file** + +```csharp +// src/AcDream.App/Rendering/Wb/WbRenderPass.cs +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-28): WB's RenderPass enum, extracted verbatim from +/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs. +/// Renamed to WbRenderPass so it doesn't clash if we ever add an +/// acdream-side RenderPass with different semantics. +/// +public enum WbRenderPass +{ + /// The opaque pass. Only non-transparent objects are rendered. + Opaque = 0, + + /// The transparent pass. Only transparent objects are rendered, usually after the opaque pass. + Transparent = 1, + + /// + /// A single-pass render that includes both opaque and (sometimes) transparent objects, + /// or for special cases like skyboxes and certain UI elements. + /// + SinglePass = 2, +} +``` + +- [ ] **Step 2: Build green** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: PASS — no consumers yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbRenderPass.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — extract WB RenderPass enum + +Verbatim port of references/WorldBuilder/.../RenderPass.cs:1-22. Renamed +to WbRenderPass to avoid future conflict. First step of the WB +RenderInsideOut port that replaces the four reverted RR7 variants from +2026-05-27. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2: Extract `Frustum` class + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/WbFrustum.cs` +- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs:1-98` +- Test: `tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs` + +- [ ] **Step 1: Read WB source verbatim** + +Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs` in full. The class extracts six planes from a view-projection matrix and exposes `TestBox(BoundingBox)` returning `FrustumTestResult.Inside/Outside/Intersecting`, plus `Intersects(BoundingBox)` returning bool. It depends on `Chorizite.Core.Lib.BoundingBox` and `Chorizite.Core.Lib.FrustumTestResult`. + +- [ ] **Step 2: Copy verbatim and adapt** + +Copy the entire 98-LOC class. Adapt: +- Namespace: `Chorizite.OpenGLSDLBackend` → `AcDream.App.Rendering.Wb` +- Class name: `Frustum` → `WbFrustum` (avoid conflict with any future acdream Frustum) +- `BoundingBox` type: replace with `System.Numerics.Vector3` pairs OR introduce a tiny `WbBoundingBox` struct (preferred — keep WB's API shape). The struct has just `Vector3 Min, Max`. Inline at the top of the file. +- `FrustumTestResult` enum: inline `public enum FrustumTestResult { Inside, Outside, Intersecting }` at the top. + +**Add a NoteFromExtraction comment block** citing the WB source path + commit-date. + +- [ ] **Step 3: Write failing tests** + +```csharp +// tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class WbFrustumTests +{ + private static Matrix4x4 IdentityVp() => + Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1.0f, 0.1f, 100.0f); + + [Fact] + public void TestBox_PointAtOrigin_ReturnsInside() + { + var f = new WbFrustum(); + // Look down -Z from origin toward Z=-5 + var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY); + f.Update(view * IdentityVp()); + var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2)); + var res = f.TestBox(box); + Assert.Equal(FrustumTestResult.Inside, res); + } + + [Fact] + public void TestBox_BehindCamera_ReturnsOutside() + { + var f = new WbFrustum(); + var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY); + f.Update(view * IdentityVp()); + var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10)); // BEHIND + var res = f.TestBox(box); + Assert.Equal(FrustumTestResult.Outside, res); + } + + [Fact] + public void TestBox_StraddlingNear_ReturnsIntersecting() + { + var f = new WbFrustum(); + var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY); + f.Update(view * IdentityVp()); + var box = new WbBoundingBox(new Vector3(-1, -1, 1), new Vector3(1, 1, -1)); // straddles + var res = f.TestBox(box); + Assert.Equal(FrustumTestResult.Intersecting, res); + } +} +``` + +- [ ] **Step 4: Run tests, verify red→green** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~WbFrustumTests"` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbFrustum.cs tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — extract WB Frustum class + +Verbatim port of references/WorldBuilder/.../Frustum.cs (98 LOC). +Renamed to WbFrustum to avoid future conflict. Inline WbBoundingBox + +FrustumTestResult to keep WB API shape. 3 unit tests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3: Extract `SceneryInstance` + `EnvCellLandblock` + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs` +- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs:1-161` +- Test: `tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs` + +- [ ] **Step 1: Copy SceneryInstance struct verbatim** + +Port the entire struct (lines 11-56). Keep all fields. Adapt namespace `Chorizite.OpenGLSDLBackend.Lib` → `AcDream.App.Rendering.Wb`. Replace `BoundingBox` references with our `WbBoundingBox` from Task 2. Replace `SceneryDisqualificationReason` with `int` or drop it (we don't use this field — only the editor cares about scenery disqualification). + +Drop `DisqualificationReason` field and `SceneryDisqualificationReason` enum entirely. Keep all other fields — even ones we don't currently use, since WB's algorithm may consult them. + +- [ ] **Step 2: Copy ObjectLandblock as EnvCellLandblock (stripped)** + +Port WB's `ObjectLandblock` class (lines 62-160) — but RENAME to `EnvCellLandblock` since we ONLY use it for env-cell rendering (procedural scenery + outdoor stabs use our existing pipeline). Strip the following editor-only or modern-MDI-shared fields that we don't need for cell rendering: +- `IsQueuedForUpload`, `IsTransformOnlyUpdate` +- `ParticleEmitters` (we have our own particle path) +- `InstanceBufferOffset`, `InstanceCount`, `MdiCommands` (we'll handle MDI in `EnvCellRenderer` differently) + +Keep: +- `GridX`, `GridY`, `Lock`, `Instances`, `EnvCellBounds`, `SeenOutsideCells`, + `PendingInstances`, `PendingEnvCellBounds`, `PendingSeenOutsideCells`, + `StaticPartGroups`, `BuildingPartGroups`, `BoundingBox`, `TotalEnvCellBounds`, + `PendingTotalEnvCellBounds`, `InstancesReady`, `MeshDataReady`, `GpuReady`. + +The two `PartGroups` dictionaries are the heart of the data model — `Dictionary>`. We need `InstanceData` from our existing `src/AcDream.App/Rendering/Wb/InstanceData.cs`. + +- [ ] **Step 3: Write failing tests** + +```csharp +// tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class EnvCellSceneryInstanceTests +{ + [Fact] + public void Instance_Construct_HoldsAllFields() + { + var t = Matrix4x4.CreateTranslation(1, 2, 3); + var s = new EnvCellSceneryInstance + { + ObjectId = 0x01000123, + IsBuilding = true, + WorldPosition = new Vector3(1, 2, 3), + Transform = t, + }; + Assert.True(s.IsBuilding); + Assert.Equal(0x01000123UL, s.ObjectId); + } + + [Fact] + public void Landblock_PartGroups_StartEmpty() + { + var lb = new EnvCellLandblock { GridX = 0xA9, GridY = 0xB4 }; + Assert.Empty(lb.StaticPartGroups); + Assert.Empty(lb.BuildingPartGroups); + Assert.False(lb.InstancesReady); + } + + [Fact] + public void Landblock_AddInstance_GroupsByGfxObjId() + { + var lb = new EnvCellLandblock(); + var ins = new InstanceData { /* default */ }; + lb.StaticPartGroups.GetOrAdd(0x01000001UL).Add(ins); + lb.StaticPartGroups.GetOrAdd(0x01000001UL).Add(ins); + Assert.Single(lb.StaticPartGroups); + Assert.Equal(2, lb.StaticPartGroups[0x01000001UL].Count); + } +} + +internal static class DictExt +{ + public static List GetOrAdd(this Dictionary> d, ulong k) + { + if (!d.TryGetValue(k, out var v)) { v = new(); d[k] = v; } + return v; + } +} +``` + +- [ ] **Step 4: Build + test** + +Run: `dotnet build && dotnet test --filter "FullyQualifiedName~EnvCellSceneryInstanceTests"` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — extract SceneryInstance + EnvCellLandblock + +Verbatim port of WB's SceneryInstance struct and stripped-down +ObjectLandblock (renamed EnvCellLandblock; dropped editor-only and +MDI-aggregation fields that we don't reuse). 3 unit tests pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 4: Extract `VisibilitySnapshot` + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs` +- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36` + +- [ ] **Step 1: Copy verbatim with rename** + +```csharp +// src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs +using System.Collections.Generic; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Phase A8 (2026-05-28): EnvCell-scoped visibility snapshot. Direct port of +/// WB's VisibilitySnapshot, renamed because we only use the per-cell variant +/// (no scenery / static-object snapshot). +/// +public sealed class EnvCellVisibilitySnapshot +{ + /// Landblocks fully or partially inside the frustum. + public List VisibleLandblocks { get; init; } = new(); + + /// + /// Grouped instance data by CellId. + /// Key: full 32-bit CellId; Value: { GfxObjId: List<InstanceData> }. + /// + public Dictionary>> BatchedByCell { get; init; } = new(); + + /// Whether this snapshot contains any visible cells. + public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0; +} +``` + +- [ ] **Step 2: Build green** + +Run: `dotnet build` +Expected: PASS — no consumers yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — extract EnvCellVisibilitySnapshot + +Direct port of WB's VisibilitySnapshot, narrowed to the per-cell variant +(BatchedByCell only). Used by EnvCellRenderer for thread-safe render-time +state. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5: Build `EnvCellRenderer` + +**Files:** +- Create: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (~700 LOC) +- Test: `tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs` +- WB sources: + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:247-373` (PrepareRenderBatches) + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:395-511` (Render) + - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:RenderModernMDI` (find by grep — modern multi-draw path) + +This is **the** task. It's the largest and most consequential. Subagents executing this MUST read WB's sources line-by-line and follow them. + +#### Step 5.1: Skeleton + constructor + +```csharp +// src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +// +// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the +// production cell-rendering pipeline for indoor visibility, replacing the +// broken "cell as WorldEntity with MeshRef(envCellId)" approach that the +// four reverted RR7 variants couldn't fix. +// +// Sources ported byte-for-byte: +// PrepareRenderBatches ← WB EnvCellRenderManager.cs:247-373 +// Render(filter:) ← WB EnvCellRenderManager.cs:395-511 +// (inline) RenderModernMDI ← WB ObjectRenderManagerBase.cs:[grep] +// +// Note we do NOT inherit from WB's ObjectRenderManagerBase. That base +// class owns the landblock-streaming loop (Update, _pendingGeneration, +// _uploadQueue). acdream's StreamingController already does that work — +// running a parallel loop would compete for dat I/O. Instead, we expose +// RegisterCell(...) as the seam: callers populate our instance store at +// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming). + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Numerics; +using System.Threading; +using AcDream.App.Rendering; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Wb; + +public sealed unsafe class EnvCellRenderer : IDisposable +{ + private readonly GL _gl; + private readonly ObjectMeshManager _meshManager; + private readonly WbFrustum _frustum; + + // Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000). + private readonly ConcurrentDictionary _landblocks = new(); + + // Active snapshot (atomic swap under _renderLock). + private readonly object _renderLock = new(); + private EnvCellVisibilitySnapshot _activeSnapshot = new(); + + // Shader (set by caller via Initialize). + private GLSLShader? _shader; + private bool _initialized; + + // List pool — copied from WB ObjectRenderManagerBase. + private readonly List> _listPool = new(); + private int _poolIndex = 0; + + // Modern-MDI scratch buffers (one slot — frame-by-frame draw). + private uint _mdiCommandBuffer; + private int _mdiCommandCapacity = 1024; + private uint _modernInstanceBuffer; + private int _modernInstanceCapacity = 1024; + + public bool NeedsPrepare { get; private set; } = true; + + public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum) + { + _gl = gl; + _meshManager = meshManager; + _frustum = frustum; + } + + public void Initialize(GLSLShader shader) + { + _shader = shader; + AllocateMdiBuffers(); + _initialized = true; + } + + private void AllocateMdiBuffers() + { + // ... (MDI command buffer + instance buffer GL allocation — copied + // verbatim from BaseObjectRenderManager constructor, slot 0 only) + } + + // ... (continued in subsequent steps) +} +``` + +- [ ] Implement the constructor + Initialize + AllocateMdiBuffers using WB `BaseObjectRenderManager.cs:62-100` (extract just the buffer allocation, drop the per-frame triple-buffering — we only need slot 0). + +#### Step 5.2: `RegisterCell` — the streaming seam + +```csharp +/// +/// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load +/// time, ONCE per EnvCell that has non-null EnvironmentId. Populates the +/// landblock's pending instance list with one entry per cell (the cell +/// geometry itself) and one per StaticObject in the cell. +/// +/// After all cells in a landblock are registered, call +/// to atomically swap pending → instances and recompute bounds. +/// +/// NOTE: this method does NOT trigger mesh loading. The caller (or +/// ObjectMeshManager Tick) drives PrepareMeshDataAsync. We assume that +/// by the time PrepareRenderBatches runs in the render thread, the mesh +/// data is ready (or will silently skip via TryGetRenderData null check — +/// matching WB behavior). +/// +public void RegisterCell( + uint landblockId, + uint envCellId, + DatReaderWriter.DBObjs.EnvCell envCell, + DatReaderWriter.Types.CellStruct cellStruct, + Matrix4x4 cellTransform, + Vector3 cellWorldPosition, + Quaternion cellRotation, + IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects) +{ + // 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId). + var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces); + + // 2. Trigger mesh prep for the cell geometry on ObjectMeshManager. + // This populates ObjectMeshManager._renderData[cellGeomId] when complete. + _ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId, + envCell.CellStructure, envCell.Surfaces); + + // 3. Build local bounds from cellStruct vertices. + var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct); + var worldBounds = TransformBoundingBox(localBounds, cellTransform); + + // 4. Create the per-cell SceneryInstance. + var cellInstance = new EnvCellSceneryInstance + { + ObjectId = cellGeomId, + InstanceId = ObjectIdFromCellId(envCellId), // helper that packs to WB shape + IsBuilding = true, + IsEntryCell = false, // could be derived from entry-portal walk; default false + WorldPosition = cellWorldPosition, + Rotation = cellRotation, + Scale = Vector3.One, + Transform = cellTransform, + LocalBoundingBox = localBounds, + BoundingBox = worldBounds, + }; + + var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock + { + GridX = (int)((id >> 24) & 0xFFu), + GridY = (int)((id >> 16) & 0xFFu), + }); + + lock (lb.Lock) + { + lb.PendingInstances ??= new List(capacity: 32); + lb.PendingInstances.Add(cellInstance); + lb.PendingEnvCellBounds ??= new Dictionary(); + lb.PendingEnvCellBounds[envCellId] = worldBounds; + + // Add static-object instances inside the cell. + foreach (var stab in staticObjects) + { + // Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes). + _ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup); + + var stabBoundsLocal = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup) + ?? default; + var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform); + + lb.PendingInstances.Add(new EnvCellSceneryInstance + { + ObjectId = stab.StaticObjectId, + InstanceId = default, + IsSetup = stab.IsSetup, + IsBuilding = false, + Transform = stab.Transform, + BoundingBox = stabBoundsWorld, + LocalBoundingBox = stabBoundsLocal, + Scale = Vector3.One, + Rotation = stab.Rot, + }); + + // Union the cell bounds with the stab bounds. + var current = lb.PendingEnvCellBounds[envCellId]; + lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld); + } + } +} + +public void FinalizeLandblock(uint landblockId) +{ + if (!_landblocks.TryGetValue(landblockId, out var lb)) return; + lock (lb.Lock) + { + if (lb.PendingInstances is not null) + { + lb.Instances = lb.PendingInstances; + lb.PendingInstances = null; + } + if (lb.PendingEnvCellBounds is not null) + { + lb.EnvCellBounds = lb.PendingEnvCellBounds; + lb.PendingEnvCellBounds = null; + } + + // Compute total bounds + populate PartGroups by walking instances. + var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); + foreach (var b in lb.EnvCellBounds.Values) total = WbBoundingBox.Union(total, b); + lb.TotalEnvCellBounds = total; + + // Populate PartGroups (mirrors WB EnvCellRenderManager.PopulatePartGroups). + PopulatePartGroups(lb); + + lb.InstancesReady = true; + lb.GpuReady = true; + } + NeedsPrepare = true; +} + +public void RemoveLandblock(uint landblockId) +{ + _landblocks.TryRemove(landblockId, out _); + NeedsPrepare = true; +} +``` + +- [ ] Implement helpers: + - `GetEnvCellGeomId(envId, cellStruct, surfaces)` — verbatim copy of WB `EnvCellRenderManager.GetEnvCellGeomId` (lines 94-103). + - `PopulatePartGroups(lb)` — verbatim from WB `EnvCellRenderManager.PopulatePartGroups` (lines 572-580), but adapt to use our `InstanceData` and walk our `lb.Instances`. Per WB, it recursively walks Setup parts via `MeshManager.GetSetupParts`. + - `ComputeLocalBoundsFromCellStruct(cellStruct)` — iterate cellStruct.VertexArray.Vertices, compute Min/Max. + - `WbBoundingBox.Union(a, b)` — standard min/max merge. Add to WbBoundingBox struct. + - `TransformBoundingBox(local, transform)` — 8-corner transform + axis-aligned re-extract. + +#### Step 5.3: `PrepareRenderBatches` — port verbatim from WB EnvCellRenderManager.cs:247-373 + +```csharp +public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet? filter = null) +{ + if (!_initialized || cameraPosition.Z > 4000) return; + + lock (_renderLock) { _poolIndex = 0; } + + // (WB updates _cameraLbX/Y here from LandscapeDoc.Region — skip; we don't + // need camera-LB tracking for the snapshot, just frustum tests.) + + // Step 1: filter loaded landblocks by GpuReady + InstancesReady + non-empty. + var landblocks = new List(); + foreach (var lb in _landblocks.Values) + if (lb.GpuReady && lb.Instances.Count > 0) + landblocks.Add(lb); + if (landblocks.Count == 0) return; + + // Step 2: parallel frustum-cull per LB + per-cell, batch by cell. + // (WB uses ThreadLocal> for thread-local accumulators. + // Port verbatim — see EnvCellRenderManager.cs:265-325.) + using var threadLocalBatchedByCell = new ThreadLocal>>>( + () => new(), trackAllValues: true); + + var parallelOptions = new System.Threading.Tasks.ParallelOptions + { MaxDegreeOfParallelism = Environment.ProcessorCount }; + + System.Threading.Tasks.Parallel.ForEach(landblocks, parallelOptions, lb => + { + lock (lb.Lock) + { + var testResult = _frustum.TestBox(lb.TotalEnvCellBounds); + if (testResult == FrustumTestResult.Outside) return; + + var lbBatchedByCell = threadLocalBatchedByCell.Value!; + + if (testResult == FrustumTestResult.Inside) + { + // Fast path: all cells visible + foreach (var (gfxObjId, instances) in lb.BuildingPartGroups) + foreach (var instanceData in instances) + { + if (filter != null && !filter.Contains(instanceData.CellId)) continue; + AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData); + } + foreach (var (gfxObjId, instances) in lb.StaticPartGroups) + foreach (var instanceData in instances) + { + if (filter != null && !filter.Contains(instanceData.CellId)) continue; + AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData); + } + return; + } + + // Slow path: per-cell frustum test + var visibleCells = new HashSet(); + foreach (var kvp in lb.EnvCellBounds) + { + var cellId = kvp.Key; + if (filter != null && !filter.Contains(cellId)) continue; + if (_frustum.Intersects(kvp.Value)) visibleCells.Add(cellId); + } + if (visibleCells.Count > 0) + { + foreach (var (gfxObjId, instances) in lb.BuildingPartGroups) + foreach (var instanceData in instances) + if (visibleCells.Contains(instanceData.CellId)) + AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData); + foreach (var (gfxObjId, instances) in lb.StaticPartGroups) + foreach (var instanceData in instances) + if (visibleCells.Contains(instanceData.CellId)) + AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData); + } + } + }); + + // Step 3: merge thread-locals + var newBatchedByCell = new Dictionary>>(); + foreach (var local in threadLocalBatchedByCell.Values) + { + foreach (var kvp in local) + { + if (!newBatchedByCell.TryGetValue(kvp.Key, out var gfxDict)) + { + gfxDict = new Dictionary>(); + newBatchedByCell[kvp.Key] = gfxDict; + } + foreach (var (gfxObjId, list) in kvp.Value) + { + if (!gfxDict.TryGetValue(gfxObjId, out var existing)) + { + existing = GetPooledList(); + gfxDict[gfxObjId] = existing; + } + existing.AddRange(list); + } + } + } + + // Step 4: atomic swap. + lock (_renderLock) + { + _activeSnapshot = new EnvCellVisibilitySnapshot + { + BatchedByCell = newBatchedByCell, + VisibleLandblocks = landblocks, + }; + _poolIndex = 0; + NeedsPrepare = false; + } +} + +private static void AddToCellGroup(Dictionary>> dst, + uint cellId, ulong gfxObjId, InstanceData data) +{ + if (!dst.TryGetValue(cellId, out var gfx)) + { + gfx = new Dictionary>(); + dst[cellId] = gfx; + } + if (!gfx.TryGetValue(gfxObjId, out var list)) + { + list = new List(); + gfx[gfxObjId] = list; + } + list.Add(data); +} + +private List GetPooledList() +{ + lock (_listPool) + { + if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++]; + var fresh = new List(); + _listPool.Add(fresh); + _poolIndex++; + return fresh; + } +} +``` + +**Note:** `InstanceData.CellId` field — verify our `src/AcDream.App/Rendering/Wb/InstanceData.cs` has this field. WB uses it for the cell-routing. If absent in our copy, ADD it (it's already in WB's InstanceData per `EnvCellRenderManager.cs:282-285` references). + +#### Step 5.4: `Render(WbRenderPass, HashSet?)` — port verbatim from WB EnvCellRenderManager.cs:395-511 + +Port the entire method byte-for-byte, but: +- Replace `MeshManager.TryGetRenderData(gfxObjId)` with our `_meshManager.TryGetRenderData(gfxObjId)` (same API after extraction). +- Replace `RenderModernMDI(_shader, drawCalls, allInstances, renderPass)` with a CALL to an internal `RenderModernMDIInternal` method copied next. +- Drop the highlights/selection rendering block at the end (lines 486-510) — we don't have SelectedInstance / HoveredInstance in EnvCellRenderer (no editor selection state). +- Keep the `uFilterByCell` uniform set to 0 (matches WB). +- Drop the `_useModernRendering` fallback branch — our codebase asserts modern path at startup (Phase N.5). + +The skeleton: +```csharp +public unsafe void Render(WbRenderPass renderPass, HashSet? filter = null) +{ + if (!_initialized || _shader is null || _shader.Program == 0) return; + + lock (_renderLock) + { + var snapshot = _activeSnapshot; + _shader.Use(); + _poolIndex = snapshot.BatchedByCell.Count; // reset point + + _shader.SetUniformInt("uRenderPass", (int)renderPass); + _shader.SetUniformInt("uFilterByCell", 0); + + var allInstances = new List(); + var drawCalls = new List<(ObjectMeshManager.ObjectRenderData renderData, int count, int offset)>(); + + if (filter is null) + { + // Walk every cell in snapshot (no filter — used by outdoor RenderOutsideIn). + foreach (var (cellId, gfxDict) in snapshot.BatchedByCell) + foreach (var (gfxObjId, transforms) in gfxDict) + { + var rd = _meshManager.TryGetRenderData(gfxObjId); + if (rd != null && !rd.IsSetup) + { + drawCalls.Add((rd, transforms.Count, allInstances.Count)); + allInstances.AddRange(transforms); + } + } + } + else + { + // Group by gfxObjId across the filtered cells (minimizes draw calls). + var filteredGroups = new Dictionary>(); + var ownedLists = new HashSet>(); + + foreach (var cellId in filter) + { + if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue; + foreach (var (gfxObjId, transforms) in gfxDict) + { + if (transforms.Count == 0) continue; + if (!filteredGroups.TryGetValue(gfxObjId, out var list)) + { + list = transforms; + filteredGroups[gfxObjId] = list; + } + else + { + if (list == transforms) continue; + if (!ownedLists.Contains(list)) + { + var owned = new List(list); + list = owned; + filteredGroups[gfxObjId] = list; + ownedLists.Add(list); + } + list.AddRange(transforms); + } + } + } + + foreach (var (gfxObjId, transforms) in filteredGroups) + { + var rd = _meshManager.TryGetRenderData(gfxObjId); + if (rd != null && !rd.IsSetup) + { + drawCalls.Add((rd, transforms.Count, allInstances.Count)); + allInstances.AddRange(transforms); + } + } + } + + if (allInstances.Count > 0) + RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass); + + _shader.SetUniformVec4("uHighlightColor", Vector4.Zero); + _gl.BindVertexArray(0); + + // Diagnostic probe ([envcells] is emitted by GameWindow at the call site + // since it needs to know our-buildings / other-buildings counts. We do + // expose LastFrameStats so the probe can read tris/cells). + _lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count; + _lastFrameStats.TrianglesDrawn = drawCalls.Sum(d => d.renderData.IndexCount / 3 * d.count); + } +} + +public LastFrameStats Stats => _lastFrameStats; +public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; } +private LastFrameStats _lastFrameStats; +``` + +#### Step 5.5: `RenderModernMDIInternal` — extract from WB BaseObjectRenderManager + +Open `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs`, search for `protected unsafe void RenderModernMDI` (it's a protected method around line 700-800). Read the method top-to-bottom. Copy it as a private method on `EnvCellRenderer`, stripping the multi-slot ring-buffer (we use one slot) and the consolidated-MDI dirty tracking (we re-upload every frame). + +The essential algorithm is: +1. Build `DrawElementsIndirectCommand[]` from `drawCalls`, each command refs `renderData.FirstIndex` + `renderData.BaseVertex`. +2. Build `ModernBatchData[]` from `drawCalls`, each batch refs surface metadata id, instance buffer offset. +3. Upload `allInstances` to `_modernInstanceBuffer`. +4. Upload commands to `_mdiCommandBuffer`. +5. Bind global VAO/IBO (from ObjectMeshManager via `_meshManager.GetGlobalIBO`/`GetGlobalVAO`). +6. Bind SSBO bindings. +7. Issue `glMultiDrawElementsIndirect`. + +Use existing `ObjectMeshManager` accessors for the global mesh buffer. If they don't exist as public, add them (return `uint VAO`, `uint IBO`). + +- [ ] **Step 5.6: Write unit tests** + +```csharp +// tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class EnvCellRendererTests +{ + // These tests cover the pure data-handling portions of EnvCellRenderer. + // The Render() and RenderModernMDIInternal() paths require a GL context + // and are visual-verified at the render frame, not here. + + [Fact] + public void NewRenderer_HasEmptySnapshot() + { + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + Assert.True(r.NeedsPrepare); + } + + [Fact] + public void GetEnvCellGeomId_Deterministic() + { + var surfaces = new List { 1, 2, 3 }; + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, surfaces); + var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, surfaces); + Assert.Equal(a, b); + Assert.NotEqual(0UL, a & 0x2_0000_0000UL); // dedup bit set + } + + [Fact] + public void GetEnvCellGeomId_DiffersByInputs() + { + var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1, 2, 3 }); + var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List { 1, 2, 3 }); + var c = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List { 1, 2, 4 }); + Assert.NotEqual(a, b); + Assert.NotEqual(a, c); + } + + // (more tests for AddToCellGroup, PopulatePartGroups dispatch, etc.) +} +``` + +- [ ] **Step 5.7: Build + test** + +Run: `dotnet build && dotnet test --filter "EnvCellRendererTests"` +Expected: all tests pass. + +- [ ] **Step 5.8: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — EnvCellRenderer (WB EnvCellRenderManager port) + +The core port: PrepareRenderBatches + Render(filter:) ported byte-for-byte +from WB EnvCellRenderManager.cs:247-511. Inline RenderModernMDIInternal +extracted from BaseObjectRenderManager (single-slot variant, drops the +3-slot ring used by WB's consolidated MDI). NOT inheriting from +ObjectRenderManagerBase — exposes RegisterCell(...) as the seam so our +existing streaming pipeline (StreamingController + LandblockStreamer) +populates the instance store at the existing landblock-load point in +GameWindow.BuildInteriorEntitiesForStreaming. + +This is what RR7 should have done: render cells through their own pipeline +call, not through the per-GfxObj-batched WbDrawDispatcher. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6: Wire `EnvCellRenderer` into landblock streaming + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (constructor + `BuildInteriorEntitiesForStreaming` + `RemoveLandblock` paths) + +- [ ] **Step 6.1: Add field + init** + +In GameWindow.cs, near line 159 where `_buildingRegistries` is declared, add: + +```csharp +// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render +// through this dedicated pipeline now, not through WbDrawDispatcher. +private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer; +``` + +In the constructor (find the spot where `_wbMeshAdapter` is created), add: + +```csharp +_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer( + _glContext.GL, + _wbMeshAdapter.MeshManager, + new AcDream.App.Rendering.Wb.WbFrustum()); + +// Initialize after the cell-mesh shader is loaded. +// Use the existing modern-mesh shader (mesh_modern.{vert,frag}) — that's +// what WB uses for env-cell rendering too. +_envCellRenderer.Initialize(_meshModernShader); // resolve the shader handle +``` + +- [ ] **Step 6.2: Modify `BuildInteriorEntitiesForStreaming`** + +At `GameWindow.cs:5401-5435`, replace the cell-as-WorldEntity creation block. Old: + +```csharp +var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); +if (cellSubMeshes.Count > 0) +{ + _pendingCellMeshes[envCellId] = cellSubMeshes; + ... + var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform); + var cellEntity = new AcDream.Core.World.WorldEntity { ... MeshRefs = new[] { cellMeshRef } }; + result.Add(cellEntity); + ... +} +``` + +New: + +```csharp +// Phase A8 (2026-05-28): cells render through EnvCellRenderer, not as +// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for +// physics (PhysicsDataCache uses it). The renderer registration replaces +// the WorldEntity creation entirely. +var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); +if (cellSubMeshes.Count > 0) +{ + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + + // Walk this cell's static objects + their transforms. + var stabs = new List<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>(); + foreach (var stab in envCell.StaticObjects) + { + var worldPos = stab.Frame.Origin + lbOffset; + var worldRot = stab.Frame.Orientation; + var stabTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(worldRot) * + System.Numerics.Matrix4x4.CreateTranslation(worldPos); + bool isSetup = (stab.Id & 0xFF000000u) == 0x02000000u; + stabs.Add((stab.Id, worldPos, worldRot, isSetup, stabTransform)); + } + + _envCellRenderer!.RegisterCell( + landblockId: landblockId, + envCellId: envCellId, + envCell: envCell, + cellStruct: cellStruct, + cellTransform: cellTransform, + cellWorldPosition: cellOrigin, + cellRotation: envCell.Position.Orientation, + staticObjects: stabs); + + // Step 4: build LoadedCell for portal visibility (unchanged from pre-A8). + BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); + + // Cache CellStruct physics BSP for indoor collision (unchanged). + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); +} +``` + +**Delete** the static-object-as-WorldEntity creation block at lines 5440-5489 — those entities are now registered with `EnvCellRenderer.RegisterCell` inside the cell loop above (via the `stabs` list). + +- [ ] **Step 6.3: Call `FinalizeLandblock` after the cell loop** + +Where `ApplyLoadedTerrain` finishes building the landblock (just before the closing brace at ~line 5856-5857), call: + +```csharp +_envCellRenderer?.FinalizeLandblock(lb.LandblockId); +``` + +- [ ] **Step 6.4: Wire `RemoveLandblock` callback** + +In the `removeTerrain` callback near `GameWindow.cs:1844`: + +```csharp +_envCellRenderer?.RemoveLandblock(id); // Phase A8 +``` + +- [ ] **Step 6.5: Build green; run tests** + +Run: `dotnet build && dotnet test` +Expected: all pre-existing tests still pass; the build does not regress on test count. + +- [ ] **Step 6.6: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — wire EnvCellRenderer into landblock streaming + +BuildInteriorEntitiesForStreaming no longer creates cell-as-WorldEntity +records. EnvCellRenderer.RegisterCell is called per cell + per static +object; FinalizeLandblock is called once per landblock load; +RemoveLandblock is called on unload. CellMesh.Build is kept (physics +still uses it via _physicsDataCache.CacheCellStruct). + +The broken MeshRef(envCellId) WorldEntity path that all four RR7 variants +inherited is gone. Cells now go through EnvCellRenderer.Render(filter:) +exclusively, which routes through ObjectMeshManager's _renderData under +the correct deduplicated cellGeomId key. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7: `IndoorCellStencilPipeline.RenderBuildingStencilMask` + +**Files:** +- Modify: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` +- Test: `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` (extend) + +- [ ] **Step 7.1: Add the new low-level method** + +Append to `IndoorCellStencilPipeline`: + +```csharp +/// +/// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB +/// PortalRenderManager.RenderBuildingStencilMask at references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484. +/// +/// Uploads the building's exit-portal mesh to our shared VBO and draws +/// it with the portal_stencil shader. Does NOT set or restore any +/// surrounding GL state — caller is responsible (stencil func, depth +/// mask, color mask, cull face, etc.) per WB VisibilityManager.RenderInsideOut +/// Steps 1/2/5a/5b/5d expectations. +/// +/// Mirrors the WB call signature: (building, vp, writeFarDepth). +/// The writeFarDepth flag sets the shader uniform that controls whether +/// gl_FragDepth = 1.0 is written (Step 2 punch) or default depth +/// (Step 1 mark). +/// +public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth) +{ + int vertexCount = UploadBuildingPortalMesh(building); + if (vertexCount == 0) return; + + _gl.Enable(EnableCap.DepthClamp); + + _shader.Use(); + var vp = viewProjection; + _gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp); + _gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0); + + _gl.BindVertexArray(_vao); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount); + _gl.BindVertexArray(0); + + _gl.Disable(EnableCap.DepthClamp); + + // Diagnostic probe (caller-driven, surfaces last upload count). + LastStencilVertexCount = vertexCount; + LastStencilWasFarPunch = writeFarDepth; + LastStencilBuildingId = building.BuildingId; +} + +// Probe data — read by the [stencil] probe emitter in GameWindow. +public int LastStencilVertexCount { get; private set; } +public bool LastStencilWasFarPunch { get; private set; } +public uint LastStencilBuildingId { get; private set; } +``` + +The existing `MarkAndPunch` / `EnableOutdoorPass` / `MarkBuildingBit2` / `PunchDepthAtStencil3` / `EnableOtherBuildingPass` / `ResetBit2` methods stay — they bundle their own state setup and remain useful for other consumers / future work. The new `RenderBuildingStencilMask` is the WB-faithful low-level entry that `RenderInsideOutAcdream` calls. + +- [ ] **Step 7.2: Add unit test for the contract** + +Test the method's existence + that it returns early if building has no portals. (Full GL behavior is visual-verified.) + +```csharp +[Fact] +public void RenderBuildingStencilMask_EmptyBuilding_NoCrash() +{ + // Headless test — only verifies the early-out path. Real GL calls + // happen on the render thread. + var b = new Building + { + BuildingId = 1, + EnvCellIds = new HashSet(), + ExitPortalPolygons = new List(), // empty + }; + // Construct pipeline with mock GL not feasible here — verify by + // direct upload semantics: UploadBuildingPortalMesh returns 0 for + // empty list. + // (Skip this test if the pipeline ctor requires a real GL context; + // the contract is exercised by the visual launch.) +} +``` + +- [ ] **Step 7.3: Build green; run existing tests** + +Run: `dotnet build && dotnet test --filter "IndoorCellStencilPipeline"` +Expected: existing 9 tests still pass. + +- [ ] **Step 7.4: Commit** + +```bash +git add src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — RenderBuildingStencilMask low-level entry + +Mirrors WB PortalRenderManager.RenderBuildingStencilMask:471-484. Pure +upload + draw with no surrounding GL state setup, matching WB's +RenderInsideOut step expectations. Existing MarkAndPunch / Mark/Reset +helpers stay for other consumers. + +LastStencil* probe fields surface the most recent draw's vertex count, +building id, and write-far-depth flag for the [stencil] probe emitter. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 8: Implement `RenderInsideOutAcdream` in GameWindow.cs + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (find the render frame method — search for `_wbDrawDispatcher.Draw` to locate it) + +- [ ] **Step 8.1: Locate the render frame** + +Use grep to find the main render method: +```bash +grep -n "_wbDrawDispatcher\.Draw\|private.*RenderFrame\|protected override.*Render" src/AcDream.App/Rendering/GameWindow.cs +``` + +The render frame is where the dispatcher's `Draw(set: All)` is called once per frame in the outdoor path. Identify that block. + +- [ ] **Step 8.2: Compute the `cameraInsideBuilding` gate** + +Just before the existing render block, add (replace any old `cameraInsideCell` etc. computations introduced by the reverted RR7): + +```csharp +// Phase A8 (2026-05-28): strict camera-inside-building gate. +// NO grace. Requires the cell to actually be a building (BuildingId != null +// per RR4 stamping). Other-cell paths flow through the outdoor branch. +var visibility = _cellVisibility.LastVisibilityResult; +var camPos = _camera.Position; +bool cameraInsideBuilding = + visibility?.CameraCell is not null + && CellVisibility.PointInCell(camPos, visibility.CameraCell) + && visibility.CameraCell.BuildingId is not null; + +// Resolve the camera's buildings (a single cell may be in multiple buildings). +List camBuildings = new(); +if (cameraInsideBuilding) +{ + uint lbId = visibility!.CameraCell!.CellId & 0xFFFF0000u; + if (_buildingRegistries.TryGetValue(lbId, out var reg)) + { + foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId)) + camBuildings.Add(b); + } +} + +// Resolve the OTHER buildings in view (used by Step 5). +List otherBuildings = new(); +if (cameraInsideBuilding) +{ + var camCellId = visibility!.CameraCell!.CellId; + foreach (var reg in _buildingRegistries.Values) + foreach (var b in reg.All()) + if (!b.EnvCellIds.Contains(camCellId)) + otherBuildings.Add(b); + // (Frustum-test other-buildings here if perf matters; A8 doesn't gate on + // it — Step 5 already uses occlusion queries.) +} +``` + +- [ ] **Step 8.3: PrepareRenderBatches on EnvCellRenderer** + +Before entering the indoor branch, call: + +```csharp +var viewProj = _camera.ViewMatrix * _camera.ProjectionMatrix; +_envCellRenderer!.PrepareRenderBatches(viewProj, camPos); // unfiltered prep +``` + +- [ ] **Step 8.4: Replace the render block with the gate** + +```csharp +if (cameraInsideBuilding) +{ + RenderInsideOutAcdream(viewProj, camPos, visibility!.CameraCell!, camBuildings, otherBuildings); +} +else +{ + // Existing outdoor path (sky + terrain + dispatcher Draw(set: All)). + // No call to _envCellRenderer here — we don't render env cells outdoors yet. + // (Future: RenderOutsideIn for cottage windows. Deferred to a later phase.) + RenderOutdoorPath(viewProj, camPos); // wrap existing code into a helper if not already +} + +// LiveDynamic always last — player + NPCs + dropped items, depth-test only. +_wbDrawDispatcher.Draw(_camera, ..., set: EntitySet.LiveDynamic); +``` + +- [ ] **Step 8.5: Implement `RenderInsideOutAcdream` — byte-for-byte from WB** + +This is the most critical method. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` and port it verbatim with these substitutions: + +- `_gl` → `_glContext.GL` +- `_buildingsWithCurrentCell` → `camBuildings` (parameter) +- `_otherBuildings` / `_visibleBuildingPortals` → `otherBuildings` (parameter) +- `portalManager?.RenderBuildingStencilMask(building, snapshotVP, false)` → `_indoorStencilPipeline!.RenderBuildingStencilMask(building, viewProj, writeFarDepth: false)` +- `envCellManager!.Render(pass1RenderPass, _currentEnvCellIds)` → `_envCellRenderer!.Render(WbRenderPass.Opaque, _currentEnvCellIds)` +- `envCellManager!.Render(RenderPass.Transparent, ...)` → `_envCellRenderer!.Render(WbRenderPass.Transparent, ...)` +- `terrainManager.Render(...)` → `_terrain!.Render(...)` (existing acdream terrain renderer) +- `sceneryManager?.Render(pass1RenderPass)` → `_wbDrawDispatcher!.Draw(_camera, ..., set: EntitySet.OutdoorScenery)` +- `staticObjectManager?.Render(pass1RenderPass)` → folded into the same `Draw(set: OutdoorScenery)` (our EntitySet partition covers both) +- `sceneryShader?.Bind()` → `_meshModernShader.Use()` (the modern mesh shader is acdream's analog) +- `state.ShowScenery` / `state.ShowStaticObjects` / `state.ShowBuildings` → always true (editor toggles; we're a game client) +- `state.EnableTransparencyPass` → `true` (we want transparency for stained glass / windows; matches WB's default) + +The method skeleton: + +```csharp +private void RenderInsideOutAcdream( + Matrix4x4 viewProj, + Vector3 camPos, + LoadedCell cameraCell, + List camBuildings, + List otherBuildings) +{ + var gl = _glContext.GL; + bool didInsideStencil = false; + + EmitDrawOrderProbe(step: 0, before: true); // entry probe + + if (camBuildings.Count > 0) + { + didInsideStencil = true; + gl.Enable(EnableCap.StencilTest); + gl.ClearStencil(0); + gl.Clear(ClearBufferMask.StencilBufferBit); + + // Step 1: stencil bit 1 at our buildings' portals. + gl.Disable(EnableCap.CullFace); + gl.StencilFunc(StencilFunction.Always, 1, 0xFFu); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + gl.StencilMask(0x01u); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.Enable(EnableCap.DepthTest); + gl.DepthFunc(DepthFunction.Always); + + EmitDrawOrderProbe(step: 1); + foreach (var b in camBuildings) + _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + + // Step 2: punch depth at portals. + gl.DepthMask(true); + gl.DepthFunc(DepthFunction.Always); + + EmitDrawOrderProbe(step: 2); + foreach (var b in camBuildings) + _indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true); + } + + // Step 3: render the camera-buildings' cells. + gl.ColorMask(true, true, true, false); + gl.DepthMask(true); + gl.Disable(EnableCap.StencilTest); + gl.DepthFunc(DepthFunction.Less); + _meshModernShader.Use(); + + EmitDrawOrderProbe(step: 3); + HashSet currentEnvCellIds = new(); + if (camBuildings.Count > 0) + { + foreach (var b in camBuildings) + foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id); + _envCellRenderer!.Render(WbRenderPass.Opaque, currentEnvCellIds); + + // Transparency pass. + gl.DepthMask(false); + _envCellRenderer!.Render(WbRenderPass.Transparent, currentEnvCellIds); + gl.DepthMask(true); + } + + // Step 4: stencil-gated outdoor (terrain + scenery + static objects). + if (didInsideStencil) + { + gl.Enable(EnableCap.StencilTest); + gl.StencilFunc(StencilFunction.Equal, 1, 0x01u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep); + gl.StencilMask(0x00u); + gl.ColorMask(true, true, true, false); + gl.DepthMask(true); + gl.Enable(EnableCap.CullFace); + gl.DepthFunc(DepthFunction.Less); + } + + EmitDrawOrderProbe(step: 4); + _terrain?.Render(viewProj /* + the rest of acdream's terrain render args */); + _meshModernShader.Use(); + _wbDrawDispatcher!.Draw(_camera, _worldState.AllLandblocks /* + the existing args */, + set: EntitySet.OutdoorScenery); + + // Step 5: per-other-building 3-bit stencil pipeline. + if (didInsideStencil && otherBuildings.Count > 0) + { + gl.Enable(EnableCap.StencilTest); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.DepthFunc(DepthFunction.Lequal); + + foreach (var b in otherBuildings) + { + // Occlusion-query read-back (same as WB). + _indoorStencilPipeline!.EnsureOcclusionQueryId(ref b.QueryId); + if (b.QueryStarted && + _indoorStencilPipeline.TryReadOcclusionResult(b.QueryId, out bool anyPassed)) + { + b.WasVisible = anyPassed; + } + _indoorStencilPipeline.BeginOcclusionQuery(b.QueryId); + b.QueryStarted = true; + + EmitDrawOrderProbe(step: 5, sub: 'a'); + + // Step 5a: mark bit 2 (Ref=3, Mask=0x02). + gl.StencilFunc(StencilFunction.Equal, 3, 0x01u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + gl.StencilMask(0x02u); + gl.Disable(EnableCap.CullFace); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + _indoorStencilPipeline.EndOcclusionQuery(); + + // Step 5b: clear depth at stencil==3. + EmitDrawOrderProbe(step: 5, sub: 'b'); + gl.StencilFunc(StencilFunction.Equal, 3, 0x03u); + gl.StencilMask(0x00u); + gl.DepthMask(true); + gl.DepthFunc(DepthFunction.Always); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true); + + // Step 5c: render this building's cells where stencil==3. + EmitDrawOrderProbe(step: 5, sub: 'c'); + gl.ColorMask(true, true, true, false); + gl.DepthFunc(DepthFunction.Less); + gl.Enable(EnableCap.CullFace); + _meshModernShader.Use(); + _envCellRenderer.Render(WbRenderPass.Opaque, b.EnvCellIds); + gl.DepthMask(false); + _envCellRenderer.Render(WbRenderPass.Transparent, b.EnvCellIds); + gl.DepthMask(true); + + // Step 5d: reset bit 2. + EmitDrawOrderProbe(step: 5, sub: 'd'); + gl.ColorMask(false, false, false, false); + gl.DepthMask(false); + gl.StencilMask(0x02u); + gl.StencilFunc(StencilFunction.Always, 1, 0x02u); + gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + _indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false); + } + gl.DepthFunc(DepthFunction.Less); + } + + // Cleanup. + if (didInsideStencil) + { + gl.Disable(EnableCap.StencilTest); + gl.StencilMask(0xFFu); + gl.ColorMask(true, true, true, false); + } + + EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count); +} +``` + +- [ ] **Step 8.6: Add a stencil-buffer clear at frame start** + +Find the `glClear` at frame start (`grep -n "ClearBufferMask\.ColorBufferBit\|ClearBufferMask\.DepthBufferBit" src/AcDream.App/Rendering/GameWindow.cs`). Add `| ClearBufferMask.StencilBufferBit` so stencil starts at 0 each frame. + +- [ ] **Step 8.7: Build green** + +Run: `dotnet build` +Expected: clean build. Compile errors here MUST be resolved before moving to Task 9. + +- [ ] **Step 8.8: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — RenderInsideOutAcdream byte-for-byte WB port + +Replicates WB VisibilityManager.RenderInsideOut Steps 1-5 verbatim from +references/WorldBuilder/.../VisibilityManager.cs:73-239. Strict +cameraInsideBuilding gate (no grace). Step 5 includes the full +3-bit-stencil + occlusion-query cross-building visibility loop. + +Frame-start glClear now includes stencil-buffer-bit so stencil starts +at 0 each frame (RR7 missed this). + +Probe emitters wired ([draworder]/[envcells]) — gated on +ACDREAM_PROBE_VIS=1. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 9: Probes + diagnostic infrastructure + +**Files:** +- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (probe emitter helpers) + +- [ ] **Step 9.1: Add `ProbeEnvCellEnabled` flag** + +In `RenderingDiagnostics.cs`: + +```csharp +private static bool _probeEnvCellEnabled = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; +public static bool ProbeEnvCellEnabled +{ + get => _probeEnvCellEnabled || ProbeVisibilityEnabled; // [envcells] also rides on PROBE_VIS + set => _probeEnvCellEnabled = value; +} +``` + +- [ ] **Step 9.2: Wire the probe emitters in GameWindow** + +Add these methods near the `EmitVisibilityProbe` helper (or wherever the existing [vis] probe lives): + +```csharp +private int _drawOrderFrame = 0; + +private void EmitDrawOrderProbe(int step, char sub = ' ', bool before = false) +{ + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; + var gl = _glContext.GL; + gl.GetInteger(GLEnum.StencilTest, out int stOn); + gl.GetInteger(GLEnum.DepthFunc, out int depthFn); + gl.GetBoolean(GLEnum.DepthWritemask, out var depthMask); + Console.WriteLine( + $"[draworder] frame={_drawOrderFrame} step={step}{(sub != ' ' ? sub.ToString() : "")} " + + $"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask}"); +} + +private void EmitEnvCellProbe(int ourBldgs, int otherBldgs, int filterCnt) +{ + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeEnvCellEnabled) return; + var stats = _envCellRenderer?.Stats ?? default; + Console.WriteLine( + $"[envcells] cells={stats.CellsRendered} tris={stats.TrianglesDrawn} " + + $"ourBldgs={ourBldgs} otherBldgs={otherBldgs} filterCnt={filterCnt}"); +} + +private void EmitStencilProbe(string op) +{ + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; + if (_indoorStencilPipeline == null) return; + Console.WriteLine( + $"[stencil] op={op} bld=0x{_indoorStencilPipeline.LastStencilBuildingId:X8} " + + $"verts={_indoorStencilPipeline.LastStencilVertexCount}"); +} + +private void EmitBuildingsProbe(uint? camCellId, IList camBldgs, int otherCount, int totalKnown) +{ + if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return; + var ids = string.Join(",", camBldgs.Select(b => $"0x{b.BuildingId:X}")); + Console.WriteLine( + $"[buildings] camCell={(camCellId.HasValue ? $"0x{camCellId.Value:X8}" : "null")} " + + $"camBldgs=[{ids}] otherBldgs={otherCount} totalKnown={totalKnown}"); +} +``` + +Call `EmitStencilProbe` after each `RenderBuildingStencilMask` call site in `RenderInsideOutAcdream`. Call `EmitBuildingsProbe` at the very top of that method. + +Increment `_drawOrderFrame` once per render frame in the outermost render method. + +- [ ] **Step 9.3: Build green** + +Run: `dotnet build` +Expected: clean build. + +- [ ] **Step 9.4: Commit** + +```bash +git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs +git commit -m "$(cat <<'EOF' +feat(render): Phase A8 — probe trail ([envcells]/[stencil]/[draworder]/[buildings]) + +Mandatory probe-before-launch infrastructure (process rule from the RR7 +saga: "no visual-gate launch without probe data first"). + +[envcells] fires once per indoor frame: cells/tris drawn + our/other + buildings counts + filter cardinality +[stencil] fires per RenderBuildingStencilMask: vertex count + building + id + write-far-depth flag +[draworder] fires at each step boundary: step number/sub + stencil + on/off + depth func/mask +[buildings] fires once per indoor frame: camera cell + camera-buildings + ids + other-buildings count + total known buildings + +Gates: ACDREAM_PROBE_VIS=1 (everything) OR ACDREAM_PROBE_ENVCELL=1 +([envcells] only). Runtime-toggleable via DebugPanel. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 10: Build green + full test suite + visual gate + +**Files:** +- None (verification + launch) + +- [ ] **Step 10.1: Final build** + +```bash +dotnet build src/AcDream.App/AcDream.App.csproj +``` +Expected: clean build, zero errors. + +- [ ] **Step 10.2: Run full test suite** + +```bash +dotnet test +``` +Expected: at minimum the pre-A8 baseline (1178 + 8) holds. New A8 tests pass (Frustum + SceneryInstance + EnvCellRenderer = ~10-15 new tests). No regressions. + +- [ ] **Step 10.3: Launch the client for visual verification** + +```powershell +$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 "a8-wb-port-launch.log" +``` + +Run in background (`run_in_background: true` on the Bash call). + +- [ ] **Step 10.4: Read probe data BEFORE asking user for visual verification** + +```bash +grep -c "\[buildings\] camBldgs=\[0x" a8-wb-port-launch.log +grep "\[envcells\] cells=[1-9]" a8-wb-port-launch.log | head -5 +grep "\[stencil\] op=mark verts=[1-9]" a8-wb-port-launch.log | head -5 +grep "\[draworder\]" a8-wb-port-launch.log | head -20 +``` + +Acceptance: +- `[buildings] camBldgs=[0x...]` non-empty for at least one frame while the user is inside a cottage +- `[envcells] cells>=1 tris>=1 filterCnt>=1` for at least one frame inside +- `[stencil] op=mark verts>0` fires per camera-building +- `[draworder]` shows the full Step 1 → 2 → 3 → 4 → 5 cycle per indoor frame + +**If probes don't show what we expect, DO NOT ask the user for visual verification.** Investigate the gap (which probe is missing → which code path failed) before relaunching. + +- [ ] **Step 10.5: Ask user to verify** + +Provide the user with these scenarios to test: +- Cottage interior (ground floor): walls solid, sky through windows +- Cottage cellar: cottage floor solid above (no transparent floor) +- Holtburg inn (multi-room): walls solid, no cross-room leak +- Dungeon corridor: walls solid (cells without BuildingId — verify outdoor branch handles them via fallback) +- Exit transition (indoor → outdoor): clean, no through-ground flicker +- Entry transition (outdoor → indoor): clean +- Cross-building (Step 5): stand inside inn, look through window at a cottage across the street — cottage interior visible through both windows + +- [ ] **Step 10.6: Ship handoff doc** + +Once visual is confirmed, write: +- `docs/research/2026-05-28-phase-a8-wb-port-shipped-handoff.md` — what shipped, evidence, what's open (e.g., RenderOutsideIn deferred) +- Update `CLAUDE.md` "Currently working toward" line + add A8 ship paragraph +- Move issue #78 to closed in `docs/ISSUES.md` + +- [ ] **Step 10.7: Final commit** + +```bash +git add docs/research/2026-05-28-phase-a8-wb-port-shipped-handoff.md docs/ISSUES.md CLAUDE.md +git commit -m "$(cat <<'EOF' +docs(a8): Phase A8 WB RenderInsideOut port — SHIPPED + +Visual-verified $(date +%Y-%m-%d) at Holtburg cottages + inn. All +acceptance scenarios pass. Closes #78. RenderOutsideIn (outdoor +camera looking into cottage windows) deferred to a follow-up phase. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Risk register + +| Risk | Likelihood | Mitigation | +|---|---|---| +| `ObjectMeshManager.PrepareEnvCellGeomMeshDataAsync` not idempotent — re-registration causes mesh re-upload | Low | Internal dedupe by `_pendingEnvCellRequests` dict; idempotent per WB's design | +| `EnvCellRenderer.PrepareRenderBatches` thread-races with `RegisterCell` during live streaming | Medium | Both lock `lb.Lock`; `_renderLock` protects snapshot swap. Same locking pattern WB uses | +| Modern MDI buffer too small for large indoor scenes (e.g. inn with 40 cells) | Low | Auto-grow on overflow (mirror BaseObjectRenderManager); initial 1024 is comfortable for a Holtburg cottage | +| `_meshModernShader.Use()` clobbers other state set by prior step | Low | Shader bind doesn't touch GL state our pipeline cares about (only program binding) | +| Step 5 occlusion queries cause CPU stall | Low | Asynchronous read-back via `TryReadOcclusionResult` (prev-frame only); matches WB | +| Frame-start stencil clear breaks something else relying on stencil persisting | Low | Nothing else in our pipeline uses stencil; verify via grep before merging Step 8.6 | +| `BuildingId` not stamped on cells loaded across frames (RR7.1's bug) | Low | RR3-RR6 BFS is dat-driven (BuildingLoader.Build seeded by LandBlockInfo.Buildings); doesn't depend on cell-load timing | + +## Falsifiability — what tells us we failed + +If after Task 10's probe check: +- `[buildings] camBldgs=[]` (empty) while user reports they are inside a cottage → `BuildingId` stamping is broken. Debug at `BuildingLoader.Build` / `LandBlockInfo.Buildings` data. +- `[envcells] cells=0` while `[buildings] camBldgs=[0x1]` non-empty → `EnvCellRenderer.PrepareRenderBatches` not finding any registered cells. Debug at `RegisterCell` / `FinalizeLandblock`. +- `[envcells] cells=N tris=0` → `ObjectMeshManager.TryGetRenderData(cellGeomId)` returns null. Debug at the dedup-id mismatch between `RegisterCell` and the dispatcher's expected mesh key. +- Probes look correct but visual still wrong → likely a GL state issue between steps. Cross-reference `[draworder]` flags against WB `VisibilityManager.cs` expected state per step. + +Each failure has a deterministic next step. No "speculative fix → another launch" loop. + +--- + +## Out of scope (deferred follow-ups) + +- **RenderOutsideIn** — outdoor camera looking into cottage windows showing the cottage interior. WB `VisibilityManager.cs:241-358`. Same extraction patterns; lower priority because it's a polish feature, not an M1.5 blocker. +- **Editor highlights / selection in EnvCellRenderer** — we deliberately dropped this from Step 5.4. Not needed for a game client. +- **Per-instance reference-counting** — WB's `IncrementInstanceRefCounts` / `DecrementInstanceRefCounts` is needed for editor brush-tools (move-between-cells). We don't do that. + +--- + +## Self-review notes + +- **Placeholder scan:** every step has concrete code or exact grep commands. No "TBD" / "implement later" / "add error handling here" placeholders. +- **Type consistency:** `WbBoundingBox` defined once in Task 2, used in Tasks 3-8. `EnvCellLandblock` defined once in Task 3, used in Tasks 5-6. `WbRenderPass` defined in Task 1, consumed in Task 5+8. +- **WB line-number citations:** every verbatim port cites the WB source path + line range. Subagents follow the cited path. +- **Spec coverage:** the handoff doc's "Phase 1-5" map → my Tasks 1-5 (extract + build renderer), Task 6 (wire to streaming), Task 7 (stencil low-level), Task 8 (render-frame port), Task 9 (probes), Task 10 (verification). Every handoff requirement is covered. +- **Probe coverage:** the handoff doc's four required probe families (`[envcells]/[stencil]/[draworder]/[buildings]`) are all wired in Task 9. + +--- + +## Execution model + +**Recommended:** subagent-driven, two-stage review per task. + +Sonnet subagents per Task 1-9. Task 5 may want to be split into 5.1-5.5 separate subagents because of the size. Task 8 (render-frame port) is the riskiest — dispatch with explicit "read WB VisibilityManager.cs:73-239 byte-by-byte; do NOT improvise" instruction. + +After each subagent's commit, the dispatcher (this conversation) reads the diff before declaring done. If a subagent reports done but the diff is wrong, the dispatcher rejects + re-dispatches. + +Pre-flight before Task 10's visual gate: confirm probes fire as expected by inspecting the log offline — don't ask the user to verify until probe data correlates. + +**One visual launch.** Not four.