docs(render): R1 implementation plan — per-cell DrawInside (TDD)
Bite-sized plan for R1 (the per-cell DrawInside core): InteriorEntityPartition (3-bucket, TDD), InteriorRenderer per-cell loop, the binary render decision in OnRender (indoor = DrawInside only), and the :1756 bypass repurpose. Particles deferred to R1b. Grounded in the live code surface (exact file:line + signatures). Ends at the R1 user visual gate (sealed Holtburg cottage, no bleed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7aca79f8eb
commit
ce7404b92b
1 changed files with 689 additions and 0 deletions
|
|
@ -0,0 +1,689 @@
|
|||
# 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<WorldEntity>,
|
||||
IReadOnlyDictionary<uint, WorldEntity>?)> OneLb(uint lbId, params WorldEntity[] ents)
|
||||
=> new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList<WorldEntity>)ents,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
||||
|
||||
[Fact]
|
||||
public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
|
||||
{
|
||||
var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
|
||||
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
|
||||
var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
|
||||
var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
|
||||
var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
|
||||
|
||||
var visible = new HashSet<uint> { CellA, CellB };
|
||||
var result = InteriorEntityPartition.Partition(
|
||||
visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));
|
||||
|
||||
Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0)
|
||||
Assert.Contains(livePlayer, result.LiveDynamic);
|
||||
Assert.Contains(liveNpcInCell, result.LiveDynamic);
|
||||
|
||||
Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic)
|
||||
Assert.Contains(staticA, result.ByCell[CellA]);
|
||||
Assert.Single(result.ByCell[CellB]);
|
||||
Assert.Contains(staticB, result.ByCell[CellB]);
|
||||
|
||||
Assert.Single(result.Outdoor);
|
||||
Assert.Contains(scenery, result.Outdoor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Static_InNonVisibleCell_IsDropped()
|
||||
{
|
||||
var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
|
||||
var visible = new HashSet<uint> { CellA };
|
||||
var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));
|
||||
|
||||
Assert.False(result.ByCell.ContainsKey(0xA9B40199));
|
||||
Assert.Empty(result.Outdoor);
|
||||
Assert.Empty(result.LiveDynamic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityWithNoMeshRefs_IsSkipped()
|
||||
{
|
||||
var noMesh = new WorldEntity
|
||||
{
|
||||
Id = 9, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001,
|
||||
Position = Vector3.Zero, Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(), ParentCellId = CellA,
|
||||
};
|
||||
var result = InteriorEntityPartition.Partition(
|
||||
new HashSet<uint> { CellA }, OneLb(0xA9B4FFFF, noMesh));
|
||||
|
||||
Assert.False(result.ByCell.ContainsKey(CellA));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter InteriorEntityPartitionTests`
|
||||
Expected: FAIL — `InteriorEntityPartition` does not exist (compile error).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```csharp
|
||||
// src/AcDream.App/Rendering/InteriorEntityPartition.cs
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a frame's landblock entities into the three draw buckets the per-cell
|
||||
/// <see cref="InteriorRenderer"/> needs, using the SAME precedence as
|
||||
/// <see cref="Wb.WbDrawDispatcher.ResolveEntitySlot"/>:
|
||||
/// <list type="number">
|
||||
/// <item><b>ServerGuid != 0</b> (player / NPCs / items / doors) ⇒ <see cref="Result.LiveDynamic"/>
|
||||
/// — drawn unclipped (depth only). These have no <c>ParentCellId</c> so they MUST be tested first.</item>
|
||||
/// <item><b>ParentCellId</b> in the visible set ⇒ <see cref="Result.ByCell"/>[cell] — per-cell, portal-clipped.</item>
|
||||
/// <item><b>ParentCellId == null</b> (outdoor scenery / building shell) ⇒ <see cref="Result.Outdoor"/>
|
||||
/// — drawn through the doorway, clipped to OutsideView.</item>
|
||||
/// </list>
|
||||
/// A static whose <c>ParentCellId</c> is NOT in <paramref name="visibleCells"/> is dropped (its cell
|
||||
/// isn't drawn this frame). Entities with no <c>MeshRefs</c> are skipped. Pure; GL-free; unit-tested.
|
||||
/// </summary>
|
||||
public static class InteriorEntityPartition
|
||||
{
|
||||
public sealed class Result
|
||||
{
|
||||
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
||||
public List<WorldEntity> Outdoor { get; } = new();
|
||||
public List<WorldEntity> LiveDynamic { get; } = new();
|
||||
}
|
||||
|
||||
public static Result Partition(
|
||||
HashSet<uint> visibleCells,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
||||
{
|
||||
var result = new Result();
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
foreach (var e in entry.Entities)
|
||||
{
|
||||
if (e.MeshRefs.Count == 0) continue;
|
||||
|
||||
if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
|
||||
{
|
||||
result.LiveDynamic.Add(e);
|
||||
}
|
||||
else if (e.ParentCellId is uint cell)
|
||||
{
|
||||
if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
|
||||
if (!result.ByCell.TryGetValue(cell, out var list))
|
||||
result.ByCell[cell] = list = new List<WorldEntity>();
|
||||
list.Add(e);
|
||||
}
|
||||
else // outdoor scenery / building shell
|
||||
{
|
||||
result.Outdoor.Add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter InteriorEntityPartitionTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/InteriorEntityPartition.cs tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs
|
||||
git commit -m "feat(render): R1 — InteriorEntityPartition (3-bucket per-cell entity split)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `InteriorRenderer` — the per-cell `DrawInside` loop
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/InteriorRenderer.cs`
|
||||
|
||||
**Why:** This is the per-cell flood (retail `PView::DrawCells` loops 2+3). It iterates `OrderedVisibleCells` closest-first and, per cell, draws the shell + that cell's objects; then live-dynamics unclipped; then transparent shells. It reuses the existing `EnvCellRenderer.Render(pass, {cellId})` (single-cell filter, confirmed working) and `WbDrawDispatcher.Draw` (confirmed safe to call N×/frame — only diagnostic GPU-timing miscounts). No GL state setup here — the caller (`OnRender`) owns the clip-plane bracket + clip routing; each renderer sets its own self-contained GL state internally.
|
||||
|
||||
**Retail anchor:** `PView::DrawCells @ 0x5a4840` (pc: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;
|
||||
|
||||
/// <summary>Per-frame inputs for one <see cref="InteriorRenderer.DrawInside"/> flood.</summary>
|
||||
public sealed class InteriorRenderContext
|
||||
{
|
||||
/// <summary>Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame.</summary>
|
||||
public required IReadOnlyList<uint> OrderedVisibleCells { get; init; }
|
||||
/// <summary>The 3-bucket entity split (Task 1). Only ByCell + LiveDynamic are used here;
|
||||
/// Outdoor scenery is drawn by the caller's landscape-through-door step.</summary>
|
||||
public required InteriorEntityPartition.Result Partition { get; init; }
|
||||
public required ICamera Camera { get; init; }
|
||||
public required FrustumPlanes? Frustum { get; init; }
|
||||
/// <summary>The full FFFF-suffixed landblock id of the player. Used as BOTH the synthetic
|
||||
/// per-cell entry id AND neverCullLandblockId so the degenerate (zero) synthetic AABB is never
|
||||
/// landblock-culled — per-entity frustum culling inside Draw still applies.</summary>
|
||||
public required uint? PlayerLandblockId { get; init; }
|
||||
public required HashSet<uint>? AnimatedEntityIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
|
||||
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
|
||||
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
|
||||
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
|
||||
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL-state is
|
||||
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
|
||||
/// </summary>
|
||||
public sealed class InteriorRenderer
|
||||
{
|
||||
private readonly EnvCellRenderer _envCells;
|
||||
private readonly WbDrawDispatcher _entities;
|
||||
// Reused single-cell filter set — cleared+repopulated per cell to avoid per-frame allocs.
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
|
||||
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
|
||||
{
|
||||
_envCells = envCells;
|
||||
_entities = entities;
|
||||
}
|
||||
|
||||
public void DrawInside(InteriorRenderContext ctx)
|
||||
{
|
||||
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
{
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||
|
||||
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
|
||||
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
|
||||
}
|
||||
|
||||
// Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
|
||||
// Drawn AFTER opaque shells so wall depth occludes them correctly.
|
||||
if (ctx.Partition.LiveDynamic.Count > 0)
|
||||
DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
|
||||
|
||||
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
{
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||
}
|
||||
}
|
||||
|
||||
// Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
|
||||
// landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
|
||||
// set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
|
||||
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
|
||||
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
|
||||
private void DrawEntityBucket(
|
||||
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
|
||||
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
|
||||
uint lbId = ctx.PlayerLandblockId ?? 0u;
|
||||
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||
(IReadOnlyList<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: build succeeds. (No behavior wired yet — `InteriorRenderer` is constructed in Task 3.)
|
||||
|
||||
> **If the `Draw` tuple shape mismatches:** the dispatcher's `Draw` first parameter after `camera` is `IEnumerable<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)>` (WbDrawDispatcher.cs:643-645). The `new[] { entry }` array must match it exactly — adjust the tuple element types if the compiler complains, do NOT change `Draw`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/InteriorRenderer.cs
|
||||
git commit -m "feat(render): R1 — InteriorRenderer per-cell DrawInside loop (retail PView::DrawCells)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire `DrawInside` into `OnRender` — the binary decision
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (field declaration near line ~166; construction where `_envCellRenderer`/`_wbDrawDispatcher` are built; the render splice ~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<AcDream.Core.World.WorldEntity>)_interiorPartition.Outdoor,
|
||||
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
|
||||
_wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum,
|
||||
neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds);
|
||||
}
|
||||
```
|
||||
|
||||
> Note `_interiorPartition` and `animatedIds` must be in scope here. `animatedIds` is built at ~7507 (BEFORE this point — good). `_interiorPartition` is built in Step 4 (it must be computed BEFORE this block — see Step 4 placement).
|
||||
|
||||
- [ ] **Step 4: Build the partition in the indoor-root branch, and store it for Step 3**
|
||||
|
||||
In the `if (clipRoot is not null)` block (~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<uint>(clipAssembly.CellIdToSlot.Keys);`:
|
||||
|
||||
```csharp
|
||||
// R1: partition this frame's entities into per-cell / outdoor / live-dynamic buckets for the
|
||||
// DrawInside flood + the outdoor-scenery-through-door draw. Keyed by the SAME visible-cell set
|
||||
// the shells use (cellIdToSlot.Keys).
|
||||
_interiorPartition = AcDream.App.Rendering.InteriorEntityPartition.Partition(
|
||||
envCellShellFilter, _worldState.LandblockEntries);
|
||||
```
|
||||
|
||||
In the `else` (outdoor root) branch (~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<uint>? animatedIds = ...` block) to just before the terrain block.
|
||||
|
||||
- [ ] **Step 5: Replace the global shell + global entity draws with the binary decision**
|
||||
|
||||
Find these three calls (GameWindow.cs ~7538, ~7546, ~7553):
|
||||
|
||||
```csharp
|
||||
// (~7538) opaque shells:
|
||||
if (clipAssembly is not null && envCellShellFilter is not null)
|
||||
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, envCellShellFilter);
|
||||
|
||||
// (~7546) global entity pass:
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
|
||||
// (~7553) transparent shells:
|
||||
if (clipAssembly is not null && envCellShellFilter is not null)
|
||||
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, envCellShellFilter);
|
||||
```
|
||||
|
||||
Replace ALL THREE with the binary decision:
|
||||
|
||||
```csharp
|
||||
// R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0):
|
||||
// INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global entity pass
|
||||
// and global shell pass are NOT issued — visibility IS the cull, so the outdoor world cannot
|
||||
// bleed (it is never iterated; outdoor scenery entered above, clipped to the doorway).
|
||||
// OUTDOOR root: the existing global entity pass (no shells, no DrawInside).
|
||||
if (clipRoot is not null && _interiorRenderer is not null && _interiorPartition is not null)
|
||||
{
|
||||
var ctx = new AcDream.App.Rendering.InteriorRenderContext
|
||||
{
|
||||
OrderedVisibleCells = pvFrame.OrderedVisibleCells,
|
||||
Partition = _interiorPartition,
|
||||
Camera = camera,
|
||||
Frustum = frustum,
|
||||
PlayerLandblockId = playerLb,
|
||||
AnimatedEntityIds = animatedIds,
|
||||
};
|
||||
_interiorRenderer.DrawInside(ctx);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Outdoor root: the global entity pass (unchanged).
|
||||
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
```
|
||||
|
||||
> **`pvFrame` scope:** `pvFrame` is a local inside the `clipRoot is not null` block (~7316). To reference it at the splice (~7538), either (a) hoist its declaration: change `var pvFrame = PortalVisibilityBuilder.Build(...)` to assign a method-scoped `PortalVisibilityFrame? pvFrame = null;` declared alongside `clipAssembly`, then `pvFrame = PortalVisibilityBuilder.Build(...)` in the block; or (b) store `pvFrame.OrderedVisibleCells` into a method-scoped local `IReadOnlyList<uint>? orderedVisibleCells = null;` set in the block and used here. Use (a) — it's the cleaner hoist and keeps the splice readable. Update the splice to `OrderedVisibleCells = pvFrame!.OrderedVisibleCells`.
|
||||
|
||||
- [ ] **Step 6: Build**
|
||||
|
||||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 7: Run the regression test suite (no new failures)**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||
Expected: PASS (existing render tests unaffected — the change is orchestration, not the kept components). If a test references the old call structure, it is asserting on internals the redesign changed — report it; do not silently delete.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(render): R1 — binary render decision, indoor = per-cell DrawInside only
|
||||
|
||||
When clipRoot != null, run only InteriorRenderer.DrawInside (per-cell shells +
|
||||
per-cell objects + live-dynamics); the global entity pass + global shell pass are
|
||||
no longer issued indoors. Outdoor scenery drawn clipped to the doorway. Outdoor
|
||||
root path unchanged. Retail RenderNormalMode @ 0x453aa0.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Repurpose the `:1756` `ParentCellId==null` bypass
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (line ~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<uint>? visibleCellIds, EntitySet set)
|
||||
{
|
||||
if (visibleCellIds is null)
|
||||
return true;
|
||||
if (entity.ParentCellId.HasValue)
|
||||
return visibleCellIds.Contains(entity.ParentCellId.Value);
|
||||
if (IsShellScopedSet(set) && entity.IsBuildingShell) // dead (IsShellScopedSet == false)
|
||||
return entity.BuildingShellAnchorCellId is uint anchorCellId
|
||||
&& visibleCellIds.Contains(anchorCellId);
|
||||
return true; // ← the bypass
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Repurpose — `ParentCellId == null` does NOT pass a cell-membership filter**
|
||||
|
||||
Replace the body (remove the dead `IsShellScopedSet` branch and the `return true` bypass):
|
||||
|
||||
```csharp
|
||||
internal static bool EntityPassesVisibleCellGate(
|
||||
WorldEntity entity, HashSet<uint>? visibleCellIds, EntitySet set)
|
||||
{
|
||||
// No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / outdoor
|
||||
// scenery) ⇒ every entity passes; clip-slot routing (ResolveEntitySlot) does the gating.
|
||||
if (visibleCellIds is null)
|
||||
return true;
|
||||
|
||||
// A cell-membership filter is active. An interior static passes iff its cell is visible.
|
||||
if (entity.ParentCellId.HasValue)
|
||||
return visibleCellIds.Contains(entity.ParentCellId.Value);
|
||||
|
||||
// ParentCellId == null (outdoor scenery / building shell): NOT a member of any interior cell,
|
||||
// so it does NOT pass a cell-membership filter (R1: the bleed fix — was an unconditional
|
||||
// `return true`). When such entities must draw (through the doorway), the caller passes
|
||||
// visibleCellIds: null and relies on ResolveEntitySlot's OutsideView routing instead.
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete the now-unused `IsShellScopedSet`**
|
||||
|
||||
If `IsShellScopedSet` (line ~1761) has no remaining callers after Step 2, delete it. Search first:
|
||||
|
||||
Run: `rg --encoding utf-16-le "IsShellScopedSet" src/AcDream.App` (or the Grep tool)
|
||||
- If callers remain (e.g. in `WalkEntitiesInto` ~548/577), leave `IsShellScopedSet` (it returns `false`, harmless) and note it for R4 cleanup. Do NOT chase those call sites in R1.
|
||||
|
||||
- [ ] **Step 4: Verify the clip-slot tests still pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter WbDrawDispatcher`
|
||||
Expected: PASS. (`ResolveEntitySlot` / `ResolveSlotForFrame` are unchanged; the gate change is separate.)
|
||||
|
||||
- [ ] **Step 5: Add a gate test for the new behavior**
|
||||
|
||||
Append to `tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs` (or the existing gate test file — confirm where `EntityPassesVisibleCellGate` is tested; if untested, add to `WbDrawDispatcherClipSlotTests`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Gate_NullParentCell_WithCellFilter_DoesNotPass()
|
||||
{
|
||||
var scenery = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = 1, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001,
|
||||
Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = new[] { new AcDream.Core.World.MeshRef(0x01000001, 0) }, ParentCellId = null,
|
||||
};
|
||||
var filter = new HashSet<uint> { 0xA9B40170 };
|
||||
// R1: outdoor scenery does NOT pass a cell-membership filter (was the bleed bypass).
|
||||
Assert.False(WbDrawDispatcher.EntityPassesVisibleCellGate(scenery, filter, EntitySet.All));
|
||||
// Unfiltered (null) ⇒ passes (clip-slot routing gates it instead).
|
||||
Assert.True(WbDrawDispatcher.EntityPassesVisibleCellGate(scenery, null, EntitySet.All));
|
||||
}
|
||||
```
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter WbDrawDispatcher`
|
||||
Expected: PASS (including the new test).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherClipSlotTests.cs
|
||||
git commit -m "fix(render): R1 — repurpose the ParentCellId==null cell-gate bypass (#78)
|
||||
|
||||
EntityPassesVisibleCellGate no longer returns true unconditionally for outdoor
|
||||
scenery under a cell filter; outdoor scenery draws via the unfiltered bucket +
|
||||
ResolveEntitySlot's OutsideView routing. Was the headline outdoor-scenery bleed.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Build green + R1 visual gate
|
||||
|
||||
**Files:** none (verification).
|
||||
|
||||
**Why:** Rendering is verified on screen. This task is the make-or-break gate for R1.
|
||||
|
||||
- [ ] **Step 1: Full build + test**
|
||||
|
||||
Run: `dotnet build` then `dotnet test`
|
||||
Expected: build green; tests green except the 5 known pre-existing Core physics/collision failures flagged in the handoff (2 step-up gaps incl. the A6.P4 door regression; 3 door-collision apparatus / A6.P5). If a NEW failure appears, it's in scope — investigate before the visual gate.
|
||||
|
||||
- [ ] **Step 2: Launch the client with the probes (per CLAUDE.md "Running the client")**
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_PROBE_CELL = "1"; $env:ACDREAM_PROBE_VIS = "1"; $env:ACDREAM_PROBE_SHELL = "1"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log
|
||||
```
|
||||
(Run in the background; give it ~8s to reach in-world. Read the UTF-16 log with `Select-String` / the ripgrep Grep tool with `--encoding utf-16-le`, NOT GNU grep.)
|
||||
|
||||
- [ ] **Step 3: Walk the Holtburg cottage (USER drives + observes)**
|
||||
|
||||
Spawn `0xA9B40031` (outdoor) → walk in: `0170` vestibule → `0171` room → `0175` stairs → `0174` cellar. The `[cell-transit]` probe confirms membership; `[vis]` confirms the visible set + OutsideView; `[shell]` confirms per-cell shells draw.
|
||||
|
||||
- [ ] **Step 4: The R1 visual gate (user confirms on screen)**
|
||||
|
||||
PASS criteria — the user must confirm ALL:
|
||||
- **Sealed shell:** cottage room + cellar have opaque walls, a solid floor, and a ceiling. **No grey floor, no grey "world instead of floor" in the cellar, no blue hole.**
|
||||
- **No bleed:** standing in the cellar, NO houses/trees/outdoor stabs visible; NPCs/doors do not show through the walls above.
|
||||
- **Landscape through the door only:** sky + terrain (+ outdoor scenery) visible only through the doorway/window, not full-screen, not under the floor.
|
||||
- **Furnished + player:** interior furniture renders; the player avatar (+ any NPCs) render inside.
|
||||
|
||||
- [ ] **Step 5: If the gate fails, diagnose with the `[shell]` tree (do NOT guess)**
|
||||
|
||||
- `[shell]` shows `NOSNAP`/`gfx=0` for a cell ⇒ that cell's mesh isn't prepared (streaming/filter) — check `PrepareRenderBatches` ran with `filter:null`.
|
||||
- `[shell]` shows `idx>0 + zh=0 + tr=0` but the **floor is grey** ⇒ the cell mesh is missing its floor polygon (or it's back-facing). This is the **grey-floor sealing bug** (design §1.3) — dump the cellar EnvCell mesh (`0xA9B40174`) and confirm whether a floor polygon exists / its winding. The fix is in the mesh path (or the `CullMode.Landblock→None` winding), NOT relaxing terrain Skip. File findings; this may extend R1 or spawn a focused sub-task.
|
||||
- Outdoor scenery still bleeds ⇒ confirm the global entity `Draw` is NOT reached indoors (the `else` branch only), and the outdoor-scenery draw uses `visibleCellIds: null` + `OutdoorVisible`.
|
||||
|
||||
- [ ] **Step 6: Commit any gate-driven fixes, then update the roadmap/spec**
|
||||
|
||||
When the gate passes: note R1 done in [`docs/superpowers/specs/2026-06-02-render-pipeline-redesign-design.md`](../specs/2026-06-02-render-pipeline-redesign-design.md) §7 (R1) and the redesign plan; commit. Particles remain (R1b) — `Scene`-pass particles still draw unclipped; if outdoor particles bleed into the sealed cellar, that's the known #104 / R1b item, not an R1 regression.
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/
|
||||
git commit -m "docs(render): R1 — per-cell DrawInside shipped, visual gate passed
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage** (design spec §2/§4/§7 R1):
|
||||
- Binary decision (indoor → DrawInside only) → Task 3. ✓
|
||||
- Per-cell shells (closest-first, closed mesh) → Task 2 (loop) + Task 3 (wire). ✓
|
||||
- Per-cell objects → Task 1 (partition) + Task 2 (per-cell Draw). ✓
|
||||
- Live-dynamics unclipped → Task 1 (bucket) + Task 2 (null-filter Draw). ✓
|
||||
- Landscape-through-door (terrain + sky + outdoor scenery, clipped) + conditional Z-clear → stays inline in OnRender (already ordered); outdoor-scenery draw added in Task 3 Step 3; Z-clear (~7523) unchanged. ✓
|
||||
- Kill the `:1756` bypass → Task 4. ✓
|
||||
- Grey-floor = sealing bug, verified by probe + dump, NOT relax-Skip → Task 5 Step 5. ✓
|
||||
- **Particles** (design §4.2, #104) → **explicitly deferred to R1b** (needs a `ParentCellId`/`OwnerCellId` link on `ParticleEmitter` — a Core data-model touch, not present). Flagged in the Goal + Task 5 Step 6. This is a scoped deferral, not a gap.
|
||||
|
||||
**2. Placeholder scan:** No "TBD"/"handle later"/"add validation". The grey-floor investigation (Task 5 Step 5) is a concrete diagnostic protocol with a specific cell id + the `[shell]` tree, not a placeholder. The `pvFrame`-hoist and `animatedIds`-ordering notes (Task 3) are explicit integration instructions, not hand-waving.
|
||||
|
||||
**3. Type consistency:** `InteriorEntityPartition.Result` (Task 1) ↔ `InteriorRenderContext.Partition` (Task 2) ↔ `_interiorPartition` (Task 3) — same type. `Result.ByCell`/`.Outdoor`/`.LiveDynamic` used consistently. `InteriorRenderer.DrawInside(InteriorRenderContext)` ↔ the Task 3 `new InteriorRenderContext { ... }` — all required init properties supplied (`OrderedVisibleCells`, `Partition`, `Camera`, `Frustum`, `PlayerLandblockId`, `AnimatedEntityIds`). `Draw(...)` tuple shape matches WbDrawDispatcher.cs:643-645. `WbRenderPass.Opaque/Transparent` matches the kept enum.
|
||||
|
||||
**Risks carried into execution (documented, non-blocking):**
|
||||
- `Draw` called N×/frame miscounts the diagnostic GPU-timing query + indoor-probe rate-limiter (both `ACDREAM_WB_DIAG`/probe-only). Functional path is self-contained. → R4 polish (suppress per-call diag when looped, or a per-cell entry overload).
|
||||
- Per-cell `Draw` issues N× the SSBO uploads + MDI calls. Fine for a cottage; a large dungeon is an R3 perf check (N.6 baseline shows large CPU/GPU headroom).
|
||||
- Live-dynamics drawn unclipped includes any in a non-visible far room (depth-tested only). Correct for the cottage; an R3/R4 refinement for dungeons.
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-06-02-render-r1-per-cell-drawinside.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Tasks 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?
|
||||
Loading…
Add table
Add a link
Reference in a new issue