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>
42 KiB
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 onecell_draw_list+OutsideViewthat 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:
docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md§2 (target architecture), §5 (risks), §6 (acceptance).- This plan, from top to bottom.
- Current render loop:
GameWindow.cslines 7139–7513 — the visibility, terrain, entity, and particle draws. Confirm line numbers before editing (they shift with every commit). src/AcDream.App/Rendering/CellVisibility.cs—FindCameraCell(line 389), grace counter (line 214),ComputeVisibilityFromRoot(line 356),GetVisibleCellsFromRoot(line 539).src/AcDream.App/Rendering/PortalVisibilityBuilder.cs— BFS +OutsideViewhandling (lines 1–239).
File structure (created / modified)
| File | Action | Responsibility |
|---|---|---|
src/AcDream.App/Rendering/GameWindow.cs |
Modify (lines ~7139–7513) | 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 ~389–446) | 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 3–5 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 |
8–19 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 readonlyorstaticinitializers inPhysicsResolveCapture.csandPhysicsDiagnostics.csthat read env vars at class-init time (before any test sets them). The symptom is that a test class that setsACDREAM_CAPTURE_RESOLVEorACDREAM_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(...).
- Files:
-
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 aResetForTest()static method that re-reads all flags, call it inIDisposable.Disposeof 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 Debugtwice 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 iffseen_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:7162–7166—physicsRootderivation fromCellGraph.CurrCell; already exists but only used as a FALLBACK toComputeVisibility.GameWindow.cs:7166—ComputeVisibilityFromRootcall (already the main path via W2a).GameWindow.cs:7185–7187—playerInsideCellcomputation (callsIsInsideAnyCell, which is an independent AABB scan — this is a separate issue fromFindCameraCellbut related).GameWindow.cs:7267—bool renderSky = !cameraInsideCell— the current sky gate, keyed offvisibility?.CameraCell is not null.GameWindow.cs:7406—if (terrainClipMode == TerrainClipMode.Skip)— the current terrain gate (no draw whenSkip).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):
public VisibilityResult? ComputeVisibilityFromRoot(LoadedCell? root, Vector3 fallbackPos)
{
if (root is null)
return ComputeVisibility(fallbackPos); // <-- this calls FindCameraCell(fallbackPos)
...
}
-
Read
CellVisibility.cs:356–370andComputeVisibility(line ~521-527) to confirm the fallback chain:ComputeVisibilityFromRoot(null, pos)callsComputeVisibility(pos)which callsFindCameraCell(pos)with the AABB + grace-frame logic. -
Change
ComputeVisibilityFromRootso a nullrootreturnsnullinstead of callingComputeVisibility. The caller atGameWindow.cs:7166already 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 fromFindCameraCelltoCurrCell-based root; it is now dead because W2a ensuresCurrCellis set from the first tick.// 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 == nullinGameWindow.cs:7162–7166: whenCellGraph.CurrCellis null (pre-spawn),physicsRootis null, andComputeVisibilityFromRoot(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
FindCameraCellmethod (CellVisibility.cs:389–446) and the grace-frame counter (_cellSwitchGraceFramesfield,_lastCameraCellfield). IfComputeVisibility(the non-root variant, line 521) has other callers, check via Grep first — if it is ONLY called fromComputeVisibilityFromRoot, delete it too; otherwise stub it to callGetVisibleCellsFromRoot(null, …)and note the debt.Confirm in Step 1: search for all callers of
FindCameraCellandComputeVisibilityin the App project before deleting. Expected: zero callers outsideCellVisibility.csitself and the oneGameWindow.cschain. 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:
- It gates sky/terrain on "camera is inside ANY cell" — not on
seen_outside. A dungeon cell hasSeenOutside = falsebutcameraInsideCell = true, and the current code suppresses sky (correct for dungeon) but does NOT actively gate terrain for a dungeon that has accidentally non-emptyOutsideView(edge case). - Sky and terrain should be gated SEPARATELY:
seen_outsidecontrols whether terrain is available at all; theOutsideViewfrom the BFS controls whether it draws THIS frame.
-
Extract
seenOutsidefrom the PVS root cell. After theComputeVisibilityFromRootcall atGameWindow.cs:7166, add:// Retail CellManager::ChangePosition:94649 — keep landscape iff seen_outside. bool rootSeenOutside = physicsRoot?.SeenOutside ?? true; // outdoor root (null) → always seen_outside(
LoadedCell.SeenOutsideis already populated atGameWindow.cs:5718fromenvCell.Flags.HasFlag(EnvCellFlags.SeenOutside).) -
Replace the
playerInsideCellAABB scan withseenOutside-derived logic. Currently (GameWindow.cs:7185–7187):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 withseen_outsidekeep 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 thatUpdateSunFromSky(called atGameWindow.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:// 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 forseen_outsidecells. Until Stage 4 is landed, sky will draw full-screen in building interiors (wrong but expected interim regression). Stage 4'sOutsideViewclipping 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 userenderSkyinstead, 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:7153–7165 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)toCellGraph.cs(new method, ~10 lines):/// 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 afterGetVisible). -
Wire it in
GameWindow.cs: After W2a lands, the only use ofFindCameraCellwas for thephysicsRootFALLBACK. That fallback is now deleted (T3.1). TheFindVisibleChildCellis used for the VISUAL projection override when the chase camera is outside the player cell — for example, to driveenvCellViewProjfrom the correct cell for portal-side tests inPortalVisibilityBuilder.Build. CurrentlyvisRootPos(the player pos) is passed as the side-test anchor (already correct per U.4c). This task is low-risk: the projection is driven fromcamera.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 callsFindCameraCellfor the camera projection. If there is none, this T3.3 may reduce to theFindVisibleChildCellmethod 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_ReturnsFalseSeenOutsideWhenCurrCell == null(pre-spawn),seenOutside = true(outdoor default),renderSky = true,playerInsideCell = false. -
Test:
RootSelection_BuildingInterior_SeenOutside_SkyRenderedCurrCell = EnvCell with SeenOutside=true→rootSeenOutside = true→renderSky = true,playerInsideCell = false. -
Test:
RootSelection_Dungeon_NoSeenOutside_SkyNotRenderedCurrCell = EnvCell with SeenOutside=false→rootSeenOutside = false→renderSky = false,playerInsideCell = true. -
Test:
FindVisibleChildCell_PlayerCellContains_ReturnsPlayerCellPlayer in cell A; query point inside A → returns A. -
Test:
FindVisibleChildCell_StabListContains_ReturnsNeighbourPlayer in cell A with StabList=[B]; query point outside A but inside B → returns B. -
Test:
FindVisibleChildCell_NeitherContains_ReturnsNullQuery 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.1–T3.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↔0031is gone (already fixed by Stages 1–2; 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 toPortalVisibilityBuilder.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; callsConstructViewthenDrawCells.PView::ClipPortals @ 0x005a5520 (pseudo_c:433662)— exit portal →outside_view.view_count += 1, clip region registered.SmartBox::RenderNormalMode @ 0x00453aa0 (pseudo_c:92635,92667)— ifseen_outside, callLScape::update_viewpoint(get_outside_cell_id(&viewer))BEFOREDrawInside.
Current state:
PortalVisibilityBuilder.Buildalready producesframe.OutsideViewwith exit-portal clip polygons (PortalVisibilityBuilder.cs:163–175).ClipFrameAssembler.Assemblealready extractsOutsideViewplanes and callsclipFrame.SetTerrainClip(outsidePlanes)when planes exist (the binding=2 terrain UBO).GameWindow.cs:7406–7431already has theTerrainClipMode.Skip/Scissor/Planesterrain gate — terrain is SKIPPED when the player is indoor andOutsideViewis empty.- GAP: When
OutsideViewis non-empty (exit portal visible from inside), terrain and sky should draw (clipped to the doorway). Currently sky is still suppressed byrenderSky = !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'srenderSky = 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:7268–7275, 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:7255–7292(sky draw block) and7377–7390(clip bracket open). Confirm current order: sky →IsLiveModeWaitingForLogingoto → clip bracket opens → terrain → cells → entities. -
Move
_skyRenderer?.RenderSky(...)and theSkyPreSceneparticle 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_ClipDistancewrites insky.vert— if the sky shader does NOT writegl_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 atGameWindow.cs:~1097). However, for the sky to be clipped TO the doorway we NEED the sky shader to writegl_ClipDistance. Checksrc/AcDream.App/Rendering/Shaders/sky.vert— if it does not write clip distances, add them mirroringmesh_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 fromclipAssembly.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.Drawuse the camera'sview * projectionmatrix from thecameraargument, 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 toPosition.get_outside_cell_id(&viewer)equivalent before the draw. Most likely acdream does NOT have this divergence (the terrain usescamera.View * camera.Projectionjust like everything else), so this task may be a no-op. Confirm and either add a// verified: no viewpoint override neededcomment 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:
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:
This requires// 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); }pvFrameto be in scope. CurrentlypvFrameis declared inside theif (clipRoot is not null)block (GameWindow.cs:7315). Either hoist it or useclipAssembly.HasOutsideView(a property to add toClipFrameAssemblyif needed). Confirm in Step 1: check whetherClipFrameAssemblyalready exposesOutsideView.Polygons.Countor 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.RegisterCellstores the full cell mesh including ceiling polygons (not just floor + walls). The dat baking happens inGameWindow.cs:5456–5502(theBuildLoadedCell/CellMesh.Buildcall). If ceiling polygons are included inCellMesh.Build, the ceiling is capped. If they are explicitly excluded, add them. -
Add a comment in
EnvCellRenderer.Renderdocumenting that ceiling is present by construction (retailDrawCells:432745,cell->structure->drawing_bspdraws 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:1004–1023 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 ofRender(…), so it is robust against any preceding state. Confirm no new state is needed (e.g.DepthFunc,CullFace— these are set atGameWindow.cs:7030before the clip bracket). -
Add a
_gl.DepthMask(true)reset at the END ofEnvCellRenderer.Renderif the transparent pass leavesDepthMask(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 inEnvCellRenderer.cs:1025–1239). 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_WhenExitPortalVisibleConstruct aLoadedCellwith one portal whoseOtherCellId = 0xFFFF(exit portal) and a non-degenerate polygon facing the camera. CallPortalVisibilityBuilder.Buildfrom a camera position on the interior side. Assertframe.OutsideView.Polygons.Count > 0. -
Test:
OutsideView_Empty_WhenNoExitPortalA cell with portals connecting to other interior cells only (OtherCellId != 0xFFFF). Assertframe.OutsideView.Polygons.Count == 0. -
Test:
VisibleSet_ContainsRootCell_AlwaysAny cell graph → root cell is always inframe.OrderedVisibleCellsand is first. -
Test:
VisibleSet_MultiCell_OrderedClosestFirstRoot cell with portal to neighbour farther away → root appears at index 0. -
Test:
BFS_Terminates_OnCyclicPortalGraphRoot A→B, B→A (cycle). BFS must terminate with exactly 2 cells inOrderedVisibleCells(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.1–T4.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:432868–432882— iteratescell_draw_listand for each callsDrawObjCellForDummies(cell). Objects in a non-visible cell are never iterated.CObjCell::object_list— objects live in their cell's object list (fromenter_cell/leave_cell). Entity→cell membership comes from the physics shadow lists.
Current state: WbDrawDispatcher.Draw at GameWindow.cs:7473–7476 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.Drawsignature and itsvisibleCellIdsparameter usage. Findsrc/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs, search forvisibleCellIds. Confirm: whenvisibleCellIds != null, entities whoseParentCellIdis NOT in the set are culled. If not: add the cull inside the dispatcher's entity loop. -
Confirm
ParentCellIdis populated correctly for EnvCell-static objects. Static objects inside a cell (inn furniture, door) haveParentCellIdset to the cell's id. Verify atGameWindow.BuildInteriorEntitiesForStreamingor the matchingAddEntitiesToExistingLandblocksite thatParentCellIdis set. If null or zero for static objects, the cell-clip gate misses them. -
Use
OrderedVisibleCellsinstead ofVisibleCellIdsfor entity ordering.visibility.VisibleCellIdsis aHashSet<uint>(unordered). For strict retail-faithful entity draw ordering, the dispatcher should iterate inframe.OrderedVisibleCellsorder (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:7494–7496 does NOT take
a visibleCellIds filter.
-
Check if
ParticleRenderer.Drawhas a cell-filter parameter. If yes, passvisibility?.VisibleCellIds. If no, add it — particles in an invisible cell (NPC smoke from behind a sealed wall) should not draw. -
Gate
SkyPreSceneandSkyPostSceneparticle passes byrenderSky(already gated atGameWindow.cs:7506for weather; confirmSkyPreSceneat line 7272 is also gated byrenderSky). 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_IsIncludedEntity withParentCellId = 0xA9B40170(the cottage cell);visibleCellIds = {0xA9B40170}→ entity included. -
Test:
EntityClip_ParentNotInVisibleSet_IsExcludedEntity withParentCellId = 0xA9B40172(sealed room not in PVS);visibleCellIds = {0xA9B40170}→ entity excluded. -
Test:
EntityClip_NullVisibleSet_IncludesAllvisibleCellIds = 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
visibleCellIdsis 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 1–2, 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.Addgate).
dotnet buildgreen.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 1–2 (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.1–T4.5 (PortalVisibilityBuilder already exists; Stage 4 wires the draw consequences) |
"MaxReprocessPerCell → update_count watermark" |
Already done in PortalVisibilityBuilder.cs:74–84 (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
physicsRootisLoadedCell?(render type) — correct;CellGraph.CurrCellisObjCell?(Core type), and the conversion atGameWindow.cs:7163–7165(TryGetCell) bridges them. No new type conversions introduced.SeenOutsideonLoadedCellisbool(line 104 ofCellVisibility.cs) — used asphysicsRoot?.SeenOutside ?? truewhich is null-safe with the correct outdoor default.OutsideView.Polygons.Countreturnsint(aList<ViewPolygon>.Count) — used in bothint > 0checks andReadOnlySpan<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.1–T4.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: 2–3 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.