docs: implementation plan + pickup handoff — verbatim retail DrawCells port

Task-by-task plan (TDD pin for the grey regression + per-task visual gates) to replace the
indoor-render approximation with a verbatim PView::DrawCells port, sequenced so Task 2 alone
should kill the grey. Pickup handoff for a fresh session: state, baselines, rules, do-not-relitigate.
Local commit only (not pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-06 21:44:19 +02:00
parent eb7b1fa67c
commit 2ec8f41200
2 changed files with 646 additions and 0 deletions

View file

@ -0,0 +1,567 @@
# Verbatim Retail Indoor Render Port — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the indoor-render approximation layer with a verbatim port of retail `PView::DrawCells` so interiors seal (no grey, no bleed, no half-character) and inside↔outside↔inside is seamless.
**Architecture:** Keep the faithful membership (`PortalVisibilityBuilder` = `cell_draw_list`) and clip math (`PortalProjection.ProjectToClip`/`ClipToRegion` = `GetClip`/`polyClipFinish`). Rewrite `RetailPViewRenderer.DrawInside`/`DrawPortal` into retail's three `DrawCells` loops: draw **every** visible cell's shell, trimmed **per-slice** with `ClipPlaneSet``gl_ClipDistance`; draw objects membership+depth gated with **no** clip. Delete the `ClipFrameAssembler` slot-pool + `drawableCells` filter that drop shells (grey) and the global clip-off that caused bleed.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL 4.3 (bindless + MDI), xUnit. PowerShell on Windows; launch logs UTF-16.
**Spec:** `docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md` (commit `eb7b1fa`). Read it first.
**Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`. Do NOT branch/worktree, push, or `git stash`/`gc`. Do NOT revert the dirty render tree. Build before launch.
---
## Orientation for the executing session (read once)
- **Baseline:** App.Tests 205/205; Core.Tests 1331 pass / 4 fail (pre-existing Physics door/step-up, unrelated) / 1 skip; `dotnet build -c Debug` 0 errors. If these don't hold at start, stop and report — something drifted.
- **The two bugs this plan kills** (both in `src/AcDream.App/Rendering/RetailPViewRenderer.cs`):
1. `:52` `drawableCells = clipAssembly.CellIdToSlot.Keys` + `if (!drawableCells.Contains(cellId)) continue;` in every loop → cells without a clip-slot are dropped → **grey**.
2. `:237` `UseIndoorMembershipOnlyRouting()``_envCells.SetClipRouting(null)` → trim globally off → **bleed**; it was disabled because the clip was (wrongly) applied to objects/characters → **half-character**.
- **Retail oracle** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): `PView::DrawCells` 0x5a4840 — three loops over reverse `cell_draw_list`: exit-portal masks → shells (`DrawEnvCell`, per-slice `setup_view` clip) → objects (`DrawObjCellForDummies`, visibility-gated, NOT hard-clipped).
- **Key existing APIs you'll reuse:**
- `PortalVisibilityFrame` (in `PortalView.cs`): `OrderedVisibleCells` (List<uint>, closest-first), `CellViews` (Dictionary<uint, CellView>), `OutsideView` (CellView). `CellView.Polygons` is the slice list; `ViewPolygon.Vertices` is Vector2[] NDC.
- `ClipPlaneSet.From(CellView)``Count`/`Planes` (≤8), `UseScissorFallback`+`ScissorNdcAabb`, `IsNothingVisible`.
- `ClipFrame`: `Reset()`, `AppendSlot(ClipPlaneSet)`→slot index, `SetTerrainClip(planes)`, `UploadShared(gl)`, `RegionSsbo`. Slot 0 is reserved no-clip.
- `EnvCellRenderer`: `SetClipRegionSsbo(uint)`, `SetClipRouting(IReadOnlyDictionary<uint,int>?)` (null = no-clip), `Render(WbRenderPass, HashSet<uint> filter)`, `PrepareRenderBatches(...)`.
- `WbDrawDispatcher`: `Draw(camera, entries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds)`, `SetClipRegionSsbo`, `ClearClipRouting()`.
- **Verification reality:** the pure draw-ORDER is unit-tested (Task 1). Every GL task is verified by **launch + your eyes** + the `[render-sig]` / `[shell]` probes — there is no unit test for a GPU draw. Launch command is in the Appendix. **Do not mark a GL task done on a green build alone — only on the user's visual confirmation.**
---
## File Structure
- **Create:** `src/AcDream.App/Rendering/IndoorDrawPlan.cs` — pure (GL-free) function turning a `PortalVisibilityFrame` into the reverse-ordered shell draw list (every visible cell, no filter). Test seam for the grey regression.
- **Create:** `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs`.
- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — the three `DrawCells` loops; remove `drawableCells`, `UseIndoorMembershipOnlyRouting`, the `ClipFrameAssembler` dependency.
- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` + `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs` (and the `ClipViewSlice`/`ClipFrameAssembly` types if unused after the rewrite).
- **Light touch:** `src/AcDream.App/Rendering/GameWindow.cs` — the indoor/look-in call sites + `sig*` diagnostics that read `ClipAssembly.OutsideViewSlices` (switch to `pvFrame.OutsideView.Polygons.Count`).
- **Untouched:** `PortalVisibilityBuilder.cs`, `PortalProjection.cs`, `ClipPlaneSet.cs`, `ClipFrame.cs`, `EnvCellRenderer.cs`, `WbDrawDispatcher.cs`, the outdoor `LScape` branch.
---
## Task 1: Pure shell-draw-order function (pins the grey regression)
**Files:**
- Create: `src/AcDream.App/Rendering/IndoorDrawPlan.cs`
- Test: `tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class IndoorDrawPlanTests
{
private static ViewPolygon Quad() => new(new[]
{ new Vector2(-1,-1), new Vector2(1,-1), new Vector2(1,1), new Vector2(-1,1) });
private static PortalVisibilityFrame FrameWith(params uint[] orderedCells)
{
var f = new PortalVisibilityFrame();
foreach (var id in orderedCells)
{
f.OrderedVisibleCells.Add(id);
var v = new CellView(); v.Add(Quad());
f.CellViews[id] = v;
}
return f;
}
[Fact]
public void ShellPass_IncludesEveryVisibleCell_NoFilter()
{
// The grey bug: a cell in OrderedVisibleCells must NEVER be dropped from the
// shell pass. (Old code dropped cells lacking a ClipFrameAssembler slot.)
var f = FrameWith(0x01, 0x02, 0x03);
var plan = IndoorDrawPlan.ShellPass(f);
Assert.Equal(new uint[] { 0x03, 0x02, 0x01 }, plan.Select(e => e.CellId).ToArray()); // reverse = far→near
Assert.All(plan, e => Assert.NotEmpty(e.Slices));
}
[Fact]
public void ShellPass_ExcludesEmptyViewCells()
{
var f = FrameWith(0x01);
f.OrderedVisibleCells.Add(0x02); // present in the list…
f.CellViews[0x02] = new CellView(); // …but empty view → not drawable
var plan = IndoorDrawPlan.ShellPass(f);
Assert.Equal(new uint[] { 0x01 }, plan.Select(e => e.CellId).ToArray());
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"`
Expected: FAIL — `IndoorDrawPlan` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
// IndoorDrawPlan.cs
//
// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840):
// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a
// non-empty view is included — there is NO "drawable" filter. Dropping cells without a
// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed).
using System.Collections.Generic;
namespace AcDream.App.Rendering;
public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
public static class IndoorDrawPlan
{
/// <summary>Reverse OrderedVisibleCells (far→near), each visible cell with its view
/// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped
/// (they are not actually visible); no other cell is ever dropped.</summary>
public static List<CellDrawEntry> ShellPass(PortalVisibilityFrame frame)
{
var result = new List<CellDrawEntry>(frame.OrderedVisibleCells.Count);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!frame.CellViews.TryGetValue(cellId, out var view) || view.IsEmpty)
continue;
result.Add(new CellDrawEntry(cellId, view.Polygons));
}
return result;
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --filter "FullyQualifiedName~IndoorDrawPlanTests"`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/IndoorDrawPlan.cs tests/AcDream.App.Tests/Rendering/IndoorDrawPlanTests.cs
git commit -m @'
feat(render): IndoorDrawPlan.ShellPass — every visible cell, no drawable filter (R1)
Pure port of retail DrawCells membership: reverse cell_draw_list, per-slice. Pins the
grey regression — a cell in OrderedVisibleCells is never dropped from the shell pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 2: Shell pass draws EVERY visible cell (kill the grey) — highest-value, smallest change
This is the change that should make the grey disappear. Keep the existing per-slice clip routing for now (Task 4 cleans it); only remove the `drawableCells` filter and iterate `IndoorDrawPlan.ShellPass`.
**Files:** Modify `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawEnvCellShells`, ~`:175-197`).
- [ ] **Step 1: Replace the body of `DrawEnvCellShells`**
Replace the loop so it iterates `IndoorDrawPlan.ShellPass(pvFrame)` (every visible cell) instead of `pvFrame.OrderedVisibleCells` gated by `drawableCells.Contains`. Keep `GetCellSlicesOrNoClip` + `UseShellClipRouting` exactly as they are for this task. New body:
```csharp
private void DrawEnvCellShells(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells) // param kept this task; removed in Task 4
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
_oneCell.Clear();
_oneCell.Add(cellId);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
{
UseShellClipRouting(cellId, slice);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
}
```
Note: `GetCellSlicesOrNoClip` already returns `NoClipSlice` for a cell with no assembler slice, so a cell that used to be dropped now draws **unclipped** — sealed (grey gone), possibly with some bleed (Task 4 fixes the trim).
- [ ] **Step 2: Build**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo`
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 3: Run the App test suite (no regression)**
Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build`
Expected: 207/207 pass (205 baseline + 2 new).
- [ ] **Step 4: Launch + VISUAL verify (user)**
Launch with probes (Appendix). Stand inside the cottage + go to the cellar. **Acceptance:** the interior walls/floor no longer render grey — every room you're in is sealed. Some bleed through doorways is expected at this step. In the log, `[render-sig] draw=[…]` should list the same cells as `ids=[…]` (no cell dropped). **Do not proceed until the user confirms the grey is gone.**
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
fix(render): shell pass draws every visible cell — kill the grey (R1)
Iterate IndoorDrawPlan.ShellPass (all visible cells) instead of gating on the
drawableCells slot filter. A visible cell whose shell was dropped for lack of a
ClipFrameAssembler slot now draws, sealing the interior. Per-slice trim unchanged
this commit (Task 4 replaces it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 3: Object pass draws every visible cell's objects, no clip (kill the half-character)
**Files:** Modify `RetailPViewRenderer.cs` (`DrawCellObjectLists`, ~`:199-224`).
- [ ] **Step 1: Replace the loop to use `IndoorDrawPlan.ShellPass` order + ensure no clip on objects**
```csharp
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells, // kept this task; removed in Task 4
InteriorEntityPartition.Result partition)
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
uint cellId = entry.CellId;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting(); // objects: NO clip planes (retail DrawObjCellForDummies)
DrawEntityBucket(ctx, bucket, _oneCell);
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket));
}
}
```
(Functionally close to today, but iterating the full visible set. `UseIndoorMembershipOnlyRouting` stays here on purpose — objects are never clip-planed. It is removed from the shell path in Task 4.)
- [ ] **Step 2: Build + App tests**
Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build`
Expected: build 0 errors; 207/207.
- [ ] **Step 3: Launch + VISUAL verify (user)**
Stand on the cellar stairs / near a door with an NPC. **Acceptance:** characters/objects are **whole** (no half-character), and objects in visible cells appear. Confirm with the user before proceeding.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
fix(render): object pass over every visible cell, no clip planes (R1)
Objects drawn membership+depth gated (retail DrawObjCellForDummies), never hard-clipped
to the 2D portal view — fixes the half-character. Iterates the full visible-cell set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 4: Per-slice shell trim from `ClipPlaneSet.From` directly (drop the ClipFrameAssembler dependency in the shell loop)
Now make the shell trim faithful and self-contained: compute each slice's planes from the cell's own `CellView.Polygons` via `ClipPlaneSet.From`, pack one region into `ClipFrame`, route the cell to it. This removes the shell loop's use of `clipAssembly` and `drawableCells`.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawEnvCellShells`, helpers).
- [ ] **Step 1: Add a per-slice shell-clip helper**
```csharp
// Pack ONE slice's convex region into the clip frame (slot 1) and route this cell to it,
// matching retail setup_view(cell, slice). Slot 0 stays no-clip. Returns false when the
// slice is empty (draw nothing) — the caller skips it.
private bool ApplyShellSliceClip(uint cellId, ViewPolygon slice, Action<uint> setTerrainClipUbo)
{
var oneSlice = new CellView();
oneSlice.Add(slice);
var planes = ClipPlaneSet.From(oneSlice);
if (planes.IsNothingVisible)
return false;
_clipFrame.Reset(); // slot 0 = no-clip
int slot = _clipFrame.AppendSlot(planes); // slot 1 = this slice (or no-clip region if scissor fallback*)
UploadClipFrame(setTerrainClipUbo); // re-upload SSBO (cheap; few indoor cells)
_oneCellSlot.Clear();
_oneCellSlot[cellId] = slot;
_envCells.SetClipRouting(_oneCellSlot);
_entities.ClearClipRouting();
return true;
}
```
\* `ClipFrame.AppendSlot(ClipPlaneSet)` packs the planes when `Count>0`, else a no-clip region. The scissor-AABB fallback (`UseScissorFallback`) is not expressible as planes — for this first port, treat scissor-fallback as no-clip (over-include, never grey). A later refinement can `glScissor` the `ScissorNdcAabb`; note it in the handoff if you see bleed only on >8-edge slices.
- [ ] **Step 2: Rewrite `DrawEnvCellShells` to use it (and drop `clipAssembly`/`drawableCells` params)**
```csharp
private void DrawEnvCellShells(
RetailPViewDrawContext ctx, // need SetTerrainClipUbo; use the concrete context
PortalVisibilityFrame pvFrame)
{
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
_oneCell.Clear();
_oneCell.Add(entry.CellId);
foreach (var slice in entry.Slices)
{
if (!ApplyShellSliceClip(entry.CellId, slice, ctx.SetTerrainClipUbo))
continue;
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
}
```
(For `DrawPortal`, which uses `RetailPViewPortalDrawContext`, either add `SetTerrainClipUbo` access via the shared `IRetailPViewCellDrawContext` interface or pass the `Action<uint>` directly. Both contexts already expose `SetTerrainClipUbo`.)
Update the two call sites (`DrawInside` `:77`, `DrawPortal` `:126`) to `DrawEnvCellShells(ctx, pvFrame)`.
- [ ] **Step 3: Build + App tests**
Run: build + `dotnet test … --no-build`. Expected: 0 errors; 207/207 (the deleted clip-slot routing isn't unit-tested).
- [ ] **Step 4: Launch + VISUAL verify (user)**
**Acceptance:** interior is sealed AND trimmed — looking through a doorway shows only the slice of the next room visible through the opening; no bleed of a neighbour room past a wall edge; no shell gap at stair/door boundaries. If a shell gaps, the slice is too small (a `ClipToRegion` case to inspect) — report it; do not re-add the filter. Confirm with the user.
- [ ] **Step 5: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
feat(render): per-slice shell trim from ClipPlaneSet.From (retail setup_view) (R1)
Each visible cell's shell is clipped per portal_view slice via ClipPlaneSet→gl_ClipDistance,
computed from the cell's own CellView — no ClipFrameAssembler slot pool, no drawableCells
filter. Objects remain unclipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 5: Look-out (landscape through OutsideView) from `OutsideView` slices directly
Drop the shell/object loops' last `clipAssembly` use and make the landscape pass read `pvFrame.OutsideView` directly.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` `:133-153`, `DrawInside` `:74`).
- [ ] **Step 1: Rewrite `DrawLandscapeThroughOutsideView` to iterate `pvFrame.OutsideView.Polygons`**
```csharp
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
PortalVisibilityFrame pvFrame,
InteriorEntityPartition.Result partition)
{
if (pvFrame.OutsideView.IsEmpty)
return;
foreach (var slicePoly in pvFrame.OutsideView.Polygons)
{
var oneSlice = new CellView();
oneSlice.Add(slicePoly);
var planes = ClipPlaneSet.From(oneSlice);
if (planes.IsNothingVisible)
continue;
_clipFrame.SetTerrainClip(planes.Count > 0 ? ToSpan(planes) : ReadOnlySpan<Vector4>.Empty);
UploadClipFrame(ctx.SetTerrainClipUbo);
// Reuse the existing ClipViewSlice DTO ONLY as the landscape callback payload, or
// introduce a small RetailPViewLandscapeSliceContext from (planes, slicePoly bounds).
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(/* slice payload */, partition.Outdoor));
}
// depth-clear over the OutsideView bounds (retail Clear(DEPTH) when outside_view.view_count>0)
ctx.ClearDepthSlice?.Invoke(/* OutsideView NDC bounds */);
}
private static ReadOnlySpan<Vector4> ToSpan(ClipPlaneSet s)
{
var a = new Vector4[s.Count];
for (int i = 0; i < s.Count; i++) a[i] = s.Planes[i];
return a;
}
```
Note: `RetailPViewLandscapeSliceContext` / `ClearDepthSlice` currently take a `ClipViewSlice`. Simplest path: keep a tiny `ClipViewSlice` record (just `{ Planes, NdcAabb }`) as a payload DTO even after deleting `ClipFrameAssembler`, OR change those two callback signatures to take `(ReadOnlySpan<Vector4> planes, Vector4 ndcAabb)`. Pick one and apply consistently to the `GameWindow` callback implementations (`DrawRetailPViewLandscapeSlice`, the `ClearDepthSlice` lambda at `GameWindow.cs:7480`).
- [ ] **Step 2: Update `DrawInside` to call the new signatures and drop `clipAssembly`/`drawableCells`**
In `DrawInside` (`:39-81`): remove `var clipAssembly = ClipFrameAssembler.Assemble(...)`, `drawableCells`, and the `UseIndoorMembershipOnlyRouting()` calls around the shell loop. Partition with **all** visible cells:
```csharp
var partition = InteriorEntityPartition.Partition(
new HashSet<uint>(pvFrame.OrderedVisibleCells), ctx.LandblockEntries);
```
Final `DrawInside` order: `Build``_envCells.PrepareRenderBatches(filter: visibleSet)``DrawLandscapeThroughOutsideView(ctx, pvFrame, partition)``DrawExitPortalMasks` (if kept) → `DrawEnvCellShells(ctx, pvFrame)``DrawCellObjectLists(ctx, pvFrame, partition)`.
- [ ] **Step 3: Build + App tests + Launch VISUAL verify (user)**
Build 0 errors; 207/207. **Acceptance:** standing inside, looking out a door/window shows the outdoor world through the opening (not grey, not full-screen); depth correct. Confirm with the user.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m @'
feat(render): look-out landscape from OutsideView slices; drop clipAssembly in DrawInside (R1)
DrawInside no longer builds a ClipFrameAssembler; landscape-through-outside_view reads
pvFrame.OutsideView directly per slice. Partition runs over all visible cells.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 6: Delete `ClipFrameAssembler` + the slot-pool dead code
After Tasks 45 nothing should reference `ClipFrameAssembler` / `ClipFrameAssembly` / `drawableCells` in the draw path.
**Files:** Delete `ClipFrameAssembler.cs`, `ClipFrameAssemblerTests.cs`; edit `RetailPViewRenderer.cs`, `GameWindow.cs`.
- [ ] **Step 1: Remove remaining references**
- `RetailPViewRenderer.cs`: remove `using`/fields/params for `ClipFrameAssembly`, `drawableCells`, `_oneCellSlot` if now unused, `GetCellSlicesOrNoClip`, `UseShellClipRouting`, and `RetailPViewFrameResult.ClipAssembly`/`DrawableCells` (keep `PortalFrame`, `Partition`). Update `DrawExitPortalMasks` to iterate `IndoorDrawPlan.ShellPass` + `entry.Slices` (or delete it if exit masks are not needed — see note).
- `GameWindow.cs`: `sigTerrainDrawn`/`sigSkyDrawn`/`sigDepthClear`/`sigSceneParticles` (`:7506-7514`) currently read `pviewResult.ClipAssembly.OutsideViewSlices.Length`; change to `pviewResult.PortalFrame.OutsideView.Polygons.Count`. Remove other `ClipAssembly`/`DrawableCells` reads.
**Exit-masks note:** retail Loop 1 (`DrawPortalPolyInternal`) punches exit-portal openings into depth for the look-out landscape. If, after Task 5, the look-out landscape shows correctly without it, delete `DrawExitPortalMasks`. If the outdoor world z-fights or is occluded wrong through the opening, keep it and port it as: per visible cell, per slice, draw the cell's exit-portal polygons depth-only. Decide from the Task 5 visual.
- [ ] **Step 2: Delete the files**
```powershell
git rm src/AcDream.App/Rendering/ClipFrameAssembler.cs tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
```
If `ClipViewSlice`/`ClipFrameAssembly` live in `ClipFrameAssembler.cs` and a payload DTO is still needed (Task 5), move the minimal record to a new small file `RetailPViewTypes.cs` (or inline into `RetailPViewRenderer.cs`).
- [ ] **Step 3: Build + FULL test suite**
Run: `dotnet build -c Debug --nologo` then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug --no-build` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug --no-build`.
Expected: build 0 errors; App green (now ~205207 depending on removed assembler tests); Core 1331/4(pre-existing)/1.
- [ ] **Step 4: Commit**
```powershell
git add -A
git commit -m @'
refactor(render): delete ClipFrameAssembler slot-pool + drawableCells filter (R1)
The verbatim DrawCells loop (Tasks 15) no longer needs the slot pool or the
drawable-cells filter that dropped shells. Removes the approximation layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 7: Look-in (B) — `DrawPortal` on the same loops
`DrawPortal` already calls `DrawEnvCellShells`/`DrawCellObjectLists`; after Tasks 46 those are filter-free and clip-correct, so look-in should "just work." This task verifies it and removes any residual `clipAssembly` use in `DrawPortal`.
**Files:** Modify `RetailPViewRenderer.cs` (`DrawPortal` `:83-131`).
- [ ] **Step 1: Mirror DrawInside's cleanup in DrawPortal**
Remove `ClipFrameAssembler.Assemble`, `drawableCells`; partition over `pvFrame.OrderedVisibleCells`; call `DrawEnvCellShells(ctx, pvFrame)` + `DrawCellObjectLists(ctx, pvFrame, partition)`; keep the `RestoreNoClip` at the end.
- [ ] **Step 2: Build + App tests**
Build 0 errors; App green.
- [ ] **Step 3: Launch + VISUAL verify (user)**
Walk up to a building from OUTSIDE with an open door. **Acceptance:** you see the room interior (shell + objects) through the doorway as you approach, sealed and trimmed; walking through the door is seamless (no flash/grey at the threshold). Confirm with the user.
- [ ] **Step 4: Commit**
```powershell
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m @'
feat(render): look-in DrawPortal on the verbatim DrawCells loops (R1, scope B)
Outside-looking-in reuses the same per-cell, per-slice shell + object passes as DrawInside;
no separate engine, no slot pool.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
'@
```
---
## Task 8: Final verification + handoff
- [ ] **Step 1: Full green**
`dotnet build -c Debug --nologo`; App + Core test suites. Record counts.
- [ ] **Step 2: Clean launch (no probes) for FPS/feel + the four seamless scenarios**
With the user: (1) stand inside cottage — sealed, textured-or-grey noted; (2) cellar — floor + stairs present, character whole; (3) room↔room↔cellar transitions — no flap; (4) walk outside→in and look in through a door — seamless. Get explicit visual sign-off on each.
- [ ] **Step 3: If walls are sealed but UNTEXTURED (grey-but-drawn)**
That is the separate surface/texture bug the spec flags as out of scope (HEAD's "interior walls grey"). File it as a new issue in `docs/ISSUES.md` with the evidence (every shell now draws per `[render-sig]`, but surfaces render untextured) — do NOT reopen the membership/clip work for it.
- [ ] **Step 4: Update memory + roadmap**
If the seal holds and the user confirms: add a `memory/` entry (e.g. `feedback_verbatim_drawcells_port.md`) capturing that the grey was the `drawableCells` filter + the verbatim `DrawCells` loop (every cell, per-slice shell clip, objects unclipped) fixed it; update `MEMORY.md`. Note the two-handoff contradiction (2026-06-05 shell-sealing vs 2026-06-06 projection) so it isn't re-litigated.
---
## Self-Review (done while writing — recorded for the executor)
- **Spec coverage:** §4.2 loop → Tasks 2/3/4 (shells/objects/trim); §4.3 look-in → Task 7; §4 look-out → Task 5; §4.1 deletions → Task 6; testing §7 → Task 1 (pure pin) + per-task visual gates; risks §8 → Task 4 Step 4 note + Task 8 Step 3. Covered.
- **Type consistency:** `IndoorDrawPlan.ShellPass``List<CellDrawEntry>`; `CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices)`; `ClipPlaneSet.From(CellView)`; `ClipFrame.Reset()/AppendSlot/SetTerrainClip/UploadShared`; `EnvCellRenderer.SetClipRouting/Render`. Consistent across tasks.
- **Known soft spots (flagged inline, not placeholders):** (a) the landscape/depth-clear callback DTO (`ClipViewSlice` vs a new `(planes, aabb)`) — Task 5 Step 1 picks one explicitly; (b) exit-masks keep-or-delete — Task 6 Step 1 decides from the Task 5 visual; (c) scissor-AABB fallback rendered as no-clip for now — Task 4 note. These are genuine implementation choices for the executor, each with a stated default.
---
## Appendix: launch command (probes)
```powershell
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | ForEach-Object { $_.CloseMainWindow() | Out-Null; if (-not $_.WaitForExit(5000)) { $_ | Stop-Process -Force } }
Start-Sleep -Seconds 3
$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_FLAP="1"; $env:ACDREAM_PROBE_SHELL="1"; $env:ACDREAM_PROBE_VIS="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-drawcells.log"
```
Run in the background; the user tests then closes the window. For a clean FPS/feel run, drop the three `ACDREAM_PROBE_*` vars. **Build before every launch.** Heavy probe output can make the client sluggish — keep probe runs short.