acdream/docs/superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md
Erik ce7404b92b docs(render): R1 implementation plan — per-cell DrawInside (TDD)
Bite-sized plan for R1 (the per-cell DrawInside core): InteriorEntityPartition
(3-bucket, TDD), InteriorRenderer per-cell loop, the binary render decision in
OnRender (indoor = DrawInside only), and the :1756 bypass repurpose. Particles
deferred to R1b. Grounded in the live code surface (exact file:line + signatures).
Ends at the R1 user visual gate (sealed Holtburg cottage, no bleed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:45:50 +02:00

38 KiB
Raw Blame History

R1 — Per-cell DrawInside 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: When the player is inside an EnvCell, render the world through ONE per-cell DrawInside flood (a faithful port of retail PView::DrawCells) — per-cell closed shells + per-cell objects + live-dynamics, with the landscape pulled in only through the clipped doorway — so the cottage interior is sealed with no outdoor/entity bleed. This is phase R1 of the render-pipeline redesign (design spec: docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md).

Architecture: A new InteriorRenderer.DrawInside owns the per-cell loop (closest-first over PortalVisibilityFrame.OrderedVisibleCells): per cell, draw its shell (EnvCellRenderer.Render(pass, {cellId})) and its objects (WbDrawDispatcher.Draw scoped to that cell), then live-dynamics unclipped, then transparent shells. GameWindow.OnRender becomes a binary decision: indoor root → DrawInside (the existing landscape-through-door sky/terrain draw + Z-clear stays, with outdoor scenery now drawn clipped; the global entity pass + global shell pass are not issued); outdoor root → the existing global path (unchanged). Visibility is the cull — the outdoor world is never iterated when inside, so it cannot bleed. Particles are deferred to R1b (they need a cell link on the emitter — a Core data-model touch).

Tech Stack: C# .NET 10, Silk.NET OpenGL (GL 4.3 + bindless/MDI), xUnit (GL-free tests under tests/AcDream.App.Tests/). Reuses the kept components: PortalVisibilityBuilder, ClipFrameAssembler/ClipFrame, EnvCellRenderer mesh path, WbDrawDispatcher MDI path.

Render-verification discipline (read before starting): Rendering is verified on screen, not by unit tests (CLAUDE.md; the handoff lesson that produced this redesign). GL-free logic (the entity partition) is TDD'd. The GL orchestration (the DrawInside loop, the OnRender splice) is verified by: (a) dotnet build green, (b) the [shell]/[vis]/[cell-transit] probes, (c) the user's eyes at the R1 visual gate. Never declare the seal done off the test suite.


File Structure

File Responsibility New/Modified
src/AcDream.App/Rendering/InteriorEntityPartition.cs Pure: split a landblock's entities into 3 buckets (live-dynamic / per-cell statics / outdoor scenery) by the same precedence as ResolveEntitySlot. GL-free, unit-tested. Create
src/AcDream.App/Rendering/InteriorRenderer.cs The per-cell DrawInside orchestrator + its per-frame context record. Owns the closest-first loop; calls EnvCellRenderer.Render(pass,{cellId}) + WbDrawDispatcher.Draw per cell. Create
src/AcDream.App/Rendering/GameWindow.cs Wire-in: construct InteriorRenderer; the binary decision in OnRender (~75307554); the outdoor-scenery-clipped draw in the indoor landscape; the :1756 cleanup. Modify
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs Repurpose EntityPassesVisibleCellGate's ParentCellId==null → return true bypass (line ~1756). Modify
tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs Unit tests for the partition (3-bucket precedence, full-id keys, empty cases). Create

Decomposition rationale: the only genuinely unit-testable logic (the partition) is isolated in its own pure file. The per-cell loop is small and GL-driving — it lives in InteriorRenderer (Code Structure Rule 1: keep new feature bodies out of GameWindow.cs), wired into OnRender with a few lines. The landscape-through-door (sky/terrain/Z-clear) stays inline in OnRender — it already exists there with all its params and is already correctly ordered (sky → terrain → Z-clear); R1 only adds the outdoor-scenery-clipped draw to it and gates the entity/shell handling on the binary decision.


Task 1: Entity → cell partition (pure, TDD)

Files:

  • Create: src/AcDream.App/Rendering/InteriorEntityPartition.cs
  • Test: tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs

Why: The per-cell loop needs each visible cell's static entities, the outdoor scenery (drawn through the doorway), and the live-dynamics (drawn unclipped). Bucketing must match WbDrawDispatcher.ResolveEntitySlot's precedence exactly — serverGuid first (live-dynamics have no ParentCellId), then ParentCellId, else outdoor. This is pure logic; TDD it.

  • Step 1: Write the failing test
// tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.World;
using Xunit;

namespace AcDream.App.Tests.Rendering;

public class InteriorEntityPartitionTests
{
    private const uint CellA = 0xA9B40170;
    private const uint CellB = 0xA9B40171;

    private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
    {
        Id = id,
        ServerGuid = serverGuid,
        SourceGfxObjOrSetupId = 0x01000001,
        Position = Vector3.Zero,
        Rotation = Quaternion.Identity,
        MeshRefs = new[] { new MeshRef(0x01000001, 0) },
        ParentCellId = parentCell,
    };

    private static IEnumerable<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
        IReadOnlyDictionary<uint, WorldEntity>?)> OneLb(uint lbId, params WorldEntity[] ents)
        => new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList<WorldEntity>)ents,
                    (IReadOnlyDictionary<uint, WorldEntity>?)null) };

    [Fact]
    public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
    {
        var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null);   // live-dynamic
        var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
        var staticA = Ent(3, serverGuid: 0, parentCell: CellA);              // per-cell static
        var staticB = Ent(4, serverGuid: 0, parentCell: CellB);              // per-cell static
        var scenery = Ent(5, serverGuid: 0, parentCell: null);               // outdoor scenery

        var visible = new HashSet<uint> { CellA, CellB };
        var result = InteriorEntityPartition.Partition(
            visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));

        Assert.Equal(2, result.LiveDynamic.Count);                 // player + npc (serverGuid != 0)
        Assert.Contains(livePlayer, result.LiveDynamic);
        Assert.Contains(liveNpcInCell, result.LiveDynamic);

        Assert.Single(result.ByCell[CellA]);                       // only staticA (npc went live-dynamic)
        Assert.Contains(staticA, result.ByCell[CellA]);
        Assert.Single(result.ByCell[CellB]);
        Assert.Contains(staticB, result.ByCell[CellB]);

        Assert.Single(result.Outdoor);
        Assert.Contains(scenery, result.Outdoor);
    }

    [Fact]
    public void Static_InNonVisibleCell_IsDropped()
    {
        var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
        var visible = new HashSet<uint> { CellA };
        var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));

        Assert.False(result.ByCell.ContainsKey(0xA9B40199));
        Assert.Empty(result.Outdoor);
        Assert.Empty(result.LiveDynamic);
    }

    [Fact]
    public void EntityWithNoMeshRefs_IsSkipped()
    {
        var noMesh = new WorldEntity
        {
            Id = 9, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001,
            Position = Vector3.Zero, Rotation = Quaternion.Identity,
            MeshRefs = System.Array.Empty<MeshRef>(), ParentCellId = CellA,
        };
        var result = InteriorEntityPartition.Partition(
            new HashSet<uint> { CellA }, OneLb(0xA9B4FFFF, noMesh));

        Assert.False(result.ByCell.ContainsKey(CellA));
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter InteriorEntityPartitionTests Expected: FAIL — InteriorEntityPartition does not exist (compile error).

  • Step 3: Write the implementation
// src/AcDream.App/Rendering/InteriorEntityPartition.cs
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;

namespace AcDream.App.Rendering;

/// <summary>
/// Splits a frame's landblock entities into the three draw buckets the per-cell
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
/// <list type="number">
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
///   — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
///   — drawn through the doorway, clipped to OutsideView.</item>
/// </list>
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
/// </summary>
public static class InteriorEntityPartition
{
    public sealed class Result
    {
        public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
        public List<WorldEntity> Outdoor { get; } = new();
        public List<WorldEntity> LiveDynamic { get; } = new();
    }

    public static Result Partition(
        HashSet<uint> visibleCells,
        IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
                     IReadOnlyList<WorldEntity> Entities,
                     IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
    {
        var result = new Result();
        foreach (var entry in landblockEntries)
        {
            foreach (var e in entry.Entities)
            {
                if (e.MeshRefs.Count == 0) continue;

                if (e.ServerGuid != 0)            // live-dynamic — precedence first (no ParentCellId)
                {
                    result.LiveDynamic.Add(e);
                }
                else if (e.ParentCellId is uint cell)
                {
                    if (!visibleCells.Contains(cell)) continue;   // its cell isn't drawn this frame
                    if (!result.ByCell.TryGetValue(cell, out var list))
                        result.ByCell[cell] = list = new List<WorldEntity>();
                    list.Add(e);
                }
                else                              // outdoor scenery / building shell
                {
                    result.Outdoor.Add(e);
                }
            }
        }
        return result;
    }
}
  • Step 4: Run test to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter InteriorEntityPartitionTests Expected: PASS (3 tests).

  • Step 5: Commit
git add src/AcDream.App/Rendering/InteriorEntityPartition.cs tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs
git commit -m "feat(render): R1 — InteriorEntityPartition (3-bucket per-cell entity split)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: InteriorRenderer — the per-cell DrawInside loop

Files:

  • Create: src/AcDream.App/Rendering/InteriorRenderer.cs

Why: This is the per-cell flood (retail PView::DrawCells loops 2+3). It iterates OrderedVisibleCells closest-first and, per cell, draws the shell + that cell's objects; then live-dynamics unclipped; then transparent shells. It reuses the existing EnvCellRenderer.Render(pass, {cellId}) (single-cell filter, confirmed working) and WbDrawDispatcher.Draw (confirmed safe to call N×/frame — only diagnostic GPU-timing miscounts). No GL state setup here — the caller (OnRender) owns the clip-plane bracket + clip routing; each renderer sets its own self-contained GL state internally.

Retail anchor: PView::DrawCells @ 0x5a4840 (pc:432815432882) — Loop 2 (DrawEnvCell per visible cell) + Loop 3 (DrawObjCellForDummies per visible cell, PortalList set).

  • Step 1: Write the implementation
// src/AcDream.App/Rendering/InteriorRenderer.cs
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;

namespace AcDream.App.Rendering;

/// <summary>Per-frame inputs for one <see cref="InteriorRenderer.DrawInside"/> flood.</summary>
public sealed class InteriorRenderContext
{
    /// <summary>Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame.</summary>
    public required IReadOnlyList<uint> OrderedVisibleCells { get; init; }
    /// <summary>The 3-bucket entity split (Task 1). Only ByCell + LiveDynamic are used here;
    /// Outdoor scenery is drawn by the caller's landscape-through-door step.</summary>
    public required InteriorEntityPartition.Result Partition { get; init; }
    public required ICamera Camera { get; init; }
    public required FrustumPlanes? Frustum { get; init; }
    /// <summary>The full FFFF-suffixed landblock id of the player. Used as BOTH the synthetic
    /// per-cell entry id AND neverCullLandblockId so the degenerate (zero) synthetic AABB is never
    /// landblock-culled — per-entity frustum culling inside Draw still applies.</summary>
    public required uint? PlayerLandblockId { get; init; }
    public required HashSet<uint>? AnimatedEntityIds { get; init; }
}

/// <summary>
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL-state is
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
/// </summary>
public sealed class InteriorRenderer
{
    private readonly EnvCellRenderer _envCells;
    private readonly WbDrawDispatcher _entities;
    // Reused single-cell filter set — cleared+repopulated per cell to avoid per-frame allocs.
    private readonly HashSet<uint> _oneCell = new(1);

    public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
    {
        _envCells = envCells;
        _entities = entities;
    }

    public void DrawInside(InteriorRenderContext ctx)
    {
        // Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
        foreach (uint cellId in ctx.OrderedVisibleCells)
        {
            _oneCell.Clear();
            _oneCell.Add(cellId);
            _envCells.Render(WbRenderPass.Opaque, _oneCell);

            if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
                DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
        }

        // Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
        // Drawn AFTER opaque shells so wall depth occludes them correctly.
        if (ctx.Partition.LiveDynamic.Count > 0)
            DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);

        // Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
        foreach (uint cellId in ctx.OrderedVisibleCells)
        {
            _oneCell.Clear();
            _oneCell.Add(cellId);
            _envCells.Render(WbRenderPass.Transparent, _oneCell);
        }
    }

    // Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
    // landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
    // set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
    // The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
    // outdoorSlot + outdoorVisible) via ResolveEntitySlot.
    private void DrawEntityBucket(
        InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
    {
        // LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
        // never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
        uint lbId = ctx.PlayerLandblockId ?? 0u;
        var entry = (lbId, Vector3.Zero, Vector3.Zero,
                     (IReadOnlyList<WorldEntity>)bucket,
                     (IReadOnlyDictionary<uint, WorldEntity>?)null);

        _entities.Draw(
            ctx.Camera,
            new[] { entry },
            ctx.Frustum,
            neverCullLandblockId: ctx.PlayerLandblockId,
            visibleCellIds: visibleCellIds,
            animatedEntityIds: ctx.AnimatedEntityIds);
    }
}
  • Step 2: Verify it compiles

Run: dotnet build src/AcDream.App/AcDream.App.csproj Expected: build succeeds. (No behavior wired yet — InteriorRenderer is constructed in Task 3.)

If the Draw tuple shape mismatches: the dispatcher's Draw first parameter after camera is IEnumerable<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)> (WbDrawDispatcher.cs:643-645). The new[] { entry } array must match it exactly — adjust the tuple element types if the compiler complains, do NOT change Draw.

  • Step 3: Commit
git add src/AcDream.App/Rendering/InteriorRenderer.cs
git commit -m "feat(render): R1 — InteriorRenderer per-cell DrawInside loop (retail PView::DrawCells)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: Wire DrawInside into OnRender — the binary decision

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (field declaration near line ~166; construction where _envCellRenderer/_wbDrawDispatcher are built; the render splice ~75307554; the indoor-landscape outdoor-scenery draw ~7489)

Why: This is the inversion. When clipRoot != null (indoor root), the render must run ONLY the per-cell DrawInside flood — NOT the global entity pass + global shell pass. The landscape-through-door (sky ~7392, terrain ~7464, Z-clear ~7523) stays inline and already runs in the right order; we add the outdoor-scenery-clipped draw to it, then call DrawInside. When outdoor, the existing global path is unchanged.

Retail anchor: SmartBox::RenderNormalMode @ 0x453aa0 (pc:92635) — binary: viewer inside → DrawInside only; viewer outside → LScape::draw.

  • Step 1: Add the InteriorRenderer field

Find the field declarations near _envCellRenderer (GameWindow.cs ~166). Add:

private AcDream.App.Rendering.InteriorRenderer? _interiorRenderer; // R1: per-cell DrawInside flood
  • Step 2: Construct it where _envCellRenderer + _wbDrawDispatcher are created

Find where both _envCellRenderer and _wbDrawDispatcher have been assigned (search _wbDrawDispatcher = new). Immediately AFTER both exist, add:

// R1: per-cell DrawInside flood. Constructed once both renderers exist.
_interiorRenderer = new AcDream.App.Rendering.InteriorRenderer(_envCellRenderer, _wbDrawDispatcher);

If _envCellRenderer/_wbDrawDispatcher are nullable at that point, guard: if (_envCellRenderer is not null && _wbDrawDispatcher is not null) _interiorRenderer = new(...);

  • Step 3: Draw outdoor scenery clipped, as part of the indoor landscape (before the Z-clear)

Find the terrain draw block (GameWindow.cs ~74647489). It ends just before the [Stage 4] conditional doorway Z-clear block (~7515). Insert, AFTER the terrain else branch closes (~7489) and BEFORE the Z-clear (~7523):

// R1: outdoor scenery (ParentCellId == null) is part of the landscape seen through the doorway
// (retail LScape::draw draws the exterior, clipped to OutsideView). Drawn here — after terrain,
// BEFORE the Z-clear — only on an indoor root, scoped to the outdoor bucket. ResolveEntitySlot
// routes these (ParentCellId == null) to OutdoorSlot when OutdoorVisible, else CULLs them, via the
// SetClipRouting installed above. visibleCellIds: null ⇒ they pass the membership gate (no cell
// filter) and are gated purely by the clip slot. _interiorPartition is built in Step 4 below.
if (clipAssembly is not null && _interiorPartition is not null
    && _interiorPartition.Outdoor.Count > 0 && clipAssembly.OutdoorVisible)
{
    var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
                        (IReadOnlyList<AcDream.Core.World.WorldEntity>)_interiorPartition.Outdoor,
                        (IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
    _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum,
        neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds);
}

Note _interiorPartition and animatedIds must be in scope here. animatedIds is built at ~7507 (BEFORE this point — good). _interiorPartition is built in Step 4 (it must be computed BEFORE this block — see Step 4 placement).

  • Step 4: Build the partition in the indoor-root branch, and store it for Step 3

In the if (clipRoot is not null) block (~73117362), after envCellShellFilter is assigned (~7333), add the partition build. First add the field near the _interiorRenderer field (Step 1):

private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // R1: per-frame, indoor only

Then inside the clipRoot is not null block, after envCellShellFilter = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);:

// R1: partition this frame's entities into per-cell / outdoor / live-dynamic buckets for the
// DrawInside flood + the outdoor-scenery-through-door draw. Keyed by the SAME visible-cell set
// the shells use (cellIdToSlot.Keys).
_interiorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition(
    envCellShellFilter, _worldState.LandblockEntries);

In the else (outdoor root) branch (~73637371), clear it:

_interiorPartition = null;

Ordering check: the terrain/scenery/Z-clear block (Step 3) is at ~74647528, which runs AFTER the clipRoot block (~73117371). So _interiorPartition is built before Step 3 reads it. Good. But animatedIds is built at ~7507 — confirm Step 3's insertion point (~74897515) is AFTER ~7507. If animatedIds is built after your scenery draw, MOVE the animatedIds build (the ~7507 HashSet<uint>? animatedIds = ... block) to just before the terrain block.

  • Step 5: Replace the global shell + global entity draws with the binary decision

Find these three calls (GameWindow.cs ~7538, ~7546, ~7553):

// (~7538) opaque shells:
if (clipAssembly is not null && envCellShellFilter is not null)
    _envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, envCellShellFilter);

// (~7546) global entity pass:
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
    neverCullLandblockId: playerLb,
    visibleCellIds: visibility?.VisibleCellIds,
    animatedEntityIds: animatedIds);

// (~7553) transparent shells:
if (clipAssembly is not null && envCellShellFilter is not null)
    _envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, envCellShellFilter);

Replace ALL THREE with the binary decision:

// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
//   INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global entity pass
//     and global shell pass are NOT issued — visibility IS the cull, so the outdoor world cannot
//     bleed (it is never iterated; outdoor scenery entered above, clipped to the doorway).
//   OUTDOOR root: the existing global entity pass (no shells, no DrawInside).
if (clipRoot is not null && _interiorRenderer is not null && _interiorPartition is not null)
{
    var ctx = new AcDream.App.Rendering.InteriorRenderContext
    {
        OrderedVisibleCells = pvFrame.OrderedVisibleCells,
        Partition = _interiorPartition,
        Camera = camera,
        Frustum = frustum,
        PlayerLandblockId = playerLb,
        AnimatedEntityIds = animatedIds,
    };
    _interiorRenderer.DrawInside(ctx);
}
else
{
    // Outdoor root: the global entity pass (unchanged).
    _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
        neverCullLandblockId: playerLb,
        visibleCellIds: visibility?.VisibleCellIds,
        animatedEntityIds: animatedIds);
}

pvFrame scope: pvFrame is a local inside the clipRoot is not null block (~7316). To reference it at the splice (~7538), either (a) hoist its declaration: change var pvFrame = PortalVisibilityBuilder.Build(...) to assign a method-scoped PortalVisibilityFrame? pvFrame = null; declared alongside clipAssembly, then pvFrame = PortalVisibilityBuilder.Build(...) in the block; or (b) store pvFrame.OrderedVisibleCells into a method-scoped local IReadOnlyList<uint>? orderedVisibleCells = null; set in the block and used here. Use (a) — it's the cleaner hoist and keeps the splice readable. Update the splice to OrderedVisibleCells = pvFrame!.OrderedVisibleCells.

  • Step 6: Build

Run: dotnet build src/AcDream.App/AcDream.App.csproj Expected: build succeeds.

  • Step 7: Run the regression test suite (no new failures)

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS (existing render tests unaffected — the change is orchestration, not the kept components). If a test references the old call structure, it is asserting on internals the redesign changed — report it; do not silently delete.

  • Step 8: Commit
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(render): R1 — binary render decision, indoor = per-cell DrawInside only

When clipRoot != null, run only InteriorRenderer.DrawInside (per-cell shells +
per-cell objects + live-dynamics); the global entity pass + global shell pass are
no longer issued indoors. Outdoor scenery drawn clipped to the doorway. Outdoor
root path unchanged. Retail RenderNormalMode @ 0x453aa0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: Repurpose the :1756 ParentCellId==null bypass

Files:

  • Modify: src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs (line ~17391761)

Why: EntityPassesVisibleCellGate returns true unconditionally for ParentCellId == null entities when visibleCellIds is non-null (line ~1756) — historically the outdoor-scenery bleed. Under the R1 structure the bleed is already gone (the global pass isn't issued indoors), so this is no longer load-bearing — but the spec mandates removing it so the gate can't reintroduce bleed if the dispatcher is ever fed the full entity list with a cell filter again. The faithful behavior: a ParentCellId == null entity is outdoor scenery; with a cell-membership filter active, it is NOT a member of any interior cell, so it should be gated OUT (the clip-slot routing in ResolveEntitySlot is what lets it through to the OutsideView slot, not this membership gate).

  • Step 1: Read the current method

Confirm the current body at WbDrawDispatcher.cs:17391757:

internal static bool EntityPassesVisibleCellGate(
    WorldEntity entity, HashSet<uint>? visibleCellIds, EntitySet set)
{
    if (visibleCellIds is null)
        return true;
    if (entity.ParentCellId.HasValue)
        return visibleCellIds.Contains(entity.ParentCellId.Value);
    if (IsShellScopedSet(set) && entity.IsBuildingShell)   // dead (IsShellScopedSet == false)
        return entity.BuildingShellAnchorCellId is uint anchorCellId
            && visibleCellIds.Contains(anchorCellId);
    return true;   // ← the bypass
}
  • Step 2: Repurpose — ParentCellId == null does NOT pass a cell-membership filter

Replace the body (remove the dead IsShellScopedSet branch and the return true bypass):

internal static bool EntityPassesVisibleCellGate(
    WorldEntity entity, HashSet<uint>? visibleCellIds, EntitySet set)
{
    // No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / outdoor
    // scenery) ⇒ every entity passes; clip-slot routing (ResolveEntitySlot) does the gating.
    if (visibleCellIds is null)
        return true;

    // A cell-membership filter is active. An interior static passes iff its cell is visible.
    if (entity.ParentCellId.HasValue)
        return visibleCellIds.Contains(entity.ParentCellId.Value);

    // ParentCellId == null (outdoor scenery / building shell): NOT a member of any interior cell,
    // so it does NOT pass a cell-membership filter (R1: the bleed fix — was an unconditional
    // `return true`). When such entities must draw (through the doorway), the caller passes
    // visibleCellIds: null and relies on ResolveEntitySlot's OutsideView routing instead.
    return false;
}
  • Step 3: Delete the now-unused IsShellScopedSet

If IsShellScopedSet (line ~1761) has no remaining callers after Step 2, delete it. Search first:

Run: rg --encoding utf-16-le "IsShellScopedSet" src/AcDream.App (or the Grep tool)

  • If callers remain (e.g. in WalkEntitiesInto ~548/577), leave IsShellScopedSet (it returns false, harmless) and note it for R4 cleanup. Do NOT chase those call sites in R1.

  • Step 4: Verify the clip-slot tests still pass

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter WbDrawDispatcher Expected: PASS. (ResolveEntitySlot / ResolveSlotForFrame are unchanged; the gate change is separate.)

  • Step 5: Add a gate test for the new behavior

Append to tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs (or the existing gate test file — confirm where EntityPassesVisibleCellGate is tested; if untested, add to WbDrawDispatcherClipSlotTests):

[Fact]
public void Gate_NullParentCell_WithCellFilter_DoesNotPass()
{
    var scenery = new AcDream.Core.World.WorldEntity
    {
        Id = 1, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001,
        Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity,
        MeshRefs = new[] { new AcDream.Core.World.MeshRef(0x01000001, 0) }, ParentCellId = null,
    };
    var filter = new HashSet<uint> { 0xA9B40170 };
    // R1: outdoor scenery does NOT pass a cell-membership filter (was the bleed bypass).
    Assert.False(WbDrawDispatcher.EntityPassesVisibleCellGate(scenery, filter, EntitySet.All));
    // Unfiltered (null) ⇒ passes (clip-slot routing gates it instead).
    Assert.True(WbDrawDispatcher.EntityPassesVisibleCellGate(scenery, null, EntitySet.All));
}

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter WbDrawDispatcher Expected: PASS (including the new test).

  • Step 6: Commit
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs
git commit -m "fix(render): R1 — repurpose the ParentCellId==null cell-gate bypass (#78)

EntityPassesVisibleCellGate no longer returns true unconditionally for outdoor
scenery under a cell filter; outdoor scenery draws via the unfiltered bucket +
ResolveEntitySlot's OutsideView routing. Was the headline outdoor-scenery bleed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 5: Build green + R1 visual gate

Files: none (verification).

Why: Rendering is verified on screen. This task is the make-or-break gate for R1.

  • Step 1: Full build + test

Run: dotnet build then dotnet test Expected: build green; tests green except the 5 known pre-existing Core physics/collision failures flagged in the handoff (2 step-up gaps incl. the A6.P4 door regression; 3 door-collision apparatus / A6.P5). If a NEW failure appears, it's in scope — investigate before the visual gate.

  • Step 2: Launch the client with the probes (per CLAUDE.md "Running the client")
$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_CELL = "1"; $env:ACDREAM_PROBE_VIS = "1"; $env:ACDREAM_PROBE_SHELL = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log

(Run in the background; give it ~8s to reach in-world. Read the UTF-16 log with Select-String / the ripgrep Grep tool with --encoding utf-16-le, NOT GNU grep.)

  • Step 3: Walk the Holtburg cottage (USER drives + observes)

Spawn 0xA9B40031 (outdoor) → walk in: 0170 vestibule → 0171 room → 0175 stairs → 0174 cellar. The [cell-transit] probe confirms membership; [vis] confirms the visible set + OutsideView; [shell] confirms per-cell shells draw.

  • Step 4: The R1 visual gate (user confirms on screen)

PASS criteria — the user must confirm ALL:

  • Sealed shell: cottage room + cellar have opaque walls, a solid floor, and a ceiling. No grey floor, no grey "world instead of floor" in the cellar, no blue hole.

  • No bleed: standing in the cellar, NO houses/trees/outdoor stabs visible; NPCs/doors do not show through the walls above.

  • Landscape through the door only: sky + terrain (+ outdoor scenery) visible only through the doorway/window, not full-screen, not under the floor.

  • Furnished + player: interior furniture renders; the player avatar (+ any NPCs) render inside.

  • Step 5: If the gate fails, diagnose with the [shell] tree (do NOT guess)

  • [shell] shows NOSNAP/gfx=0 for a cell ⇒ that cell's mesh isn't prepared (streaming/filter) — check PrepareRenderBatches ran with filter:null.

  • [shell] shows idx>0 + zh=0 + tr=0 but the floor is grey ⇒ the cell mesh is missing its floor polygon (or it's back-facing). This is the grey-floor sealing bug (design §1.3) — dump the cellar EnvCell mesh (0xA9B40174) and confirm whether a floor polygon exists / its winding. The fix is in the mesh path (or the CullMode.Landblock→None winding), NOT relaxing terrain Skip. File findings; this may extend R1 or spawn a focused sub-task.

  • Outdoor scenery still bleeds ⇒ confirm the global entity Draw is NOT reached indoors (the else branch only), and the outdoor-scenery draw uses visibleCellIds: null + OutdoorVisible.

  • Step 6: Commit any gate-driven fixes, then update the roadmap/spec

When the gate passes: note R1 done in docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md §7 (R1) and the redesign plan; commit. Particles remain (R1b) — Scene-pass particles still draw unclipped; if outdoor particles bleed into the sealed cellar, that's the known #104 / R1b item, not an R1 regression.

git add docs/superpowers/
git commit -m "docs(render): R1 — per-cell DrawInside shipped, visual gate passed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Self-Review

1. Spec coverage (design spec §2/§4/§7 R1):

  • Binary decision (indoor → DrawInside only) → Task 3. ✓
  • Per-cell shells (closest-first, closed mesh) → Task 2 (loop) + Task 3 (wire). ✓
  • Per-cell objects → Task 1 (partition) + Task 2 (per-cell Draw). ✓
  • Live-dynamics unclipped → Task 1 (bucket) + Task 2 (null-filter Draw). ✓
  • Landscape-through-door (terrain + sky + outdoor scenery, clipped) + conditional Z-clear → stays inline in OnRender (already ordered); outdoor-scenery draw added in Task 3 Step 3; Z-clear (~7523) unchanged. ✓
  • Kill the :1756 bypass → Task 4. ✓
  • Grey-floor = sealing bug, verified by probe + dump, NOT relax-Skip → Task 5 Step 5. ✓
  • Particles (design §4.2, #104) → explicitly deferred to R1b (needs a ParentCellId/OwnerCellId link on ParticleEmitter — a Core data-model touch, not present). Flagged in the Goal + Task 5 Step 6. This is a scoped deferral, not a gap.

2. Placeholder scan: No "TBD"/"handle later"/"add validation". The grey-floor investigation (Task 5 Step 5) is a concrete diagnostic protocol with a specific cell id + the [shell] tree, not a placeholder. The pvFrame-hoist and animatedIds-ordering notes (Task 3) are explicit integration instructions, not hand-waving.

3. Type consistency: InteriorEntityPartition.Result (Task 1) ↔ InteriorRenderContext.Partition (Task 2) ↔ _interiorPartition (Task 3) — same type. Result.ByCell/.Outdoor/.LiveDynamic used consistently. InteriorRenderer.DrawInside(InteriorRenderContext) ↔ the Task 3 new InteriorRenderContext { ... } — all required init properties supplied (OrderedVisibleCells, Partition, Camera, Frustum, PlayerLandblockId, AnimatedEntityIds). Draw(...) tuple shape matches WbDrawDispatcher.cs:643-645. WbRenderPass.Opaque/Transparent matches the kept enum.

Risks carried into execution (documented, non-blocking):

  • Draw called N×/frame miscounts the diagnostic GPU-timing query + indoor-probe rate-limiter (both ACDREAM_WB_DIAG/probe-only). Functional path is self-contained. → R4 polish (suppress per-call diag when looped, or a per-cell entry overload).
  • Per-cell Draw issues N× the SSBO uploads + MDI calls. Fine for a cottage; a large dungeon is an R3 perf check (N.6 baseline shows large CPU/GPU headroom).
  • Live-dynamics drawn unclipped includes any in a non-visible far room (depth-tested only). Correct for the cottage; an R3/R4 refinement for dungeons.

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md. Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration. Tasks 14 are bounded code changes (good subagent fits); Task 5 is the visual gate (requires YOU at the running client — no subagent).

2. Inline Execution — I execute the tasks in this session using executing-plans, with checkpoints (build/test after each task, and a hard stop at Task 5 for your visual verification).

Either way, Task 5 stops for your eyes — that's the R1 acceptance.

Which approach?