Shell pass is a safe no-op for the node id (no exclusion needed); indoor->outdoor terrain already works via OutsideView; the only new piece is feeding the outdoor ROOT node's full-screen region to OutsideView. Remaining = OutsideView integration (read ClipFrameAssembler) + clipRoot flip + launch + visual gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
357 lines
24 KiB
Markdown
357 lines
24 KiB
Markdown
# Render Unification (Outdoor-as-a-Cell) 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:** Collapse acdream's two render paths (`OutdoorRoot` vs `RetailPViewInside`) into one — a single portal flood rooted at the viewer cell (indoor *or* a new outdoor cell node) and a single draw of every visible cell — so the indoor/outdoor FLAP is impossible by construction.
|
|
|
|
**Architecture:** Model the outdoor world as a synthetic `LoadedCell` flood node whose "shell" is the landscape and whose "doorways" are nearby building entrances. `PortalVisibilityBuilder.Build` roots at the viewer cell; building exit portals lead to the outdoor node; the draw path renders each visible cell uniformly (outdoor node → terrain/sky; interior → shell). Matches retail `SmartBox::RenderNormalMode → DrawInside(viewer_cell)`.
|
|
|
|
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Render code in `src/AcDream.App/Rendering/`. Tests in `tests/AcDream.App.Tests/Rendering/`.
|
|
|
|
**Spec:** [docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md](../specs/2026-06-07-render-unification-outdoor-as-cell-design.md)
|
|
|
|
## Progress (2026-06-07)
|
|
|
|
- **Task 1 — `OutdoorCellNode.Build` — DONE** (`2a2cc97`). 2 unit tests; App.Tests 212/212.
|
|
- **Task 3 — outdoor-root flood — DONE** (`c5b4f77`, done before Task 2 to de-risk the
|
|
core hypothesis). **KEY RESULT: the flood roots at the outdoor node and reaches
|
|
buildings with ZERO production changes** — `PortalVisibilityBuilder.Build` and
|
|
`OutdoorCellNode.Build` are correct as-is; cycle termination holds. App.Tests 214/214.
|
|
(The plan's Task 3 fixture sketch had `InsideSide=0`; the shipped test uses the correct
|
|
`InsideSide=1` — building interior at Y>5 is the negative half-space. `OutdoorCellNode`
|
|
flips it so the outdoor camera passes the side test.)
|
|
- **Task 2 — build the outdoor node each frame — DONE** (`d01fe30`). Additive: `_outdoorNode`
|
|
built each outdoor frame from nearby building-entrance portals (Chebyshev ≤1), with an
|
|
`[outdoor-node]` probe (ACDREAM_PROBE_FLAP) reporting the live portal count. Not yet rooted
|
|
→ behaviour unchanged. App.Tests 214/214, build green. (Insertion: `GameWindow.cs` just
|
|
before the branch at the old line ~7341; `playerLb` is in scope there.)
|
|
|
|
- **NEXT — THE CUTOVER FLIP (the remaining risky, launch-gated chunk), INLINE.** Now fully
|
|
de-risked by reading the draw path:
|
|
- The shell pass is a **safe no-op** for the synthetic node id — `DrawEnvCellShells` →
|
|
`_envCells.Render(pass, {id})` renders nothing for an id with no prepared geometry
|
|
(`RetailPViewRenderer.cs:190-202`). So **no explicit shell-exclusion is needed.**
|
|
- Indoor→outdoor terrain **already works** via the existing `OutsideView` → terrain-slice
|
|
path (`DrawInside` → `DrawLandscapeThroughOutsideView`, `RetailPViewRenderer.cs:79,138`).
|
|
The ONLY new piece is the **outdoor-ROOT** case: when `DrawInside` is rooted at the
|
|
outdoor node, the node's full-screen view region must become an `OutsideView` slice so
|
|
terrain draws full-screen. → Read `ClipFrameAssembler` (how `pvFrame.OutsideView` becomes
|
|
`OutsideViewSlices`; how a full-screen region maps to a no-clip terrain slice), then in
|
|
`PortalVisibilityBuilder.Build` (or `DrawInside`): when the root is the outdoor node
|
|
(`SeenOutside` + outdoor id), `AddRegion(frame.OutsideView, <full-screen NDC quad>)`.
|
|
- Then flip: `viewerRoot = _outdoorNode` when outdoors; `clipRoot = viewerRoot` always
|
|
(drop the `playerIndoorGate && viewerRoot != null` gate at `GameWindow.cs:~7346`). This
|
|
routes EVERY frame through `_retailPViewRenderer.DrawInside` (the `else` outdoor block
|
|
becomes dead — leave it for the post-visual-gate delete).
|
|
- **Build → launch (`ACDREAM_PROBE_FLAP` only) → USER VISUAL GATE** at the cottage. Then
|
|
delete `BuildFromExterior` / `DrawPortal` / the dead `else` block / `OutsideView`-only
|
|
plumbing (Task 7) + cleanup (Phase 4).
|
|
- **WARNING:** this is coordinated surgery (Build + ClipFrameAssembler + GameWindow) that
|
|
ends at a launch + visual gate; a first attempt rarely renders right. Do it with adequate
|
|
context headroom (the dead-zone regression came from rushing a render change before a
|
|
visual gate). Verify the [outdoor-node] probe shows real portals FIRST.
|
|
- Tree clean; HEAD `d01fe30`; baselines App 214 / Core 1331-4-1.
|
|
|
|
**Baselines that must hold:** build 0 errors; App.Tests 210 pass; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Run the client per CLAUDE.md "Running the client"; `+Acdream` spawns at the Holtburg/Arcanum cottage. Launch logs are UTF-16. Use `ACDREAM_PROBE_FLAP` only (NOT `ACDREAM_PROBE_SHELL`).
|
|
|
|
---
|
|
|
|
## File structure
|
|
|
|
| File | Responsibility | Change |
|
|
|---|---|---|
|
|
| `src/AcDream.App/Rendering/OutdoorCellNode.cs` | Build a synthetic outdoor `LoadedCell` from nearby building exit portals | **Create** |
|
|
| `src/AcDream.App/Rendering/CellVisibility.cs` | `LoadedCell`/`CellPortalInfo`/`PortalClipPlane` types; cell registry; `TryGetCell`; resolve the outdoor node | Modify |
|
|
| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | The one flood; root at outdoor node; exit portals → outdoor node | Modify |
|
|
| `src/AcDream.App/Rendering/RetailPViewRenderer.cs` | The one draw path; outdoor-node-aware cell draw | Modify |
|
|
| `src/AcDream.App/Rendering/GameWindow.cs` | Per-frame: resolve viewer cell, one flood, one draw; delete the branch | Modify |
|
|
| `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs` | Outdoor node construction + portal wiring | **Create** |
|
|
| `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs` | Build rooted at the outdoor node; cycle termination | **Create** |
|
|
|
|
**Deletions (Phase 3):** `PortalVisibilityBuilder.BuildFromExterior`; `RetailPViewRenderer.DrawPortal`; the `OutsideView` mechanism + `GameWindow.DrawRetailPViewLandscapeSlice` / `DrawLandscapeThroughOutsideView`; the two-branch gate at `GameWindow.cs:7342-7349`.
|
|
|
|
---
|
|
|
|
## PHASE 1 — The outdoor cell node (additive; not yet consumed by the draw)
|
|
|
|
### Task 1: `OutdoorCellNode.Build` — synthesize the outdoor node from nearby building entrances
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/OutdoorCellNode.cs`
|
|
- Test: `tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs`
|
|
|
|
Context: a building cell stores its entrance as a portal with `OtherCellId == 0xFFFF` (exit-to-outdoors) in `Portals[i]`, with the matching `ClipPlanes[i]` (local-space `Normal`,`D`,`InsideSide`) and `PortalPolygons[i]` (local-space verts). The outdoor node is a `LoadedCell` with `WorldTransform = Identity` whose `Portals` point *back into* each building cell, with the entrance polygon transformed to world space and the clip plane reversed (`InsideSide` flipped) so "inside the outdoor node" is the half-space outside the building.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```csharp
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public class OutdoorCellNodeTests
|
|
{
|
|
// A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF)
|
|
// whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal
|
|
// back into that building cell, with the entrance polygon moved to world space and the
|
|
// inside-side flipped (so the outdoor half-space is "inside" the node).
|
|
private static LoadedCell BuildingWithOneExit(uint cellId)
|
|
{
|
|
var cell = new LoadedCell { CellId = cellId };
|
|
cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f);
|
|
cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f);
|
|
cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0));
|
|
cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 });
|
|
cell.PortalPolygons.Add(new[]
|
|
{
|
|
new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2)
|
|
});
|
|
return cell;
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_FromBuildingExit_AddsReversePortalIntoBuilding()
|
|
{
|
|
uint outdoorId = 0xA9B40031;
|
|
var building = BuildingWithOneExit(0xA9B40170);
|
|
var node = OutdoorCellNode.Build(outdoorId, new[] { building });
|
|
|
|
Assert.Equal(outdoorId, node.CellId);
|
|
Assert.True(node.SeenOutside);
|
|
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
|
Assert.Single(node.Portals);
|
|
Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId);
|
|
// Reversed inside-side: the building's exit was InsideSide=0, the node's is 1.
|
|
Assert.Equal(1, node.ClipPlanes[0].InsideSide);
|
|
// Entrance polygon moved to world space (building translated +10 X): first vert x≈10.
|
|
Assert.Equal(10f, node.PortalPolygons[0][0].X, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_NoBuildings_ReturnsEmptyPortalNode()
|
|
{
|
|
var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty<LoadedCell>());
|
|
Assert.Empty(node.Portals);
|
|
Assert.True(node.SeenOutside);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"`
|
|
Expected: FAIL — `OutdoorCellNode` does not exist (compile error).
|
|
|
|
- [ ] **Step 3: Write minimal implementation**
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/OutdoorCellNode.cs
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell
|
|
/// (spec 2026-06-07-render-unification-outdoor-as-cell). Its "shell" is the landscape
|
|
/// (drawn by the terrain renderer); its portals are the reverse of each nearby
|
|
/// building's exit portal (OtherCellId==0xFFFF). One node per frame, keyed by the
|
|
/// viewer's outdoor landcell id. WorldTransform is identity (portals stored in world
|
|
/// space). Mirrors retail's outdoor landcell that DrawInside(viewer_cell) roots at.
|
|
/// </summary>
|
|
public static class OutdoorCellNode
|
|
{
|
|
public static LoadedCell Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells)
|
|
{
|
|
var node = new LoadedCell
|
|
{
|
|
CellId = outdoorCellId,
|
|
SeenOutside = true,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
};
|
|
|
|
foreach (var bcell in nearbyBuildingCells)
|
|
{
|
|
for (int i = 0; i < bcell.Portals.Count; i++)
|
|
{
|
|
if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors
|
|
if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue;
|
|
|
|
// Reverse portal: outdoor node -> this building cell.
|
|
node.Portals.Add(new CellPortalInfo(
|
|
OtherCellId: (ushort)(bcell.CellId & 0xFFFFu),
|
|
PolygonId: bcell.Portals[i].PolygonId,
|
|
Flags: bcell.Portals[i].Flags,
|
|
OtherPortalId: (ushort)i));
|
|
|
|
// Entrance polygon -> world space (node transform is identity).
|
|
var srcPoly = bcell.PortalPolygons[i];
|
|
var worldPoly = new Vector3[srcPoly.Length];
|
|
for (int v = 0; v < srcPoly.Length; v++)
|
|
worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform);
|
|
node.PortalPolygons.Add(worldPoly);
|
|
|
|
// Clip plane -> world space, inside-side flipped (outdoor half-space is "inside").
|
|
var src = bcell.ClipPlanes[i];
|
|
var worldNormal = Vector3.TransformNormal(src.Normal, bcell.WorldTransform);
|
|
worldNormal = Vector3.Normalize(worldNormal);
|
|
var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform);
|
|
node.ClipPlanes.Add(new PortalClipPlane
|
|
{
|
|
Normal = worldNormal,
|
|
D = -Vector3.Dot(worldNormal, pointOnPlane),
|
|
InsideSide = src.InsideSide == 0 ? 1 : 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
return node;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~OutdoorCellNodeTests"`
|
|
Expected: PASS (2 tests). If `LoadedCell.CellId` is not settable from tests, confirm its declaration in `CellVisibility.cs` and adjust (it is a public field used as `cameraCell.CellId` throughout the builder).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/OutdoorCellNode.cs tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs
|
|
git commit -m "feat(render): Phase 1 — OutdoorCellNode.Build (outdoor world as a flood node)"
|
|
```
|
|
|
|
### Task 2: Resolve `viewerRoot` to the outdoor node when the eye is outdoors
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7201-7204` (viewerRoot resolution)
|
|
- Modify: `src/AcDream.App/Rendering/CellVisibility.cs` (add `GetNearbyBuildingCellsForExterior` if not already exposed; the look-in enumeration at `GameWindow.cs:~7538-7565` already gathers candidate cells — reuse it)
|
|
|
|
Note: this step builds the node and stores it on a field but **does not yet feed it to the flood/draw** — the existing branch still runs. Purely additive; the only observable change is that `viewerRoot` is non-null outdoors (verify via `[render-sig]` `viewerRoot=` once wired in Phase 3; for now assert via a focused test or a temporary log).
|
|
|
|
- [ ] **Step 1:** Add a `private LoadedCell? _outdoorNode;` field to `GameWindow` and, right after the existing `viewerRoot` block (`GameWindow.cs:7201-7203`), when `viewerRoot is null && viewerCellId != 0u` (outdoor id), build the node from the nearby building cells (reuse the exterior-candidate enumeration already at ~7538-7565, extracted into a helper `GatherNearbyBuildingCells(playerLb)` returning `IReadOnlyList<LoadedCell>`), assign `_outdoorNode = OutdoorCellNode.Build(viewerCellId, nearby);` and **leave `viewerRoot` unchanged for now** (Phase 3 flips the consumer). Add a one-line `[render-sig]`-adjacent log behind `ProbeFlapEnabled`: `outdoorNode portals=N` to confirm wiring live.
|
|
|
|
- [ ] **Step 2:** `dotnet build -c Debug` → 0 errors. `dotnet test` both suites → baselines hold (210 / 1331-4-1). No behavior change yet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/Rendering/CellVisibility.cs
|
|
git commit -m "feat(render): Phase 1 — build the outdoor node each frame (additive, unconsumed)"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 2 — Outdoor-root flood capability (additive; old exit-portal behaviour untouched)
|
|
|
|
### Task 3: `PortalVisibilityBuilder.Build` floods from the outdoor node into buildings
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (the `Build` seed + portal loop at lines 63, 133-318)
|
|
- Test: `tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs`
|
|
|
|
Context: `Build(cameraCell, cameraPos, lookup, viewProj)` seeds the root full-screen and floods interior portals. Rooting at the outdoor node already works structurally (it's a `LoadedCell` with portals into buildings). This task is a **characterization test** proving Build floods outdoor→building, plus any fix needed for the outdoor node's identity-transform portals (its polygons are already world-space, so `ProjectToClip(localPoly, node.WorldTransform=Identity, viewProj)` is correct).
|
|
|
|
- [ ] **Step 1: Write the failing test** (real fixture: outdoor node + one building cell reachable through it)
|
|
|
|
```csharp
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public class UnifiedFloodTests
|
|
{
|
|
[Fact]
|
|
public void Build_RootedAtOutdoorNode_FloodsIntoBuilding()
|
|
{
|
|
// Building cell directly in front of the eye, with an exit portal facing the eye.
|
|
var building = new LoadedCell { CellId = 0xA9B40170, SeenOutside = true };
|
|
building.WorldTransform = Matrix4x4.Identity;
|
|
building.InverseWorldTransform = Matrix4x4.Identity;
|
|
building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0));
|
|
building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 0 });
|
|
building.PortalPolygons.Add(new[]
|
|
{
|
|
new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2)
|
|
});
|
|
|
|
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
|
|
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
|
|
|
|
// Eye in front of the entrance, looking +Y toward it.
|
|
var eye = new Vector3(0, -3, 1);
|
|
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
|
|
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
|
|
|
|
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
|
|
|
|
Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself
|
|
Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify it fails or passes.** Run the filter `UnifiedFloodTests`. If it FAILS (building not flooded), inspect why (likely the lookup keys on full id vs low id, or the node's world-space polygon needs identity transform in `Build`'s projection call). Fix minimally in `PortalVisibilityBuilder`. If it PASSES first try, it's a characterization test that locks the behaviour — keep it.
|
|
|
|
- [ ] **Step 3: Cycle-termination test** — add a reciprocal exit portal on the building back to the outdoor node and assert `Build` terminates (no hang, bounded `OrderedVisibleCells`). The existing `queued`/`MaxReprocessPerCell` guards should cover it; this test pins it.
|
|
|
|
- [ ] **Step 4:** `dotnet test` both suites → baselines hold + the new tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
|
|
git commit -m "feat(render): Phase 2 — Build floods from the outdoor node into buildings (+cycle guard test)"
|
|
```
|
|
|
|
---
|
|
|
|
## PHASE 3 — The cutover (the one risky, visual-gated step)
|
|
|
|
> Each task here is git-revertible as a unit. After Task 7, **stop for the user's visual gate** before Task 8's deletions.
|
|
|
|
### Task 4: Exit portals enqueue the outdoor node
|
|
|
|
- [ ] In `PortalVisibilityBuilder.Build`, at the exit-portal branch (`PortalVisibilityBuilder.cs:234`, `portal.OtherCellId == 0xFFFF`), instead of (only) `AddRegion(frame.OutsideView, clippedRegion)`, resolve the outdoor node via the `lookup` (Phase 1 makes it resolvable) and enqueue it with `clippedRegion` as its view, exactly like an interior neighbour (the `AddRegion(nview,…)` + `queued.Add` path at lines 296-316). Keep `OutsideView` populated too **for this task only** (so the old draw still works) — it is removed in Task 7. Run `UnifiedFloodTests` + add a test: indoor root → flood reaches the outdoor node through the exit portal.
|
|
- [ ] Commit: `feat(render): Phase 3 — exit portals flood into the outdoor node`.
|
|
|
|
### Task 5: Unified draw — render the outdoor node's shell as terrain
|
|
|
|
- [ ] In `RetailPViewRenderer` (the visible-cell draw walk, `DrawEnvCellShells`/`IndoorDrawPlan.ShellPass`), special-case the outdoor node: when the visible cell is the outdoor node, draw terrain + sky + outdoor scenery clipped to that cell's view region (reuse the existing terrain clip mechanism — drive `TerrainModernRenderer`'s binding=2 clip UBO from the node's region planes; full-screen region → the existing no-clip UBO) instead of EnvCell shell geometry. Interior cells unchanged.
|
|
- [ ] Build green; commit: `feat(render): Phase 3 — draw the outdoor node's shell as terrain (unified draw)`.
|
|
|
|
### Task 6: Route the frame through the single path
|
|
|
|
- [ ] At `GameWindow.cs:7342-7349`, replace the branch so `viewerRoot` is the outdoor node when outdoors (Task 2 already builds it; assign `viewerRoot = _outdoorNode` when the prior lookup was null and an outdoor node exists). Set `clipRoot = viewerRoot` unconditionally (drop the `playerIndoorGate && viewerRoot != null` gate). The single draw path (`RetailPViewRenderer.DrawInside`) now runs every frame, rooted at the viewer cell.
|
|
- [ ] Build green; `dotnet test` baselines. Commit: `feat(render): Phase 3 — single render path rooted at the viewer cell`.
|
|
|
|
### Task 7: **VISUAL GATE** — user verifies, then delete the old paths
|
|
|
|
- [ ] Build, launch (`ACDREAM_PROBE_FLAP=1`, UTF-16 log). **User test** at the Holtburg cottage: walk in/out, pan at the threshold, cellar down/up, look at the cottage from outside. Acceptance: no flap; no missing wall/roof textures; terrain + sky correct; no see-through walls; pure-outdoor FPS unchanged. Capture `[render-sig]` — `branch` is now always the single path; `viewerCell`/`draw` transition cleanly with no 4↔6 cell-set jump.
|
|
- [ ] **Only after the user confirms:** delete `PortalVisibilityBuilder.BuildFromExterior`, `RetailPViewRenderer.DrawPortal`, the `OutsideView` field + `AddRegion(frame.OutsideView,…)`, and `GameWindow.DrawRetailPViewLandscapeSlice`/`DrawLandscapeThroughOutsideView` + the now-dead outdoor-branch block. Build green; `dotnet test` baselines.
|
|
- [ ] Commit: `feat(render): Phase 3 — delete two-pipe split (BuildFromExterior/DrawPortal/OutsideView)`.
|
|
|
|
---
|
|
|
|
## PHASE 4 — Cleanup
|
|
|
|
### Task 8: Reconcile probes + dead code
|
|
|
|
- [ ] Update the `[render-sig]` emit (`GameWindow.cs:~9039-9082`) so `branch` reflects the single path and the now-removed `extPortal/extIds/outdoorRoot*` fields are dropped or repurposed. Remove any now-unreachable helpers flagged by the build. Update `docs/research` / memory `project_indoor_flap_rootcause` + `reference_render_pipeline_state` with the shipped outcome.
|
|
- [ ] Update the roadmap "shipped" table (`docs/plans/2026-04-11-roadmap.md`) + the milestones doc M1.5 note. Commit: `chore(render): Phase 4 — probe + docs reconcile after unification`.
|
|
|
|
---
|
|
|
|
## Self-review
|
|
|
|
- **Spec coverage:** §6.1 outdoor node → Task 1/2; §6.2 one flood → Task 3/4; §6.3 one draw + deletions → Task 5/6/7; §6.4 terrain clip reuse → Task 5; §9 phasing → Phases 1-4 (1-2 additive, 3 cutover, 4 cleanup); §10 testing → Tasks 1/3 unit + Task 7 visual gate + the pure-outdoor regression guard (assert in Task 5/6 that an outdoor root with no buildings yields a full-screen no-clip terrain draw). **Gap fixed:** add to Task 6 an explicit assertion/log that the no-building outdoor case routes to the no-clip terrain UBO (regression guard from spec §10).
|
|
- **Placeholders:** Phases 1-2 carry real test + impl code. Phase 3-4 are concrete wiring/deletion tasks against named methods (their exact code is finalized against the Phase 1-2 APIs at execution — the cutover is inherently wire-and-delete + visual gate, not new algorithm). No "TBD"/"add error handling".
|
|
- **Type consistency:** `LoadedCell` (fields `CellId`, `Portals`, `ClipPlanes`, `PortalPolygons`, `WorldTransform`, `InverseWorldTransform`, `SeenOutside`), `CellPortalInfo(OtherCellId,PolygonId,Flags,OtherPortalId)`, `PortalClipPlane{Normal,D,InsideSide}`, `OutdoorCellNode.Build(uint, IReadOnlyList<LoadedCell>) → LoadedCell`, `PortalVisibilityBuilder.Build(LoadedCell, Vector3, Func<uint,LoadedCell?>, Matrix4x4)` — consistent across tasks.
|
|
|
|
**Note for the executor:** confirm `LoadedCell.CellId` is a settable public field and the exact `PortalVisibilityBuilder.Build` signature against `CellVisibility.cs`/`PortalVisibilityBuilder.cs:63` before Task 1/3 (the plan assumes the signatures observed 2026-06-07). Phase 3 tasks reference real method names to wire/delete; read each call site before editing.
|