# 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` (~7530–7554); 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, IReadOnlyDictionary?)> OneLb(uint lbId, params WorldEntity[] ents) => new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)ents, (IReadOnlyDictionary?)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 { 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 { 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(), ParentCellId = CellA, }; var result = InteriorEntityPartition.Partition( new HashSet { 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; /// /// Splits a frame's landblock entities into the three draw buckets the per-cell /// needs, using the SAME precedence as /// : /// /// ServerGuid != 0 (player / NPCs / items / doors) ⇒ /// — drawn unclipped (depth only). These have no ParentCellId so they MUST be tested first. /// ParentCellId in the visible set ⇒ [cell] — per-cell, portal-clipped. /// ParentCellId == null (outdoor scenery / building shell) ⇒ /// — drawn through the doorway, clipped to OutsideView. /// /// A static whose ParentCellId is NOT in is dropped (its cell /// isn't drawn this frame). Entities with no MeshRefs are skipped. Pure; GL-free; unit-tested. /// public static class InteriorEntityPartition { public sealed class Result { public Dictionary> ByCell { get; } = new(); public List Outdoor { get; } = new(); public List LiveDynamic { get; } = new(); } public static Result Partition( HashSet visibleCells, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? 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(); 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) " ``` --- ## 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:432815–432882) — 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; /// Per-frame inputs for one flood. public sealed class InteriorRenderContext { /// Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame. public required IReadOnlyList OrderedVisibleCells { get; init; } /// 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. public required InteriorEntityPartition.Result Partition { get; init; } public required ICamera Camera { get; init; } public required FrustumPlanes? Frustum { get; init; } /// 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. public required uint? PlayerLandblockId { get; init; } public required HashSet? AnimatedEntityIds { get; init; } } /// /// 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). /// 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 _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 bucket, HashSet? 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)bucket, (IReadOnlyDictionary?)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, IReadOnlyDictionary?)>` (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) " ``` --- ## 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 ~7530–7554; 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 ~7464–7489). 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)_interiorPartition.Outdoor, (IReadOnlyDictionary?)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 (~7311–7362), 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(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 (~7363–7371), clear it: ```csharp _interiorPartition = null; ``` > **Ordering check:** the terrain/scenery/Z-clear block (Step 3) is at ~7464–7528, which runs AFTER the `clipRoot` block (~7311–7371). So `_interiorPartition` is built before Step 3 reads it. Good. But `animatedIds` is built at ~7507 — confirm Step 3's insertion point (~7489–7515) is AFTER ~7507. If `animatedIds` is built after your scenery draw, MOVE the `animatedIds` build (the ~7507 `HashSet? 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? 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) " ``` --- ## Task 4: Repurpose the `:1756` `ParentCellId==null` bypass **Files:** - Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (line ~1739–1761) **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:1739–1757: ```csharp internal static bool EntityPassesVisibleCellGate( WorldEntity entity, HashSet? 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? 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 { 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) " ``` --- ## 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) " ``` --- ## 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 1–4 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?