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

689 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`](../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**
```csharp
// 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**
```csharp
// 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**
```bash
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**
```csharp
// 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**
```bash
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:
```csharp
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:
```csharp
// 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):
```csharp
// 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):
```csharp
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);`:
```csharp
// 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:
```csharp
_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):
```csharp
// (~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:
```csharp
// 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**
```bash
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:
```csharp
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):
```csharp
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`):
```csharp
[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**
```bash
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")**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_PROBE_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`](../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.
```bash
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?