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>
38 KiB
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 (~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
// 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:432815–432882) — 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
Drawtuple shape mismatches: the dispatcher'sDrawfirst parameter aftercameraisIEnumerable<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)>(WbDrawDispatcher.cs:643-645). Thenew[] { entry }array must match it exactly — adjust the tuple element types if the compiler complains, do NOT changeDraw.
- 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/_wbDrawDispatcherare 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
InteriorRendererfield
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+_wbDrawDispatcherare 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/_wbDrawDispatcherare 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):
// 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
_interiorPartitionandanimatedIdsmust be in scope here.animatedIdsis built at ~7507 (BEFORE this point — good)._interiorPartitionis 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):
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 (~7363–7371), clear it:
_interiorPartition = null;
Ordering check: the terrain/scenery/Z-clear block (Step 3) is at ~7464–7528, which runs AFTER the
clipRootblock (~7311–7371). So_interiorPartitionis built before Step 3 reads it. Good. ButanimatedIdsis built at ~7507 — confirm Step 3's insertion point (~7489–7515) is AFTER ~7507. IfanimatedIdsis built after your scenery draw, MOVE theanimatedIdsbuild (the ~7507HashSet<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);
}
pvFramescope:pvFrameis a local inside theclipRoot is not nullblock (~7316). To reference it at the splice (~7538), either (a) hoist its declaration: changevar pvFrame = PortalVisibilityBuilder.Build(...)to assign a method-scopedPortalVisibilityFrame? pvFrame = null;declared alongsideclipAssembly, thenpvFrame = PortalVisibilityBuilder.Build(...)in the block; or (b) storepvFrame.OrderedVisibleCellsinto a method-scoped localIReadOnlyList<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 toOrderedVisibleCells = 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 ~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:
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 == nulldoes 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), leaveIsShellScopedSet(it returnsfalse, 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]showsNOSNAP/gfx=0for a cell ⇒ that cell's mesh isn't prepared (streaming/filter) — checkPrepareRenderBatchesran withfilter:null. -
[shell]showsidx>0 + zh=0 + tr=0but 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 theCullMode.Landblock→Nonewinding), NOT relaxing terrain Skip. File findings; this may extend R1 or spawn a focused sub-task. -
Outdoor scenery still bleeds ⇒ confirm the global entity
Drawis NOT reached indoors (theelsebranch only), and the outdoor-scenery draw usesvisibleCellIds: 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
:1756bypass → 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/OwnerCellIdlink onParticleEmitter— 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):
Drawcalled N×/frame miscounts the diagnostic GPU-timing query + indoor-probe rate-limiter (bothACDREAM_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
Drawissues 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?