acdream/docs/superpowers/plans/2026-06-02-phase-w-render-rewrite.md
Erik a06226f9a2 docs(render): Phase W render-rewrite plan (Stages 3-5) — grounded, per-step
Per-step subagent-driven plan for the render half: T0 test-hygiene baseline,
Stage 3 render-root unification (root at CellGraph.CurrCell + seen_outside, drop
the FindCameraCell grace-frame fallback), Stage 4 PView seal (sky/landscape inside
the portal-clip bracket + conditional doorway Z-clear = no blue-hole; EnvCellRenderer
GL_BLEND verify), Stage 5 entity/particle cell-clip. Key reframe from grounding the
plan in the actual code: the PView infra (PortalVisibilityBuilder BFS + OutsideView,
ClipFrame, EnvCellRenderer GL_BLEND fix, WbDrawDispatcher cell gate) ALREADY EXISTS and
the A8 stencil split is already gone — so the render half is wire-and-fill-gaps, not a
from-scratch port. Execution policy: no intermediate user gates, single final visual
verification, full suite green at verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:13:35 +02:00

782 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase W Render Rewrite — Stages 3-5 Implementation Plan
**Goal:** Complete the render half of Phase W: root visibility at the physics `CurrCell`
+ `seen_outside` (Stage 3), port the retail PView portal traversal producing one
`cell_draw_list` + `OutsideView` that draws sky/rain through exit portals with no blue
hole (Stage 4), and clip entities/particles to the PView visible set (Stage 5).
Result: cottage interior is sealed, outdoor sky visible through the door, no transparent
walls, no entity bleed — at both cottage and dungeon.
**Architecture (2-3 sentences):** Retail uses one cell graph and one portal-visibility
traversal (`PView`) rooted at the committed player cell (`CellGraph.CurrCell`); the
inside/outside decision is a single `(id & 0xFFFF) < 0x100` + `seen_outside` predicate,
and landscape is drawn through exit-portal clip regions by `DrawCells`, not as a separate
stencil pass. acdream already has `PortalVisibilityBuilder` (a faithful `PView` port),
`EnvCellRenderer` (cell-shell draw), `ClipFrame`/`ClipFrameAssembler` (the outdoor clip
machinery), and `CellGraph.CurrCell` (the physics membership answer) — Stage 3 deletes
the `FindCameraCell` grace-frame path and wires `CurrCell` as the mandatory root; Stage 4
uses the existing `OutsideView` planes to drive terrain + sky draw indoors; Stage 5 uses
the `PortalVisibilityFrame.OrderedVisibleCells` set to clip entity/particle draws.
**Tech stack:** .NET 10 / C# / OpenGL 4.3 + bindless / Silk.NET. Tests in
`tests/AcDream.Core.Tests/` (unit + replay) and `tests/AcDream.App.Tests/` (app-layer);
no new test projects.
**REQUIRED SUB-SKILL:** `superpowers:subagent-driven-development` — each stage should
be dispatched as a bounded implementation chunk with a clear spec and acceptance criteria.
**EXECUTION POLICY (user directive 2026-06-02):** NO intermediate user visual gates. The
per-stage "visual gate" sections below are demoted to **internal build+test-green
checkpoints** (the orchestrator launches/inspects only if a probe is needed to settle a
question). The user performs a **single final visual verification** after ALL stages land,
and the **full Core+App test suite must be GREEN** at that point (no broken/red tests).
Interim states (e.g. Stage 3's full-screen sky before Stage 4 clips it) are acceptable
because no user sees them before the final gate. The `DoorwayMembershipReplayTests` must
also use a **committed** fixture (a trimmed subset of `doorway-capture.jsonl`), not the
untracked 364K capture, so the suite stays green/portable (folded into T0).
---
## Prerequisite reading (implementer must read before coding)
Before any Stage 3+ work:
1. `docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md`
§2 (target architecture), §5 (risks), §6 (acceptance).
2. This plan, from top to bottom.
3. Current render loop: `GameWindow.cs` lines 71397513 — the visibility, terrain, entity,
and particle draws. Confirm line numbers before editing (they shift with every commit).
4. `src/AcDream.App/Rendering/CellVisibility.cs``FindCameraCell` (line 389), grace
counter (line 214), `ComputeVisibilityFromRoot` (line 356), `GetVisibleCellsFromRoot`
(line 539).
5. `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — BFS + `OutsideView` handling
(lines 1239).
---
## File structure (created / modified)
| File | Action | Responsibility |
|------|--------|----------------|
| `src/AcDream.App/Rendering/GameWindow.cs` | Modify (lines ~71397513) | Stage 3: remove `FindCameraCell` grace-frame fallback; unify the indoor/outdoor gate; Stage 4: drive terrain+sky from `OutsideView` indoors; Stage 5: entity/particle clip. |
| `src/AcDream.App/Rendering/CellVisibility.cs` | Modify (lines ~389446) | Stage 3: delete or stub the AABB `FindCameraCell` grace-frame band-aid; promote `ComputeVisibilityFromRoot` to the sole path; expose `SeenOutside` of the root cell. |
| `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | Possibly extend | Stage 4: confirm `OutsideView` polygons are already propagated for the no-blue-hole case; add landscape-viewpoint helper if absent. |
| `tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs` | Create | Stage 3 unit tests: root-selection logic + `seen_outside` terrain/sky gate. |
| `tests/AcDream.Core.Tests/Rendering/PViewBfsTests.cs` | Create or extend | Stage 4 unit tests: `OutsideView` non-empty for a cell with an exit portal; sealed-dungeon `OutsideView`-empty. |
| `tests/AcDream.Core.Tests/Rendering/EntityClipTests.cs` | Create | Stage 5: cell-clip predicate unit tests. |
> **No new production-code files** except the three new test files above. All render changes
> are surgical edits to existing files.
---
## Test-hygiene task (run FIRST, before any Stage 3 code)
**Purpose:** establish a deterministic green baseline so Stage 35 additions don't mask
pre-existing noise.
### Pre-existing known failures (documented; do NOT fix unless noted)
| Test class | Failure kind | Root cause | Action |
|---|---|---|---|
| `PhysicsResolveCapture` statics | 819 flaky failures per run | Static env-var read shared across test classes in the same process | Fix test isolation (see task T0.1 below). |
| `CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap` | "document-the-bug" — passes while bug exists | Documents the cottage-floor cap; the Stage 2 membership fix is expected to make this test flip to FAILING, which means the bug is now fixed. Then update it to assert the fixed behavior. | After Stage 2 lands, update this test to assert the fix. |
| `DoorBugTrajectoryReplayTests.Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug` | Same pattern | Documents the door squeeze-through | After Stage 4 seals the pipeline, revisit and update to assert fixed behavior if applicable. |
| `BSPStepUpTests` (some variants) | Intermittent | Static BSP state | Same static-isolation fix. |
### T0.1 — Fix static-leak test isolation
- [ ] **Find the static fields causing test leakage.** Search for `static readonly` or
`static` initializers in `PhysicsResolveCapture.cs` and `PhysicsDiagnostics.cs` that
read env vars at class-init time (before any test sets them). The symptom is that a test
class that sets `ACDREAM_CAPTURE_RESOLVE` or `ACDREAM_PROBE_*` poisons the shared
statics for downstream test classes in the same process.
- Files: `src/AcDream.Core/Physics/PhysicsResolveCapture.cs`,
`src/AcDream.Core/Physics/PhysicsDiagnostics.cs`.
- Pattern to find: `static readonly bool s_xxx = Environment.GetEnvironmentVariable(...)`.
- [ ] **Add `[Collection(nameof(PhysicsResolveCapture))]` isolation OR convert static
env-var reads to instance-time reads with a reset method.** The simplest retail-faithful
fix: add a `ResetForTest()` static method that re-reads all flags, call it in
`IDisposable.Dispose` of any test class that sets env vars. Alternatively annotate the
affected test classes with `[Collection("IsolatedPhysicsCapture")]` to force sequential
execution (xUnit's isolation model).
- MUST NOT change production behavior — only test teardown.
- [ ] **Run `dotnet test -c Debug` twice in a row and confirm the failure set is
deterministic** (same tests, same count, or fully green). The pre-existing documented-bug
tests (`LiveCompare_FirstCap_FixClosesCottageFloorCap`, etc.) are allowed to remain red
until their stage lands — record their names in a comment so Stage 4 implementers know
to flip them.
Commit: `test: fix PhysicsResolveCapture static-leak isolation`
---
## Stage 3 — Render-root unification
**Goal:** root render visibility at `CellGraph.CurrCell` + `seen_outside`; remove the
AABB `FindCameraCell` grace-frame source-of-truth; port the `CellManager::ChangePosition`
landscape/sky policy; camera offset via graph child lookup. Visual gate: no render-branch
strobe; landscape policy correct indoors/outdoors/dungeon.
**Retail anchors:**
- `SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635)` — the single indoor/outdoor
decision (viewer landcell → `LScape::draw`; viewer EnvCell → `DrawInside`).
- `CellManager::ChangePosition @ 0x004559b0(pseudo_c:94601)` — landscape kept live iff
`seen_outside || isLandCell`.
- `CEnvCell::find_visible_child_cell @ 0x0052dc50 (pseudo_c:311397)` — camera-offset
child cell lookup (rooted at the player cell, not the eye).
**Key current-code map:**
- `GameWindow.cs:71627166``physicsRoot` derivation from `CellGraph.CurrCell`; already
exists but only used as a FALLBACK to `ComputeVisibility`.
- `GameWindow.cs:7166``ComputeVisibilityFromRoot` call (already the main path via W2a).
- `GameWindow.cs:71857187``playerInsideCell` computation (calls `IsInsideAnyCell`,
which is an independent AABB scan — this is a separate issue from `FindCameraCell` but
related).
- `GameWindow.cs:7267``bool renderSky = !cameraInsideCell` — the current sky gate,
keyed off `visibility?.CameraCell is not null`.
- `GameWindow.cs:7406``if (terrainClipMode == TerrainClipMode.Skip)` — the current
terrain gate (no draw when `Skip`).
- `CellVisibility.cs:389``FindCameraCell` (AABB, with grace-frame).
- `CellVisibility.cs:214``CellSwitchGraceFrameCount = 3`.
- `CellVisibility.cs:356``ComputeVisibilityFromRoot` — already the production path.
### T3.1 — Eliminate the `FindCameraCell` fallback path
**Current code (CellVisibility.cs, ~line 356-369):**
```csharp
public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos)
{
if (root is null)
return ComputeVisibility(fallbackPos); // <-- this calls FindCameraCell(fallbackPos)
...
}
```
- [ ] **Read `CellVisibility.cs:356370` and `ComputeVisibility` (line ~521-527)** to
confirm the fallback chain: `ComputeVisibilityFromRoot(null, pos)` calls
`ComputeVisibility(pos)` which calls `FindCameraCell(pos)` with the AABB + grace-frame
logic.
- [ ] **Change `ComputeVisibilityFromRoot` so a null `root` returns `null` instead of
calling `ComputeVisibility`.** The caller at `GameWindow.cs:7166` already handles a
null result as the "outdoor root" path (no interior portal frame, everything slot 0).
The fallback was the old bridge for the transition from `FindCameraCell` to
`CurrCell`-based root; it is now dead because W2a ensures `CurrCell` is set from the
first tick.
```csharp
// BEFORE (CellVisibility.cs:~359-361):
if (root is null)
return ComputeVisibility(fallbackPos);
// AFTER:
if (root is null)
return null; // outdoor root: caller handles null as "player is outside"
```
- [ ] **Confirm the fallback for `physicsRoot == null` in `GameWindow.cs:71627166`:**
when `CellGraph.CurrCell` is null (pre-spawn), `physicsRoot` is null, and
`ComputeVisibilityFromRoot(null, …)` returns null — the outdoor path runs, which is
correct (pre-spawn, no indoor draws). No additional change needed here.
- [ ] **Delete (or comment-out-for-reference, then delete in the same commit) the
`FindCameraCell` method** (`CellVisibility.cs:389446`) and the grace-frame counter
(`_cellSwitchGraceFrames` field, `_lastCameraCell` field). If `ComputeVisibility` (the
non-root variant, line 521) has other callers, check via Grep first — if it is ONLY
called from `ComputeVisibilityFromRoot`, delete it too; otherwise stub it to call
`GetVisibleCellsFromRoot(null, …)` and note the debt.
**Confirm in Step 1:** search for all callers of `FindCameraCell` and
`ComputeVisibility` in the App project before deleting. Expected: zero callers outside
`CellVisibility.cs` itself and the one `GameWindow.cs` chain. If any other callers exist
note them and do not delete until they are migrated.
Commit: `refactor(render): Stage 3 — delete FindCameraCell AABB grace-frame fallback`
---
### T3.2 — Port `seen_outside` terrain/sky gate (retail `CellManager::ChangePosition`)
**The retail policy (verified, `pseudo_c:94649`):**
```
if (seen_outside || keep_lscape_loaded)
keep landscape + terrain
else
LScape::release_all (dungeon)
```
The current acdream code uses `cameraInsideCell` (any non-null `CameraCell` from
`ComputeVisibilityFromRoot`) to gate both sky and terrain. This has two problems:
1. It gates sky/terrain on "camera is inside ANY cell" — not on `seen_outside`. A dungeon
cell has `SeenOutside = false` but `cameraInsideCell = true`, and the current code
suppresses sky (correct for dungeon) but does NOT actively gate terrain for a dungeon
that has accidentally non-empty `OutsideView` (edge case).
2. Sky and terrain should be gated SEPARATELY: `seen_outside` controls whether terrain is
available at all; the `OutsideView` from the BFS controls whether it draws THIS frame.
- [ ] **Extract `seenOutside` from the PVS root cell.** After the
`ComputeVisibilityFromRoot` call at `GameWindow.cs:7166`, add:
```csharp
// Retail CellManager::ChangePosition:94649 — keep landscape iff seen_outside.
bool rootSeenOutside = physicsRoot?.SeenOutside ?? true; // outdoor root (null) → always seen_outside
```
(`LoadedCell.SeenOutside` is already populated at `GameWindow.cs:5718` from
`envCell.Flags.HasFlag(EnvCellFlags.SeenOutside)`.)
- [ ] **Replace the `playerInsideCell` AABB scan with `seenOutside`-derived logic.**
Currently (`GameWindow.cs:71857187`):
```csharp
bool playerInsideCell = (_playerMode && _playerController is not null)
? _cellVisibility.IsInsideAnyCell(_playerController.Position)
: cameraInsideCell;
```
This calls `IsInsideAnyCell` (brute-force AABB scan of all cells every frame). Replace
with: `bool playerInsideCell = cameraInsideCell && !rootSeenOutside;` — i.e., player
is "fully indoor" (no sky/sun) only when inside a cell AND that cell cannot see outside
(a dungeon). Building interiors with `seen_outside` keep the sun because the sky is
visible through the door. Document the retail anchor (`CellManager::ChangePosition
@ 0x004559b0, pseudo_c:94649`). **Confirm in Step 1**: verify that `UpdateSunFromSky`
(called at `GameWindow.cs:7202`) behaves correctly with this new semantics — `true` =
kill sunlight (dungeon); `false` = keep sunlight (outdoor or building interior).
If the sun logic inverts this flag, invert accordingly.
- [ ] **Replace `bool renderSky = !cameraInsideCell` (`GameWindow.cs:7267`) with:**
```csharp
// Sky suppressed when inside a sealed interior (no exit portals). Building interiors
// with seen_outside still draw sky — it appears through the doorway via OutsideView
// (Stage 4). Outdoor root: always render sky. Retail RenderNormalMode:92649.
bool renderSky = !cameraInsideCell || rootSeenOutside;
```
For a building interior: `cameraInsideCell = true`, `rootSeenOutside = true` →
`renderSky = true` (sky is drawn, clipped to the doorway by Stage 4).
For a dungeon: `cameraInsideCell = true`, `rootSeenOutside = false` →
`renderSky = false`.
For outdoor: `cameraInsideCell = false` → `renderSky = true`.
**Note:** This means sky now renders indoors for `seen_outside` cells. Until Stage 4
is landed, sky will draw full-screen in building interiors (wrong but expected interim
regression). Stage 4's `OutsideView` clipping will confine it to the doorway opening.
Document this as an expected interim state; do not gate on a TODO flag.
- [ ] Similarly, update the weather gate at `GameWindow.cs:7506` (`if (!cameraInsideCell)`)
to use `renderSky` instead, so rain also follows the same policy.
Commit: `feat(render): Stage 3 — seen_outside terrain/sky gate per CellManager::ChangePosition`
---
### T3.3 — Camera-offset child-cell lookup (retail `find_visible_child_cell`)
**Context:** In 3rd-person chase mode, the camera eye drifts outside the player cell
(U.4c). The render root is already pinned to the player cell (`visRootPos` at
`GameWindow.cs:7153`). This task ensures that when the camera is slightly outside the
player's cell, we use a graph/BSP child lookup rather than an AABB reclassification to
find the camera's cell for the projection.
**Retail anchor:** `CEnvCell::find_visible_child_cell @ 0x0052dc50 (pseudo_c:311397)` —
walks the cell's `stab_list` looking for the first cell whose `PointInCell` contains the
query point. It is used by `AdjustPosition` (pc:280028) for the camera position, not a
fresh AABB scan.
**Current state:** `GameWindow.cs:71537165` already roots the BFS at the PLAYER cell
(U.4c). The AABB `FindCameraCell` (now deleted by T3.1) is the old camera-cell resolver.
After T3.1, there is no AABB camera reclassification. The player's `CurrCell` is used as
the PVS root, and that is all we need for the portal traversal.
- [ ] **Add `CellGraph.FindVisibleChildCell(Vector3 worldPoint)` to `CellGraph.cs`
(new method, ~10 lines):**
```csharp
/// Retail find_visible_child_cell (pseudo_c:311397): walk CurrCell's StabList + self,
/// return the first EnvCell whose PointInCell is true for worldPoint.
/// Used to resolve the camera cell in 3rd-person from the physics cell graph,
/// NOT from a fresh AABB scan.
public EnvCell? FindVisibleChildCell(uint rootId, Vector3 worldPoint)
{
if (!_envCells.TryGetValue(rootId, out var root)) return null;
if (root.PointInCell(worldPoint)) return root;
foreach (var stabId in root.StabList)
if (_envCells.TryGetValue(stabId, out var stab) && stab.PointInCell(worldPoint))
return stab;
return null;
}
```
File: `src/AcDream.Core/World/Cells/CellGraph.cs` (append after `GetVisible`).
- [ ] **Wire it in `GameWindow.cs`**: After W2a lands, the only use of `FindCameraCell`
was for the `physicsRoot` FALLBACK. That fallback is now deleted (T3.1). The
`FindVisibleChildCell` is used for the VISUAL projection override when the chase camera
is outside the player cell — for example, to drive `envCellViewProj` from the correct
cell for portal-side tests in `PortalVisibilityBuilder.Build`. Currently `visRootPos`
(the player pos) is passed as the side-test anchor (already correct per U.4c). This
task is **low-risk**: the projection is driven from `camera.View * camera.Projection`
(the actual eye), which is unchanged. The side-test anchor (`visRootPos`) is already
the player pos. **Confirm in Step 1:** verify no current code site calls
`FindCameraCell` for the camera projection. If there is none, this T3.3 may reduce to
the `FindVisibleChildCell` method addition only (needed by Stage 4's camera-outside-door
scenario).
Commit: `feat(core): Stage 3 — CellGraph.FindVisibleChildCell (retail find_visible_child_cell)`
---
### T3.4 — Unit tests for render-root selection
File: `tests/AcDream.Core.Tests/Rendering/CellGraphRootTests.cs` (new).
- [ ] **Test: `RootSelection_OutdoorRoot_NullCurrCell_ReturnsFalseSeenOutside`**
When `CurrCell == null` (pre-spawn), `seenOutside = true` (outdoor default),
`renderSky = true`, `playerInsideCell = false`.
- [ ] **Test: `RootSelection_BuildingInterior_SeenOutside_SkyRendered`**
`CurrCell = EnvCell with SeenOutside=true` → `rootSeenOutside = true` →
`renderSky = true`, `playerInsideCell = false`.
- [ ] **Test: `RootSelection_Dungeon_NoSeenOutside_SkyNotRendered`**
`CurrCell = EnvCell with SeenOutside=false` → `rootSeenOutside = false` →
`renderSky = false`, `playerInsideCell = true`.
- [ ] **Test: `FindVisibleChildCell_PlayerCellContains_ReturnsPlayerCell`**
Player in cell A; query point inside A → returns A.
- [ ] **Test: `FindVisibleChildCell_StabListContains_ReturnsNeighbour`**
Player in cell A with StabList=[B]; query point outside A but inside B → returns B.
- [ ] **Test: `FindVisibleChildCell_NeitherContains_ReturnsNull`**
Query point outside all cells → null.
These tests are pure-logic (no GL). Run: `dotnet test --filter
"FullyQualifiedName~CellGraphRootTests" -c Debug`.
Commit: `test(render): Stage 3 — CellGraphRootTests`
---
### Stage 3 visual gate
After T3.1T3.4 green + `dotnet build` green:
> Ask the user to launch the client, walk to the Holtburg cottage, enter it, and confirm:
> - **Outdoor**: terrain + sky draw as before. No regression.
> - **Building interior (seen_outside=true)**: walls/floors render; sky may draw
> full-screen (interim regression until Stage 4); no strobe between indoor/outdoor state.
> - **Dungeon** (if accessible): no sky, no terrain; walls render.
> - Cell ping-pong `0170↔0031` is gone (already fixed by Stages 12; confirm no
> regression).
---
## Stage 4 — PView traversal + seamless seal
**This is the big one.** Goal: draw landscape through exit portals clipped to the doorway
(kills the blue hole); seal ceilings; fix the `EnvCellRenderer` GL_BLEND regression.
Visual gate: interior sealed, sky/rain through the door, no blue-hole, no transparent walls.
**Retail anchors:**
- `PView::ConstructView @ 0x005a57b0 (pseudo_c:433750)` — BFS, already ported to
`PortalVisibilityBuilder.Build`.
- `PView::DrawCells @ 0x005a4840 (pseudo_c:432709)` — `if outside_view.view_count > 0 →
LScape::draw first; conditional Z-clear (NOT color); then draw indoor cells`.
- `PView::DrawInside @ 0x005a5860 (pseudo_c:433793)` — top-level indoor entry; calls
`ConstructView` then `DrawCells`.
- `PView::ClipPortals @ 0x005a5520 (pseudo_c:433662)` — exit portal →
`outside_view.view_count += 1`, clip region registered.
- `SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635,92667)` — if `seen_outside`,
call `LScape::update_viewpoint(get_outside_cell_id(&viewer))` BEFORE `DrawInside`.
**Current state:**
- `PortalVisibilityBuilder.Build` already produces `frame.OutsideView` with exit-portal
clip polygons (`PortalVisibilityBuilder.cs:163175`).
- `ClipFrameAssembler.Assemble` already extracts `OutsideView` planes and calls
`clipFrame.SetTerrainClip(outsidePlanes)` when planes exist (the binding=2 terrain UBO).
- `GameWindow.cs:74067431` already has the `TerrainClipMode.Skip/Scissor/Planes` terrain
gate — terrain is SKIPPED when the player is indoor and `OutsideView` is empty.
- **GAP:** When `OutsideView` is non-empty (exit portal visible from inside), terrain and
sky should draw (clipped to the doorway). Currently sky is still suppressed by
`renderSky = !cameraInsideCell` (fixed in T3.2), but the terrain is gated correctly
(Planes mode if OutsideView has planes). However, sky is drawn BEFORE the portal frame
is assembled, so even with T3.2's `renderSky = true`, the sky draws FULL-SCREEN before
the clip bracket — it is not clipped to the doorway opening.
### T4.1 — Move sky draw inside the portal-clip bracket
**Problem:** Sky (`_skyRenderer?.RenderSky`) draws at `GameWindow.cs:72687275`, BEFORE
the clip bracket (`glEnable(ClipDistance)` at line ~7387). This means sky is never
clipped by the `gl_ClipDistance` planes, so it bleeds full-screen indoors.
**Fix:** Move the sky draw to AFTER the terrain draw but BEFORE the entity draw, inside
the clip bracket. Sky must write to depth so entities z-test against it.
- [ ] **Read `GameWindow.cs:72557292`** (sky draw block) and `73777390` (clip bracket
open). Confirm current order: sky → `IsLiveModeWaitingForLogin` goto → clip bracket
opens → terrain → cells → entities.
- [ ] **Move `_skyRenderer?.RenderSky(...)` and the `SkyPreScene` particle pass to
inside the clip bracket**, just before the terrain draw (`GameWindow.cs:~7405`).
Structure becomes:
```
[clip bracket opens: glEnable(ClipDistance)]
sky draw (RenderSky + SkyPreScene particles) — now inside clip so gated to OutsideView
terrain draw
EnvCellRenderer opaque
entity dispatcher
EnvCellRenderer transparent
[clip bracket closes]
particles (scene pass, not sky)
weather + SkyPostScene (still outside-gated by renderSky)
```
**Confirm in Step 1:** verify `gl_ClipDistance` writes in `sky.vert` — if the sky
shader does NOT write `gl_ClipDistance[i]`, enabling clip while sky draws is harmless
(undefined behavior for an unwritten clip plane yields no clipping per the spec note
in the code comment at `GameWindow.cs:~1097`). However, for the sky to be clipped TO
the doorway we NEED the sky shader to write `gl_ClipDistance`. Check
`src/AcDream.App/Rendering/Shaders/sky.vert` — if it does not write clip distances,
add them mirroring `mesh_modern.vert`'s pattern. If that is too large a shader change
for Stage 4, use the Scissor fallback: before the sky draw, set a scissor rectangle
from `clipAssembly.TerrainScissorNdcAabb` (already computed for terrain), draw sky,
then disable scissor. The scissor approach is simpler and works without shader changes.
Commit: `feat(render): Stage 4 — move sky draw inside portal-clip bracket`
---
### T4.2 — Landscape viewpoint pre-position (retail `LScape::update_viewpoint`)
**Retail:** Before `DrawInside`, when `seen_outside`, retail calls
`LScape::update_viewpoint(lscape, Position::get_outside_cell_id(&viewer))` (`RenderNormalMode:92667`).
This sets the terrain system's viewpoint to the OUTDOOR landcell corresponding to the
indoor camera position — so the terrain, when drawn through the doorway, is correctly
positioned.
**Current state:** Terrain draw at `GameWindow.cs:7425/7430` calls
`_terrain?.Draw(camera, frustum, …)`. The terrain system's viewpoint is managed by
`LandblockStreamer` based on the player's landblock (streaming center). This is
separate from the per-frame projection the terrain shader sees.
- [ ] **Confirm in Step 1:** does `TerrainModernRenderer.Draw` use the camera's
`view * projection` matrix from the `camera` argument, or a separately-tracked
"landscape viewpoint"? If the shader reads the matrix from the scene UBO (which has
the real camera view-proj), no change is needed — the terrain already projects from the
correct eye position. If there is a separate "landscape eye" or terrain-specific
viewpoint that gets reset to the player position rather than the camera position, it
needs to be set to `Position.get_outside_cell_id(&viewer)` equivalent before the draw.
Most likely acdream does NOT have this divergence (the terrain uses `camera.View *
camera.Projection` just like everything else), so this task may be a no-op. Confirm
and either add a `// verified: no viewpoint override needed` comment or fix it.
Commit (or no-op annotation): `docs(render): Stage 4 — verify terrain viewpoint is camera-relative`
---
### T4.3 — Conditional Z-clear for the doorway (retail `DrawCells:432731`)
**Retail:** After drawing the landscape (terrain + sky), retail does a CONDITIONAL Z-clear:
```c
if (forceClear || D3DPolyRender::portalsDrawnCount != 0)
RenderDevice->Clear(4, 0x820fc0, 1.0); // flag 4 = Z-buffer ONLY, NOT color
```
This clears depth ONLY WHERE the portal geometry was drawn (exit portal polygons), so
indoor geometry draws on top of the landscape without z-fighting through the doorway.
It is a Z-clear, NOT a color-clear — so there is no "blue hole" painted.
**Current state:** acdream does not perform this clear. The current terrain `TerrainClipMode.Skip`
path means terrain never draws indoors, so z-fighting has not been observed. Once terrain
draws through the doorway (T4.1 fixed), z-fighting may appear at the doorway plane.
- [ ] **After the terrain/sky draw block (after T4.1's sky+terrain), add a conditional
depth-only clear gated on `clipAssembly is not null && frame.OutsideView.Polygons.Count > 0`:**
```csharp
// Retail PView::DrawCells:432731 — conditional Z-clear after landscape, before indoor geometry.
// Clears depth in the doorway region so indoor walls draw over the terrain.
// Z-buffer only (glClear(GL_DEPTH_BUFFER_BIT)) — NOT color — so no blue hole.
if (clipAssembly is not null && pvFrame.OutsideView.Polygons.Count > 0)
{
// Scissor to the doorway AABB before clearing to avoid clearing depth for
// the entire screen (only the exit-portal region needs it).
var fb = _window!.FramebufferSize;
float nx0 = System.Math.Clamp(terrainScissorNdc.X, -1f, 1f);
float ny0 = System.Math.Clamp(terrainScissorNdc.Y, -1f, 1f);
float nx1 = System.Math.Clamp(terrainScissorNdc.Z, -1f, 1f);
float ny1 = System.Math.Clamp(terrainScissorNdc.W, -1f, 1f);
int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
_gl.Clear(ClearBufferMask.DepthBufferBit);
_gl.Disable(EnableCap.ScissorTest);
}
```
This requires `pvFrame` to be in scope. Currently `pvFrame` is declared inside the
`if (clipRoot is not null)` block (`GameWindow.cs:7315`). Either hoist it or use
`clipAssembly.HasOutsideView` (a property to add to `ClipFrameAssembly` if needed).
**Confirm in Step 1:** check whether `ClipFrameAssembly` already exposes
`OutsideView.Polygons.Count` or an equivalent. Add if absent.
Commit: `feat(render): Stage 4 — conditional Z-clear at doorway portal (retail DrawCells:432731)`
---
### T4.4 — Verify and document ceilings-are-capped (no code change expected)
**Retail:** Ceilings are capped BY CONSTRUCTION — each EnvCell's `drawing_bsp` is a
closed mesh (floor, 4 walls, ceiling authored in the dat). There is no explicit "cap
ceiling" step. `EnvCellRenderer.Render` draws each visible cell's mesh.
- [ ] **Confirm in Step 1:** Verify that `EnvCellRenderer.RegisterCell` stores the full
cell mesh including ceiling polygons (not just floor + walls). The dat baking happens
in `GameWindow.cs:54565502` (the `BuildLoadedCell`/`CellMesh.Build` call). If ceiling
polygons are included in `CellMesh.Build`, the ceiling is capped. If they are
explicitly excluded, add them.
- [ ] **Add a comment** in `EnvCellRenderer.Render` documenting that ceiling is present
by construction (retail `DrawCells:432745`, `cell->structure->drawing_bsp` draws all
cell surfaces including ceiling). No code change if already correct.
Commit (or annotation): `docs(render): Stage 4 — document ceiling sealed by EnvCell dat`
---
### T4.5 — Verify `EnvCellRenderer` GL_BLEND fix is complete and extends to the ceiling pass
**Current state (ALREADY FIXED):** `EnvCellRenderer.cs:10041023` already sets
`_gl.Disable(EnableCap.Blend); _gl.DepthMask(true)` for the opaque pass and
`_gl.Enable(EnableCap.Blend); _gl.DepthMask(false)` for the transparent pass. This fix
is the U.4 root-cause fix for the transparent-walls regression (noted in the comment at
`EnvCellRenderer.cs:1004`).
- [ ] **Verify the fix covers the call order after T4.1's sky-inside-clip-bracket
change.** After T4.1, the order is: sky → terrain → `EnvCellRenderer.Render(Opaque)`
→ entity dispatcher → `EnvCellRenderer.Render(Transparent)`. Sky may leave GL state
dirty. The fix at line 1004 already re-establishes Blend + DepthMask at the TOP of
`Render(…)`, so it is robust against any preceding state. Confirm no new state is
needed (e.g. `DepthFunc`, `CullFace` — these are set at `GameWindow.cs:7030` before
the clip bracket).
- [ ] **Add a `_gl.DepthMask(true)` reset at the END of `EnvCellRenderer.Render`** if
the transparent pass leaves `DepthMask(false)`. Currently the code at line 1012 says
"Restored to opaque defaults at the end of the draw loop" — verify this restoration
actually exists (search for the matching `_gl.DepthMask(true)` after the draw loop in
`EnvCellRenderer.cs:10251239`). If missing, add it before the method returns.
Commit: `fix(render): Stage 4 — verify EnvCellRenderer GL state restoration after transparent pass`
---
### T4.6 — Unit tests for PView BFS + OutsideView behavior
File: `tests/AcDream.Core.Tests/Rendering/PViewBfsTests.cs` (new; tests use
`PortalVisibilityBuilder.Build` directly with synthetic `LoadedCell` fixtures).
- [ ] **Test: `OutsideView_NonEmpty_WhenExitPortalVisible`**
Construct a `LoadedCell` with one portal whose `OtherCellId = 0xFFFF` (exit portal)
and a non-degenerate polygon facing the camera. Call `PortalVisibilityBuilder.Build`
from a camera position on the interior side. Assert `frame.OutsideView.Polygons.Count > 0`.
- [ ] **Test: `OutsideView_Empty_WhenNoExitPortal`**
A cell with portals connecting to other interior cells only (`OtherCellId != 0xFFFF`).
Assert `frame.OutsideView.Polygons.Count == 0`.
- [ ] **Test: `VisibleSet_ContainsRootCell_Always`**
Any cell graph → root cell is always in `frame.OrderedVisibleCells` and is first.
- [ ] **Test: `VisibleSet_MultiCell_OrderedClosestFirst`**
Root cell with portal to neighbour farther away → root appears at index 0.
- [ ] **Test: `BFS_Terminates_OnCyclicPortalGraph`**
Root A→B, B→A (cycle). BFS must terminate with exactly 2 cells in `OrderedVisibleCells`
(no infinite loop). This is the #95 dungeon-blowup guard.
Run: `dotnet test --filter "FullyQualifiedName~PViewBfsTests" -c Debug`.
Commit: `test(render): Stage 4 — PViewBfsTests (OutsideView + BFS termination)`
---
### Stage 4 visual gate
After T4.1T4.6 green + `dotnet build` green:
> Ask the user to launch the client at Holtburg cottage:
> - **Through the doorway from inside**: sky + rain visible in the doorway opening,
> NOT full-screen. Outside terrain/buildings visible through the door.
> - **No blue clear-color hole** in the doorway.
> - **Ceiling is present** (no "no ceiling" regression).
> - **Walls are opaque** (the transparent-walls regression is not re-introduced).
> - **Dungeon** (if accessible): no terrain, no sky, walls/floors render — PVS BFS
> converges without blowup.
---
## Stage 5 — Entity / particle cell clipping
**Goal:** Clip entities/particles to the PView visible cell set, not the world frustum.
Kills NPC/door/smoke bleed-through.
**Retail anchor:**
- `PView::DrawCells @ pseudo_c:432868432882` — iterates `cell_draw_list` and for each
calls `DrawObjCellForDummies(cell)`. Objects in a non-visible cell are never iterated.
- `CObjCell::object_list` — objects live in their cell's object list (from `enter_cell`/
`leave_cell`). Entity→cell membership comes from the physics shadow lists.
**Current state:** `WbDrawDispatcher.Draw` at `GameWindow.cs:74737476` already takes
`visibleCellIds: visibility?.VisibleCellIds`. The `WbDrawDispatcher` uses this to gate
entity draws (ParentCellId ∈ visibleCellIds). This is largely correct already. The
gap: entities OUTSIDE a visible cell but INSIDE the world frustum may still draw.
### T5.1 — Verify entity-clip path is fully wired
- [ ] **Grep `WbDrawDispatcher.Draw` signature and its `visibleCellIds` parameter usage.**
Find `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, search for `visibleCellIds`.
Confirm: when `visibleCellIds != null`, entities whose `ParentCellId` is NOT in the set
are culled. If not: add the cull inside the dispatcher's entity loop.
- [ ] **Confirm `ParentCellId` is populated correctly for EnvCell-static objects.**
Static objects inside a cell (inn furniture, door) have `ParentCellId` set to the
cell's id. Verify at `GameWindow.BuildInteriorEntitiesForStreaming` or the matching
`AddEntitiesToExistingLandblock` site that `ParentCellId` is set. If null or zero for
static objects, the cell-clip gate misses them.
- [ ] **Use `OrderedVisibleCells` instead of `VisibleCellIds` for entity ordering.**
`visibility.VisibleCellIds` is a `HashSet<uint>` (unordered). For strict retail-faithful
entity draw ordering, the dispatcher should iterate in `frame.OrderedVisibleCells` order
(closest first). This is an enhancement, not a blocker. Add a TODO comment if deferred.
Commit: `fix(render): Stage 5 — verify entity-clip visibleCellIds gate in WbDrawDispatcher`
---
### T5.2 — Particle cell clipping
**Current state:** `_particleRenderer.Draw(_particleSystem, camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene)` at `GameWindow.cs:74947496` does NOT take
a `visibleCellIds` filter.
- [ ] **Check if `ParticleRenderer.Draw` has a cell-filter parameter.** If yes, pass
`visibility?.VisibleCellIds`. If no, add it — particles in an invisible cell (NPC
smoke from behind a sealed wall) should not draw.
- [ ] **Gate `SkyPreScene` and `SkyPostScene` particle passes by `renderSky`** (already
gated at `GameWindow.cs:7506` for weather; confirm `SkyPreScene` at line 7272 is also
gated by `renderSky`). This is the particle equivalent of Stage 3's sky gate.
Commit: `fix(render): Stage 5 — particle cell-clip via visibleCellIds`
---
### T5.3 — Unit tests for entity/particle cell-clip predicate
File: `tests/AcDream.Core.Tests/Rendering/EntityClipTests.cs` (new).
- [ ] **Test: `EntityClip_ParentInVisibleSet_IsIncluded`**
Entity with `ParentCellId = 0xA9B40170` (the cottage cell); `visibleCellIds =
{0xA9B40170}` → entity included.
- [ ] **Test: `EntityClip_ParentNotInVisibleSet_IsExcluded`**
Entity with `ParentCellId = 0xA9B40172` (sealed room not in PVS); `visibleCellIds =
{0xA9B40170}` → entity excluded.
- [ ] **Test: `EntityClip_NullVisibleSet_IncludesAll`**
`visibleCellIds = null` (outdoor root) → all entities included (no gate).
Run: `dotnet test --filter "FullyQualifiedName~EntityClipTests" -c Debug`.
Commit: `test(render): Stage 5 — EntityClipTests`
---
### Stage 5 visual gate
> Ask the user to launch and walk to the Holtburg cottage door:
> - **NPC just outside the door**: should be visible through the door when looking out,
> but NOT visible through the wall from inside looking sideways (not through the portal
> opening).
> - **Smoke/particles from objects in invisible cells** do not bleed through walls.
> - **No regression** on entity visibility in outdoor scenes (all entities visible when
> `visibleCellIds` is null).
---
## Final acceptance — combined visual gate
After ALL stages green + full `dotnet test -c Debug` green:
> Ask the user to verify the full acceptance criteria (spec §6):
>
> **Cottage (Holtburg):**
> - [ ] Walk through the doorway: no strobe (Stage 12, already done).
> - [ ] Inside the cottage: interior sealed (walls, floor, ceiling all present).
> - [ ] Looking at the door from inside: sky + rain visible through the doorway opening,
> NOT full-screen. No blue clear-color hole.
> - [ ] No transparent walls. No terrain bleeding through the floor.
> - [ ] No entity/particle bleed through sealed walls.
>
> **Dungeon (if accessible):**
> - [ ] No terrain, no sky (sealed dungeon).
> - [ ] Walls/floors/ceilings render. Portal traversal converges (no FPS drop from BFS
> blowup — issue #95 confirmed bounded by `seen.Add` gate).
>
> **`dotnet build` green.**
> **`dotnet test -c Debug` — full suite green** (or the documented static-leak subset
> fixed by T0.1; no new deterministic failures vs pre-Phase-W baseline).
---
## Roadmap update (implementer: do this in the same commit that clears the final visual gate)
- [ ] Update `docs/plans/2026-04-11-roadmap.md` — move Phase W to "shipped" with the
commit SHA.
- [ ] Update CLAUDE.md "Currently working toward" section to the next milestone.
- [ ] Add a memory note to `memory/` if there are durable lessons (e.g. the sky-inside-
clip-bracket pattern, the Z-clear for doorway depth).
---
## Self-review
### Spec coverage
| Spec §2 item | Covered by |
|---|---|
| "Membership is transition-owned" | Stage 12 (already done/shipped) |
| "Render roots at `CurrCell` + `seen_outside`" | T3.1, T3.2 |
| "Camera offset via graph/BSP child lookup" | T3.3 |
| "One PView portal traversal, `OutsideView`" | T4.1T4.5 (PortalVisibilityBuilder already exists; Stage 4 wires the draw consequences) |
| "`MaxReprocessPerCell` → `update_count` watermark" | Already done in `PortalVisibilityBuilder.cs:7484` (`seen` HashSet = enqueue-once, equivalent to `cell_view_done`). No code change. |
| "Draw landscape through exit portals; no blue hole" | T4.1 (sky inside clip), T4.2 (terrain viewpoint), T4.3 (Z-clear) |
| "Cap ceilings" | T4.4 (verification only — already capped by construction) |
| "`EnvCellRenderer` GL_BLEND fix" | T4.5 (already shipped in U.4; verify + extend) |
| "Entity/particle clip to PView visible set" | T5.1, T5.2 |
| "Dungeon: no terrain/sky" | T3.2 (`seen_outside=false → renderSky=false`), T4.6 test |
### Placeholder scan
All "Confirm in Step 1" items are explicitly labeled and have a concrete investigative
action. They are marked as genuinely-uncertain integration points because the exact
behavior depends on current shader and method bodies that would require reading additional
files. They are NOT implementation-blocking — each has a fallback or a "no-op if already
correct" path.
### Type consistency
- `physicsRoot` is `LoadedCell?` (render type) — correct; `CellGraph.CurrCell` is
`ObjCell?` (Core type), and the conversion at `GameWindow.cs:71637165` (TryGetCell)
bridges them. No new type conversions introduced.
- `SeenOutside` on `LoadedCell` is `bool` (line 104 of `CellVisibility.cs`) — used as
`physicsRoot?.SeenOutside ?? true` which is null-safe with the correct outdoor default.
- `OutsideView.Polygons.Count` returns `int` (a `List<ViewPolygon>.Count`) — used in
both `int > 0` checks and `ReadOnlySpan<Vector4>` plane extraction. No type mismatch.
### Stage 4 scope note
Stage 4 is large but all the underlying machinery (BFS, clip planes, terrain UBO,
`ClipFrameAssembler`) is already in production. The stage is 5 concrete tasks (T4.1T4.5)
plus tests (T4.6). T4.1 (move sky inside clip bracket) is the most impactful; T4.3 (Z-
clear) is the "no blue hole" fix; T4.4/T4.5 are verification. Estimated total: 23 hours
for an implementer who has read this plan and the prerequisite files.
### Issue #102 note
Issue #102 (portal-graph BFS termination — too many reprocesses) is already closed by the
`seen` HashSet in `PortalVisibilityBuilder.cs:84` (enqueue-once guarantee). No additional
work needed for this plan. If the dungeon BFS still blows up in practice (Stage 4 visual
gate), investigate whether `seen.Add(neighbourId)` is being bypassed; it should not be.