acdream/docs/superpowers/plans/2026-05-28-phase-a8-wb-render-inside-out-port.md
Erik 95f0d5267b docs(plan): Phase A8 WB RenderInsideOut port — implementation plan
Replaces the four reverted RR7 variants from 2026-05-27 with a
verbatim port of WB VisibilityManager.RenderInsideOut.

Plan covers 10 tasks across 5 dependency waves:
- Wave 1 (tasks 1-4, 7): extract WbRenderPass, WbFrustum,
  EnvCellSceneryInstance/EnvCellLandblock, EnvCellVisibilitySnapshot;
  add IndoorCellStencilPipeline.RenderBuildingStencilMask
- Wave 2 (task 5): build EnvCellRenderer with inline RenderModernMDI
- Wave 3 (task 6): wire EnvCellRenderer into landblock streaming
- Wave 4 (task 8): port RenderInsideOutAcdream byte-for-byte
- Wave 5 (task 9): probe trail [envcells]/[stencil]/[draworder]/[buildings]
- Wave 6 (task 10): probe-gated visual verification launch

Process rules carved from RR7 saga:
- No visual gate without probe data first
- No partial WB ports (Steps 1-5 ship together)
- No conceptual adaptations
- Trust-but-verify after every subagent

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

77 KiB

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-239RenderInsideOut (Steps 1-5)
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:247-373PrepareRenderBatches
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:395-511Render(RenderPass, HashSet<uint>?)
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:990-1103 — instance batching context (for inline RenderModernMDI helper)
  • references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484RenderBuildingStencilMask

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:

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

// src/AcDream.App/Rendering/Wb/WbRenderPass.cs
namespace AcDream.App.Rendering.Wb;

/// <summary>
/// Phase A8 (2026-05-28): WB's RenderPass enum, extracted verbatim from
/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs.
/// Renamed to <c>WbRenderPass</c> so it doesn't clash if we ever add an
/// acdream-side RenderPass with different semantics.
/// </summary>
public enum WbRenderPass
{
    /// <summary>The opaque pass. Only non-transparent objects are rendered.</summary>
    Opaque = 0,

    /// <summary>The transparent pass. Only transparent objects are rendered, usually after the opaque pass.</summary>
    Transparent = 1,

    /// <summary>
    /// A single-pass render that includes both opaque and (sometimes) transparent objects,
    /// or for special cases like skyboxes and certain UI elements.
    /// </summary>
    SinglePass = 2,
}
  • Step 2: Build green

Run: dotnet build src/AcDream.App/AcDream.App.csproj Expected: PASS — no consumers yet.

  • Step 3: Commit
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) <noreply@anthropic.com>
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.OpenGLSDLBackendAcDream.App.Rendering.Wb
  • Class name: FrustumWbFrustum (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
// 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
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) <noreply@anthropic.com>
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.LibAcDream.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<ulong gfxObjId, List<InstanceData>>. We need InstanceData from our existing src/AcDream.App/Rendering/Wb/InstanceData.cs.

  • Step 3: Write failing tests
// 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<InstanceData> GetOrAdd(this Dictionary<ulong, List<InstanceData>> 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
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) <noreply@anthropic.com>
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

// src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs
using System.Collections.Generic;

namespace AcDream.App.Rendering.Wb;

/// <summary>
/// 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).
/// </summary>
public sealed class EnvCellVisibilitySnapshot
{
    /// <summary>Landblocks fully or partially inside the frustum.</summary>
    public List<EnvCellLandblock> VisibleLandblocks { get; init; } = new();

    /// <summary>
    /// Grouped instance data by CellId.
    /// Key: full 32-bit CellId; Value: { GfxObjId: List&lt;InstanceData&gt; }.
    /// </summary>
    public Dictionary<uint, Dictionary<ulong, List<InstanceData>>> BatchedByCell { get; init; } = new();

    /// <summary>Whether this snapshot contains any visible cells.</summary>
    public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0;
}
  • Step 2: Build green

Run: dotnet build Expected: PASS — no consumers yet.

  • Step 3: Commit
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) <noreply@anthropic.com>
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

// 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<uint, EnvCellLandblock> _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<List<InstanceData>> _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

/// <summary>
/// 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 <see cref="FinalizeLandblock"/>
/// 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).
/// </summary>
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<EnvCellSceneryInstance>(capacity: 32);
        lb.PendingInstances.Add(cellInstance);
        lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
        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

public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? 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<EnvCellLandblock>();
    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<Dictionary<...>> for thread-local accumulators.
    //  Port verbatim — see EnvCellRenderManager.cs:265-325.)
    using var threadLocalBatchedByCell = new ThreadLocal<Dictionary<uint, Dictionary<ulong, List<InstanceData>>>>(
        () => 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<uint>();
            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<uint, Dictionary<ulong, List<InstanceData>>>();
    foreach (var local in threadLocalBatchedByCell.Values)
    {
        foreach (var kvp in local)
        {
            if (!newBatchedByCell.TryGetValue(kvp.Key, out var gfxDict))
            {
                gfxDict = new Dictionary<ulong, List<InstanceData>>();
                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<uint, Dictionary<ulong, List<InstanceData>>> dst,
    uint cellId, ulong gfxObjId, InstanceData data)
{
    if (!dst.TryGetValue(cellId, out var gfx))
    {
        gfx = new Dictionary<ulong, List<InstanceData>>();
        dst[cellId] = gfx;
    }
    if (!gfx.TryGetValue(gfxObjId, out var list))
    {
        list = new List<InstanceData>();
        gfx[gfxObjId] = list;
    }
    list.Add(data);
}

private List<InstanceData> GetPooledList()
{
    lock (_listPool)
    {
        if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++];
        var fresh = new List<InstanceData>();
        _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<uint>?) — 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:

public unsafe void Render(WbRenderPass renderPass, HashSet<uint>? 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<InstanceData>();
        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<ulong, List<InstanceData>>();
            var ownedLists = new HashSet<List<InstanceData>>();

            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<InstanceData>(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
// 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<ushort> { 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<ushort> { 1, 2, 3 });
        var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List<ushort> { 1, 2, 3 });
        var c = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 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
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) <noreply@anthropic.com>
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:

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

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

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:

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

_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
  • Step 6.4: Wire RemoveLandblock callback

In the removeTerrain callback near GameWindow.cs:1844:

_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
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) <noreply@anthropic.com>
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:

/// <summary>
/// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB
/// <c>PortalRenderManager.RenderBuildingStencilMask</c> at <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484</c>.
///
/// <para>Uploads the building's exit-portal mesh to our shared VBO and draws
/// it with the portal_stencil shader. <strong>Does NOT set or restore any
/// surrounding GL state</strong> — caller is responsible (stencil func, depth
/// mask, color mask, cull face, etc.) per WB <c>VisibilityManager.RenderInsideOut</c>
/// Steps 1/2/5a/5b/5d expectations.</para>
///
/// <para>Mirrors the WB call signature: <c>(building, vp, writeFarDepth)</c>.
/// The <c>writeFarDepth</c> flag sets the shader uniform that controls whether
/// <c>gl_FragDepth = 1.0</c> is written (Step 2 punch) or default depth
/// (Step 1 mark).</para>
/// </summary>
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.)

[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<uint>(),
        ExitPortalPolygons = new List<Vector3[]>(),  // 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
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) <noreply@anthropic.com>
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:

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

// 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<AcDream.App.Rendering.Wb.Building> 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<AcDream.App.Rendering.Wb.Building> 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:

var viewProj = _camera.ViewMatrix * _camera.ProjectionMatrix;
_envCellRenderer!.PrepareRenderBatches(viewProj, camPos);  // unfiltered prep
  • Step 8.4: Replace the render block with the gate
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
  • _buildingsWithCurrentCellcamBuildings (parameter)
  • _otherBuildings / _visibleBuildingPortalsotherBuildings (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.EnableTransparencyPasstrue (we want transparency for stained glass / windows; matches WB's default)

The method skeleton:

private void RenderInsideOutAcdream(
    Matrix4x4 viewProj,
    Vector3 camPos,
    LoadedCell cameraCell,
    List<AcDream.App.Rendering.Wb.Building> camBuildings,
    List<AcDream.App.Rendering.Wb.Building> 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<uint> 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
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) <noreply@anthropic.com>
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:

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

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<AcDream.App.Rendering.Wb.Building> 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
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) <noreply@anthropic.com>
EOF
)"

Task 10: Build green + full test suite + visual gate

Files:

  • None (verification + launch)

  • Step 10.1: Final build

dotnet build src/AcDream.App/AcDream.App.csproj

Expected: clean build, zero errors.

  • Step 10.2: Run full test suite
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
$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
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

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) <noreply@anthropic.com>
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=0ObjectMeshManager.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.