acdream/docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md
Erik 21bf97ed35 docs(render): REOPEN the render half — full retail-faithful redesign dossier (handoff + huge plan + 3 research docs)
The Phase W indoor seal did NOT land. The 2026-06-02 visual gate proved the interior render is fundamentally broken (#78: transparent walls, outdoor terrain + scenery entities bleeding in, grey floors, no outside-looking-in). Stage 4 (sky-through-door clip) was real but a top layer on a base that never sealed.

DECISIVE EVIDENCE (committed in the handoff): the PVS computes correctly AND the cell shells render correctly (opaque, textured, complete — the [shell] probe shows zero NOSNAP / zero missing-texture). The failure is the SEAL + three inconsistent gates — concretely the WbDrawDispatcher.cs:1756 ParentCellId==null -> return true bypass draws outdoor scenery indoors, and the indoor path draws the outdoor world then gates it instead of running ONLY DrawInside. Retail, when inside, runs ONE PView flood: visibility IS the cull; the landscape enters only through clipped exit portals + a conditional depth-only clear.

Dossier (per the user's mandate: NO shortcuts/bandaids, port from retail, redesign the whole pipeline if needed, brainstorm first):
- Master handoff (root cause + retail target + reusable-vs-redesign + apparatus + do-not-repeat + copy-paste pickup prompt).
- Huge staged redesign plan R0(brainstorm)->R1(one visibility authority, kill the bleed)->R2(indoor=DrawInside-only)->R3(the seal, DrawCells port)->R4(per-cell object/particle clip)->R5(outside-looking-in)->R6(dungeons)->R7(polish/conformance). Each ends at a user visual gate.
- 3 research docs: full retail render pipeline reference (705 lines, decomp-verified), acdream pipeline inventory + failure map, reference cross-check (WB two-pipe is the wrong model).

#78 promoted to the redesign. The 5 remaining Core test failures are pre-existing physics/collision bugs, none render-related.

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

35 KiB
Raw Blame History

acdream Render Pipeline — Inventory and Failure Map (2026-06-02)

Produced as a full redesign handoff after the Phase W session (Stage 3 + Stage 4 shipped, membership fix landed). Incorporates live probe evidence from the Holtburg cottage run captured the same session.


1. Per-frame draw order

All code lives in GameWindow.OnRender. Line numbers are from src/AcDream.App/Rendering/GameWindow.cs.

~7140  [visibility root]
        physicsRoot  = DataCache.CellGraph.CurrCell → TryGetCell → physicsRoot
        visibility   = _cellVisibility.ComputeVisibilityFromRoot(physicsRoot, visRootPos=PLAYER)
          → VisibilityResult { CameraCell, VisibleCellIds (old BFS set), HasExitPortalVisible }
        cameraInsideCell = visibility?.CameraCell != null
        rootSeenOutside  = physicsRoot?.SeenOutside ?? true

~7167  [compute render booleans]
        renderSky        = !cameraInsideCell || rootSeenOutside
        playerInsideCell = cameraInsideCell && !rootSeenOutside  (sealed dungeon)

~7250  [PrepareRenderBatches] — EnvCellRenderer
        _envCellRenderer.PrepareRenderBatches(envCellViewProj, camPos, filter:null, …)
        (CPU snapshot; builds per-cell batches. Always called. Cheap.)

~7290  [ClipFrame / PortalVisibilityBuilder]   ← THE AUTHORITATIVE VISIBILITY FRAME
        if clipRoot (indoor root):
            pvFrame  = PortalVisibilityBuilder.Build(clipRoot, visRootPos=PLAYER, envCellViewProj=EYE)
              → PortalVisibilityFrame { OutsideView, CellViews, OrderedVisibleCells }
            clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame)
              → TerrainClipMode { Skip | Scissor | Planes }
              → CellIdToSlot (per-visible-cell clip region)
              → OutdoorVisible / OutdoorSlot
              → HasOutsideView / OutsideViewNdcAabb
            _wbDrawDispatcher.SetClipRouting(CellIdToSlot, OutdoorSlot, OutdoorVisible)
            _envCellRenderer.SetClipRouting(CellIdToSlot)
            envCellShellFilter = HashSet<uint>(CellIdToSlot.Keys)
        else (outdoor root):
            _clipFrame.Reset() — no-clip, slot 0 for everything
            _wbDrawDispatcher.ClearClipRouting()

~7373  _clipFrame.UploadShared(_gl)  ← pushes TerrainUBO + RegionSsbo to GPU
       _wbDrawDispatcher.SetClipRegionSsbo(...)
       _envCellRenderer.SetClipRegionSsbo(...)
       _terrain.SetClipUbo(...)

~7378  [Stage 4] sky pre-scene (LScape — drawn FIRST through the doorway)
        drawSkyThisFrame = renderSky && (clipAssembly==null || clipAssembly.HasOutsideView)
        skyDoorwayClip   = clipAssembly!=null && clipAssembly.HasOutsideView
        if drawSkyThisFrame:
            BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb)   ← clips to portal opening
            re-bind TerrainClipUBO at binding=2
            Enable ClipDistance0..7
            _skyRenderer.RenderSky(...)          ← sky mesh (gl_ClipDistance gated)
            Disable ClipDistance0..7
            _particleRenderer.Draw(SkyPreScene)  ← particles ONLY inside scissor; NO cell gate
            EndScissor

~7431  [pre-login guard] if IsLiveModeWaitingForLogin → goto SkipWorldGeometry

~7444  Enable ClipDistance0..7  ← world-geometry bracket opens

~7464  [terrain]
        if terrainClipMode == Skip    → nothing drawn
        elif terrainClipMode == Scissor → glScissor to OutsideView AABB → _terrain.Draw → disable
        else (Planes)                  → _terrain.Draw (UBO gated per-vertex)

~7523  [Stage 4] conditional doorway Z-clear
        if skyDoorwayClip:
            glScissor to OutsideViewNdcAabb
            glClear(DepthBufferBit)   ← depth only; color preserved
            EndScissor

~7538  [cell shells — opaque pass]
        if clipAssembly!=null && envCellShellFilter!=null:
            _envCellRenderer.Render(Opaque, envCellShellFilter)
              (per-cell geometry from PrepareRenderBatches; clip-gated via binding=3 slot map)

~7546  [entities]
        _wbDrawDispatcher.Draw(camera, LandblockEntries, frustum,
            visibleCellIds: visibility?.VisibleCellIds,   ← OLD BFS set, NOT pvFrame
            animatedEntityIds: animatedIds)
        Per-entity slot routing (SetClipRouting active = indoor root):
            serverGuid != 0 (live-dynamic) → slot 0 (unclipped)
            ParentCellId in CellIdToSlot   → cell's clip slot
            ParentCellId null              → OutdoorSlot or CULL (OutdoorVisible)
            ParentCellId not in CellIdToSlot → CULL
        IsShellScopedSet always returns false (U.1 deleted shell sets); so outdoor
        scenery (ParentCellId==null) routes to OutdoorSlot/CULL — BUT clip routing
        only fires for indoor root (SetClipRouting active). Outdoor root: ALL slot 0,
        nothing culled → outdoor scenery ALWAYS draws when outdoors.

~7553  [cell shells — transparent pass]
        if clipAssembly!=null && envCellShellFilter!=null:
            _envCellRenderer.Render(Transparent, envCellShellFilter)

~7561  Disable ClipDistance0..7  ← world-geometry bracket closes

~7568  [Scene particles]
        _particleRenderer.Draw(Scene)
        NO cell filter; no clip planes. All particles draw regardless of camera location.

~7583  [Stage 4] sky post-scene (weather / rain)
        if drawSkyThisFrame:
            BeginDoorwayScissor(skyDoorwayClip, OutsideViewNdcAabb)
            re-bind TerrainClipUBO
            Enable ClipDistance0..7
            _skyRenderer.RenderWeather(...)      ← rain cylinder clipped to doorway
            Disable ClipDistance0..7
            _particleRenderer.Draw(SkyPostScene) ← NO cell gate; scissor only
            EndScissor

~7739  SkipWorldGeometry: (pre-login goto target)

2. The gate table — the headline inconsistency

What is gated Gate source Computed by Consistency verdict
Terrain terrainClipMode + UBO planes ClipFrameAssemblerPortalVisibilityBuilder (pvFrame OutsideView) CORRECT — skips when no exit portal visible. Planes-mode clips to portal opening per-vertex. Scissor-mode over-includes.
Cell shells envCellShellFilter (CellIdToSlot.Keys) + per-instance clip slot (binding=3) Same pvFrame / ClipFrameAssembler CORRECT — only draws cells whose clip region is non-empty.
Indoor static entities (ParentCellId set) CellIdToSlot lookup via ResolveSlotForFrame Same pvFrame / ClipFrameAssembler CORRECT for indoor statics. CULL when cell not visible.
Outdoor scenery / building shells (ParentCellId == null) OutdoorVisible → OutdoorSlot or CULL Same pvFrame / ClipFrameAssembler GATED when _clipRoutingActive (indoor root only). Clips to OutsideView slot. BUT: IsShellScopedSet always returns false (shell sets deleted in U.1), so the IsBuildingShell anchor-cell branch in EntityPassesVisibleCellGate is dead code — the live path at line 1756 returns true unconditionally for null-ParentCellId when NOT clip-routing. See §3.
Live-dynamic entities (serverGuid != 0) None — slot 0, always unclipped N/A INTENTIONAL — matches retail.
Particles (Scene / SkyPost / SkyPre) Sky pre/post: doorway scissor only. Scene: NONE N/A BROKEN: no cell gate. Issue #104 (deferred).
Sky mesh drawSkyThisFrame (seen_outside + HasOutsideView) + gl_ClipDistance against TerrainClipUBO Same pvFrame CORRECT — gated to portal opening or full-screen outdoors.

The core inconsistency (three gates, not one)

GATE #1 — Terrain:
    Computed from pvFrame.OutsideView (PortalVisibilityBuilder) → TerrainClipMode
    Skip ⇒ 0 terrain. Planes ⇒ clipped to portal. Scissor ⇒ AABB over-include.
    SOURCE: ClipFrameAssembler (~GameWindow.cs:7322)

GATE #2 — Cell shells:
    Computed from pvFrame.CellIdToSlot (same PortalVisibilityBuilder) → envCellShellFilter
    Only draws cells whose clip region is non-empty (correct, but incomplete seal).
    SOURCE: ClipFrameAssembler (~GameWindow.cs:7333)

GATE #3 — Entities (WbDrawDispatcher):
    Indoor statics: ParentCellId ∈ CellIdToSlot → correct.
    Outdoor stabs (ParentCellId == null):
        When _clipRoutingActive (indoor root): gated to OutdoorSlot or culled — looks correct.
        BUT: visibleCellIds passed to EntityPassesVisibleCellGate is from
             CellVisibility.ComputeVisibilityFromRoot → VisibilityResult.VisibleCellIds,
             which is the OLD BFS (GetVisibleCellsFromRoot). This is a SEPARATE computation
             from pvFrame. The two can agree, but they are NOT the same object.
    SOURCE: _cellVisibility.ComputeVisibilityFromRoot (~GameWindow.cs:7166)
            NOT from PortalVisibilityBuilder.

The duplication: CellVisibility.ComputeVisibilityFromRoot (old BFS, produces VisibilityResult.VisibleCellIds) and PortalVisibilityBuilder.Build (new pvFrame BFS) are TWO SEPARATE portal traversals per frame. The entity dispatcher consumes the old one via visibleCellIds; terrain + shells consume the new one via pvFrame. When they disagree, entities draw in cells whose shells are culled, or vice versa.

The structural gap (issue #78 root cause): outdoor scenery (ParentCellId == null: houses, trees, landblock-baked stabs) bypasses the entity gate entirely when NOT clip-routing (outdoor root). When clip-routing is active (indoor root), they route to OutdoorSlot/cull — BUT the IsShellScopedSet branch that would use BuildingShellAnchorCellId for building-shell culling is dead code (always returns false, line 1761). So building shells registered with IsBuildingShell=true but no BuildingShellAnchorCellId still pass. The result: some outdoor geometry leaks through the portal restriction.

Retail's design: one PView traversal (PView::ConstructViewClipPortalsAddViewToPortals, retail decomp :433750). Every renderer consumes the SAME regions. There is no separate entity-visibility BFS.


3. Per-renderer notes

3.1 EnvCellRenderer (src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs)

  • What it does: draws indoor cell geometry (walls/floor/ceiling of EnvCells). WB port (WB EnvCellRenderManager.cs). Per-cell batches prepared by PrepareRenderBatches (~line 530), drawn in two passes (Opaque / Transparent) via Render(pass, filter) (~line 790).
  • Clip mechanism: per-instance clip-slot buffer (binding=3, _clipSlotBuffer). Each instance stores its CellIdToSlot slot index. The shared clip-region SSBO (binding=2, ClipFrame.RegionSsbo) is handed in via SetClipRegionSsbo.
  • Diagnostics:
    • ACDREAM_PROBE_SHELL=1[shell] line each opaque pass (~line 943). Reports per-cell: gfx=N (gfxObj count), tf=N (transform count), batch=N, idx=N (index count / 3 = tris), tr=N (translucent batches), zh=N (zero-handle = missing bindless texture). Diagnosis tree: NOSNAP = cell not in snapshot (no batches prepared); zh>0 = geometry present but texture handle zero (invisible); idx>0 + zh=0 + tr=0 = opaque geometry drawn, fault is depth/occlusion not the shell itself.
    • Session evidence: all visited cells (01700175) showed idx>0 + zh=0 + tr=0 + tr=0. Shells ARE drawn, textured, opaque. Max filter=3 cells rendered at once (correct for a 3-cell visibility set). ZERO NOSNAP.
  • Known issue (U.4 fix): GL state (Blend, DepthMask, uViewProjection, CullMode cache) must be set self-contained at entry to each Render() call. Bugs were hit 3× in Phase U.4 when state bled from prior renderers. Fixed by explicit state setup at ~line 810 + 1010. CullMode.Landblock → CullMode.None override at line 1216 renders cell polys double-sided as a stopgap (architectural cause not yet resolved).

3.2 TerrainModernRenderer (src/AcDream.App/Rendering/TerrainModernRenderer.cs)

  • What it does: draws all loaded landblock terrain via glMultiDrawElementsIndirect. Single global VBO/EBO, one slot per landblock (~line 26 onwards).
  • Clip mechanism: TerrainClipUBO (binding=2, ClipFrame.TerrainUbo) handed in via SetClipUbo. When the UBO count > 0 (Planes mode), terrain_modern.vert clips per-vertex via gl_ClipDistance. When count==0 (outdoor root / Scissor mode), ungated.
  • Gate in GameWindow: terrainClipMode (~line 7464):
    • Skip → no draw at all (correct for sealed cellar: OutsideView is empty).
    • ScissorglScissor to TerrainScissorNdcAabb; UBO count 0 (no plane gating). This mode OVER-INCLUDES (everything in the scissor box draws, not just through the portal).
    • Planes → draws normally; UBO planes gate per-vertex.
  • Known gap: Scissor-mode over-inclusion. When the OutsideView polygon exceeds the convex-plane budget, terrain is scissored to its NDC AABB rather than clipped to the actual portal shape. An unusually large or non-convex doorway can let terrain bleed.

3.3 WbDrawDispatcher (src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)

  • What it does: draws all world entities (static scenery, NPCs, doors, items) via glMultiDrawElementsIndirect. Per-instance clip slots (binding=3) + shared clip SSBO (binding=2).
  • Entity gate: EntityPassesVisibleCellGate (static, line 1739):
    • visibleCellIds == null → pass (outdoor root: nothing culled).
    • ParentCellId set → pass iff cell ∈ visibleCellIds.
    • ParentCellId == null && IsBuildingShell && IsShellScopedSet → anchor-cell check. BUT IsShellScopedSet always returns false (line 1761, U.1 deletion). So this branch is dead.
    • ParentCellId == null && !(the above)unconditionally returns true (line 1756). This is the outdoor-scenery bypass: houses, trees, landblock-baked stabs all pass when visibleCellIds is non-null but they have no ParentCellId.
  • Clip-slot routing (when _clipRoutingActive, i.e. indoor root):
    • serverGuid != 0 → slot 0 (unclipped — retail behavior).
    • ParentCellId in CellIdToSlot → cell's clip slot.
    • ParentCellId == null && OutdoorVisible → OutdoorSlot (gated to OutsideView).
    • ParentCellId == null && !OutdoorVisible → CULL.
    • ParentCellId not in CellIdToSlot → CULL.
    • When NOT _clipRoutingActive (outdoor root): all slot 0, no culling.
  • The gap: visibleCellIds passed by GameWindow is visibility?.VisibleCellIds (old CellVisibility BFS), not pvFrame.OrderedVisibleCells (new PortalVisibilityBuilder BFS). These are parallel traversals of the same graph; in practice they should agree for the simple cottage graph, but they can diverge for complex portal topologies.

3.4 CellVisibility (src/AcDream.App/Rendering/CellVisibility.cs)

  • What it does: OLD portal visibility system. ComputeVisibilityFromRoot(root, pos) does a simple BFS through LoadedCell.Portals, collecting the full reachable set as VisibleResult.VisibleCellIds (HashSet). Sets CameraCell when inside a cell. Also produces HasExitPortalVisible (a portal with OtherCellId == 0xFFFF was reached).
  • Used for: visibility?.VisibleCellIds → entity gate in WbDrawDispatcher.Draw. Also for cameraInsideCell, physicsRoot lookup.
  • NOT used for: terrain gating, shell filter, clip-slot assignment. Those come from PortalVisibilityBuilder.
  • Note: FindCameraCell AABB grace-frame fallback was DELETED in Stage 3 (2026-06-02). ComputeVisibilityFromRoot(null, …) returns null (outdoor root) — no AABB scan fallback.

3.5 PortalVisibilityBuilder (src/AcDream.App/Rendering/PortalVisibilityBuilder.cs)

  • What it does: the NEW portal-clip visibility BFS (port of retail PView::ConstructView :433750). Produces PortalVisibilityFrame: per-cell CellView (screen-space NDC clip region), OutsideView (union of all exit-portal regions), OrderedVisibleCells (closest-first list).
  • Used for: terrain clip mode, shell filter, entity clip slots (via ClipFrameAssembler).
  • KEEP — this is the correct retail-faithful PView kernel. Output is correct (confirmed by [vis] probe: cellar 0174 shows outPolys=0 / terrain=Skip; room 0171 shows outPolys=1 / terrain=Planes).

3.6 ClipFrame / ClipFrameAssembler / ClipPlaneSet

  • What it does: CPU → GPU bridge. ClipFrameAssembler.Assemble translates the PortalVisibilityFrame into a ClipFrameAssembly: per-cell plane arrays packed into a SSBO (RegionSsbo), a UBO for terrain (TerrainUbo), CellIdToSlot map, TerrainClipMode.
  • KEEP — pure CPU logic, GL-free, fully unit-tested. The slot/gate policy is correct.

3.7 ParticleRenderer (src/AcDream.App/Rendering/ParticleRenderer.cs)

  • What it does: draws particle emitters for all three passes (SkyPreScene, Scene, SkyPostScene). particle.vert has NO gl_ClipDistance support.
  • Gating: SkyPreScene and SkyPostScene are bounded by the doorway scissor (BeginDoorwayScissor) when skyDoorwayClip is set. Scene pass: no gate at all — draws every particle regardless of camera location.
  • Known gap: issue #104 (deferred). Inside a sealed cellar, Scene particles from the outdoor network-portal (e.g. Holtburg Town portal effect) are still drawn, visible through walls.

3.8 SkyRenderer (src/AcDream.App/Rendering/Sky/SkyRenderer.cs)

  • What it does: sky mesh (celestial objects) + weather (rain cylinder). Uses sky.vert which writes gl_ClipDistance from the same binding=2 TerrainClipUBO.
  • Gate: drawSkyThisFrame = renderSky && (clipAssembly==null || HasOutsideView). Indoor with no exit portal → no sky (seen_outside=false for dungeon; HasOutsideView=false for a room whose portal is around a corner). Indoor with visible portal → sky/weather clipped to doorway via gl_ClipDistance (Planes mode) or glScissor (Scissor mode).
  • KEEP — Stage 4 correctly implements the retail LScape draw split.

4. Failure map — 5 user symptoms → root cause → gate

Probe context: [vis] probe confirms PVS is correct. [shell] probe confirms shells ARE drawn (idx>0, zh=0, tr=0). Player moves 0170→0171→0175→0174 correctly via [cell-transit].

Symptom 1: From outside through a door/window, interior walls are transparent

  • Root cause: outside-looking-in (U.5) — when the camera is outdoors and the player is outdoors, clipRoot == null → outdoor root → _clipRoutingActive = false → every entity slot 0, terrain ungated, shells not drawn (shell rendering is only active when clipAssembly != null). EnvCell shells are NEVER rendered from the outdoor root. The indoor cells are not in the WbDrawDispatcher entity walk because they have ParentCellId set to cells that may not be in visibleCellIds (which is null outdoors).
  • Gate: GATE #2 (shell rendering only active for indoor root). No outdoor → indoor visibility pipeline exists yet.
  • Status: planned Phase U.5 / deferred by the render-reset mandate.

Symptom 2: Inside you see only the outdoor world + NPCs/particles/doors (no interior)

  • Root cause (indoor root path): shells ARE drawn (probe confirmed). But outdoor terrain renders on top and clips through the shells because: (a) Terrain (Gate #1): in Planes mode, outdoor terrain is clipped to the portal-plane region, but since the cottage room has an exit portal, terrain draws THROUGH the doorway region. In Scissor mode it draws through the portal bounding box. The shells do not depth-occlude the terrain if terrain draws after the shells — but terrain draws BEFORE the shells (line 7464 vs 7538). So: terrain draws first, shells draw on top. This is correct draw order. The issue is that the terrain PLANE region covers the entire portal opening (the door/window), not just the area past the wall, so terrain pixels are written inside the room via the doorway. (b) Outdoor stabs (Gate #3, line 1756): ParentCellId==null entities bypass visibleCellIds, always pass the EntityPassesVisibleCellGate. When _clipRoutingActive, they route to OutdoorSlot (clipped to OutsideView) or are culled when !OutdoorVisible. For a cellar with seen_outside=true but sealed walls (OutsideView present), the stabs are clipped to the OutsideView AABB — but an AABB is a rectangle, not the actual doorway silhouette, so stabs visible in the same NDC box as the doorway still draw.
  • Gate: GATE #1 (terrain) leaks through the portal opening; GATE #3 (entity) allows outdoor stabs to render through any pixel in the OutsideView bounding box.

Symptom 3: Looking out from inside, you see houses/trees through the ground

  • Root cause: same as Symptom 2 from the other direction — outdoor stabs have ParentCellId==null → unconditionally pass EntityPassesVisibleCellGate (line 1756). When inside, clip routing is active and they route to OutdoorSlot. If OutdoorVisible is true (there IS an exit portal), they clip to the OutsideView AABB — but the AABB over-includes relative to the actual portal polygon. If OutdoorVisible is false (cellar with no direct exit portal in view) they should be culled — and the [vis] probe shows terrain=Skip for the sealed cellar, meaning this is correct for terrain. But stabs still leak through because clip-slot routing uses outdoorSlot (which points to the OutsideView region, computed per frame), not a true exclusion.
  • The deeper gap: even when OutdoorVisible=false (terrain Skip), the ClipSlotCull sentinel at line 399 (return outdoorVisible ? outdoorSlot : ClipSlotCull) should cull outdoor stabs. Let's verify this is actually reaching that code: ResolveSlotForFrame (line 414) gates on _clipRoutingActive. When active and OutdoorVisible=false, ResolveEntitySlot returns ClipSlotCullculled=true → entity dropped. This is CORRECT in theory. But the symptom persists, suggesting either: (a) _clipRoutingActive is not set on frames where the symptom shows (possible race between PrepareRenderBatches and Render), or (b) some outdoor stabs have serverGuid != 0 (live-dynamic) and thus take the slot-0 / always-unclipped branch.
  • Gate: GATE #3 (entity). The theory says it should work for dat-hydrated stabs when OutdoorVisible=false; if the symptom still appears from the cellar, (b) is likely.

Symptom 4: On the cellar stairs, walls show but floor is grey and entities show through walls above

  • Root cause (grey floor): terrain is rendering. For cell 0175 (stairs), seen_outside=true → terrain draws. The stairs cell has an exit portal chain, so OutsideView is non-empty, terrain=Planes. Terrain at the landblock Z level renders through the portal-plane region. The stair geometry is at a higher Z than the terrain; the terrain wins depth for some pixels → grey floor (terrain texture visible through the stair floor polygon).
  • Root cause (entities through walls above): live-dynamic entities (serverGuid != 0) take slot 0 (unclipped) unconditionally (line 391: if (serverGuid != 0) return 0). This matches retail intent, but means NPCs / doors visible through walls above the stairs are working as designed — they are depth-tested only, not portal-clipped.
  • Gate: terrain GATE #1 (Planes clip is correct in theory but terrain at ground level is visible through a downward-looking portal); entity unclipped slot for live-dynamics.

Symptom 5: In the cellar you see grey world instead of the floor

  • Root cause (confirmed by [vis] probe): cellar 0174 shows outPolys=0 / terrain=Skip. The cellar IS correctly sealed — terrain does NOT draw for the cellar frame (Skip mode). The grey is NOT outdoor terrain. It is the GL clear color (background) showing through because: (a) The cell shell DOES draw ([shell] probe: 0174 idx=42, 14 triangles, opaque, zh=0). But the FLOOR polygon of the cellar GfxObj may not be present in the dat's polygon list (the cellar has no floor polygon — only walls). The floor visual is a separate landblock terrain tile at the cellar depth, normally occluded by the terrain mesh. With terrain SKIPPED (correct!), nothing draws at that Z level — the clear color shows through. (b) Alternatively, the floor is present but the GfxObj has CullMode.Landblock remapped to CullMode.None (EnvCellRenderer line 1216). If the floor polygon's normal points up, it still draws. If the floor is missing from the dat, nothing closes the bottom.
  • This is a geometry/dat gap, not a gating bug — the cellar cell may simply not have a floor polygon in the EnvCell dat, expecting the terrain to serve as the floor (retail gets away with this because the terrain is never skipped in retail for a seen_outside=true cell with an exit portal reachable — retail draws terrain for every cell it visits in the PView walk, not just when an OutsideView exists). The Skip mode (correct for a SEALED dungeon with no exit portal) is too aggressive for a seen_outside=true cellar where the exit portal is simply behind you / not in the current frustum.
  • Gate: GATE #1 (terrain). Skip fires when OutsideView.IsNothingVisible — this is correct for a dungeon, but for a seen_outside=true building interior it fires whenever the exit portal is behind the camera, which removes the terrain floor.

5. REUSABLE vs REDESIGN inventory

KEEP (correct, retail-faithful, well-tested)

Component What to keep Why
PortalVisibilityBuilder Entire file Correct PView BFS port. Probe-confirmed: OutsideView polygons, ordered cells, exit-portal detection all correct.
ClipFrame / ClipPlaneSet Entire files GL-free, unit-tested. The slot/gate policy and SSBO layout are correct.
ClipFrameAssembler Entire file + tests Correct translation of pvFrame → GPU slots. TerrainClipMode.Skip = correct for sealed dungeon.
LoadedCell.SeenOutside Field + hydration Correct retail anchor (acclient.h seen_outside). Stage 3 uses it correctly for sky/sun gate.
Doorway Z-clear (Stage 4) GameWindow ~7523 Correct retail port of PView::DrawCells:432731.
Sky clip to OutsideView (Stage 4) SkyRenderer path Correct retail LScape split. gl_ClipDistance gating works.
CellVisibility.ComputeVisibilityFromRoot Root-selection logic only The physics-membership root selection is correct (Stage 3 fix). The BFS body can be retired once entity dispatch reads from pvFrame directly.
EnvCellRenderer Geometry/texture/MDI path [shell] probe confirms correct geometry, textures, depth, state. Keep the renderer; redesign the gating.
TerrainModernRenderer Renderer itself Works correctly. Just needs its caller to supply the correct clip mode.
WbDrawDispatcher slot-routing ResolveEntitySlot logic (lines 381400) The policy is retail-faithful for indoor statics + live-dynamics. The gap is the outdoor-stab bypass, fixable without redesigning the dispatcher.
All diagnostic probes ACDREAM_PROBE_SHELL, PROBE_VIS, PROBE_FLAP, PROBE_CELL, PROBE_SWEPT Essential apparatus. Keep until the pipeline redesign is visual-verified.

REDESIGN / FIX

Component Problem Fix direction
WbDrawDispatcher.EntityPassesVisibleCellGate line 1756 ParentCellId==null returns true unconditionally (outdoor stab bypass). When _clipRoutingActive AND !OutdoorVisible: return false. The ResolveSlotForFrame path already culls via ClipSlotCull for the clip-slot mechanism — verify that path is actually reached and the IsBuildingShell anchor branch fires for shells.
WbDrawDispatcher.Draw visibleCellIds parameter Sourced from CellVisibility old BFS, not from pvFrame. Two separate traversals per frame. Retire VisibilityResult.VisibleCellIds as the entity gate; use pvFrame.OrderedVisibleCells (same data, one traversal).
CellVisibility.GetVisibleCellsFromRoot BFS Duplicate of PortalVisibilityBuilder's BFS (minus clip regions). Called once per frame alongside pvFrame. Retain only CameraCell derivation (needed for clipRoot); discard the VisibleCellIds set.
TerrainClipMode.Skip trigger Fires when OutsideView.IsNothingVisible — correct for dungeon, but fires for a seen_outside=true building interior when the exit portal is behind the camera. Removes the terrain floor for the cellar case. Gate terrain-skip on !physicsRoot?.SeenOutside (i.e. Skip ONLY for dungeons where seen_outside=false). When seen_outside=true, always draw terrain at least at clip-mode Planes/Scissor.
ParticleRenderer.Draw(Scene) No cell filter; no clip planes. Particles draw everywhere. Add an indoor gate: when clipAssembly != null (indoor root), scissor Scene particles to OutsideView AABB. Full clip-slot support for particles is a larger change (particles have no instanceID).
EnvCellRenderer CullMode.Landblock → None override (line 1216) Architectural stopgap — renders cell polys double-sided. The real winding issue hasn't been resolved. Investigate whether AC's EnvCell geometry has consistent winding (retail uses backface cull). If winding is consistent, remove override and set CullMode per polygon's winding from the dat.
Outside-looking-in (U.5) No pipeline for outdoor-camera → indoor-cell visibility. Shell rendering is only active for indoor root. Phase U.5: add outdoor shell pass when CellVisibility detects a nearby building cell in the frustum.

6. The diagnostic apparatus

Probes: env vars and what they emit

Env var C# property Emission site Line format When to use
ACDREAM_PROBE_CELL=1 PhysicsDiagnostics.ProbeCellEnabled PlayerMovementController.cs:776 [cell-transit] old=0x... new=0x... pos=(x,y,z) reason=... Confirm cell membership changes. Low volume (fires only on transitions).
ACDREAM_PROBE_VIS=1 RenderingDiagnostics.ProbeVisibilityEnabled GameWindow.cs:7338 [vis] root=0x... cells=[...] outPolys=N outPlanes=N per-cell:{0x...:N,...} Confirm PVS output. Cell-change-throttled to stay readable.
ACDREAM_PROBE_FLAP=1 RenderingDiagnostics.ProbeFlapEnabled GameWindow.cs:7352, PortalVisibilityBuilder.cs:235 [flap-cam] root=0x... res=None eyeInRoot=Y/n eye=... terrain=... outVisible=... Check whether terrain/portals flap. High volume; use briefly.
ACDREAM_PROBE_SHELL=1 RenderingDiagnostics.ProbeShellEnabled EnvCellRenderer.cs:950 [shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:gfx=N tf=N batch=N idx=N tr=N zh=N] Confirm cell shells are drawn per frame. Opaque pass only.
ACDREAM_PROBE_SWEPT=1 PhysicsDiagnostics.ProbeSweptEnabled PhysicsEngine.cs:861 [swept-sphere] ... Physics swept-sphere diagnostics (not render).
ACDREAM_PROBE_PUSH_BACK=1 PhysicsDiagnostics.ProbePushBackEnabled BSPQuery.cs [push-back], [push-back-disp], [push-back-cell] A6 apparatus; heavy under motion.
ACDREAM_PROBE_FLAP=1 (builder) same PortalVisibilityBuilder.cs:235 [flap] camCell=0x... portals=N TRV/SKIP entries... Portal traversal trace per frame. Heavy.

Runtime-toggleable via DebugPanel (F11 → Diagnostics checkboxes) without relaunch for PROBE_CELL and PROBE_VIS.

Reading logs on Windows (launch.log is UTF-16 LE from PowerShell Tee-Object)

Do NOT use GNU grep on launch.log — it interprets UTF-16 as binary.

Correct approach (PowerShell):

# Filter [shell] lines
Select-String -Path launch.log -Pattern '^\[shell\]' | Select-Object -ExpandProperty Line

# Filter [vis] lines
Select-String -Path launch.log -Pattern '^\[vis\]' | Select-Object -ExpandProperty Line

# Filter [cell-transit] lines
Select-String -Path launch.log -Pattern '^\[cell-transit\]' | Select-Object -ExpandProperty Line

Correct approach (ripgrep / Grep tool) — works with UTF-16 LE when -E flag is set:

rg --encoding utf-16-le '\[shell\]' launch.log
rg --encoding utf-16-le '\[vis\]' launch.log

If the log was tee'd with PowerShell's default Tee-Object (UTF-16 LE without -Encoding utf8), every other byte is NUL. Read the file with Get-Content launch.log | Select-String '\[shell\]' or force UTF-8 at launch time:

dotnet run ... 2>&1 | Tee-Object -FilePath launch.log -Encoding utf8

[shell] diagnosis tree

[shell] filter=N drawCalls=N inst=N tris=N [0xCELLID:...]
│
├── NOSNAP → cell not in PrepareRenderBatches snapshot
│     Cause: cell not yet loaded (streaming lag) OR PrepareRenderBatches filter excluded it.
│     Action: check streaming for the cell; verify PrepareRenderBatches is called with filter:null.
│
├── gfx=0 → cell present in snapshot but no GfxObj batches
│     Cause: EnvCell has no renderable geometry (possible for corridor/transition cells).
│     Action: inspect dat with DatReaderWriter dump; verify the cell has a non-empty
│             EnvironmentId → GfxObj polygon list.
│
├── idx=0 → gfxObj present but zero index count
│     Cause: GfxObj loaded but ObjectMeshManager returned 0-index batch.
│     Action: check mesh staging; PrepareMeshDataAsync may not have completed for this GfxObj.
│
├── zh>0 → batch present with zero bindless texture handle
│     Cause: texture not yet uploaded to TextureCache; GfxObj mesh arrived before texture decode.
│     Action: wait for texture upload; check for TextureCache errors in log.
│
└── idx>0 + zh=0 + tr=0 → OPAQUE GEOMETRY DRAWN — fault is elsewhere
      The shell is geometrically correct and textured. The problem is:
      (a) Terrain/outdoor stabs render in front and are not gated to the portal opening,
          or (b) outside-looking-in (U.5, no outdoor root shell pass).
      This is the confirmed state for the current session.

7. Summary

The current pipeline has PVS that is computationally correct but enforces visibility through three inconsistent gates:

  1. Gate 1 (terrain): PortalVisibilityBuilderClipFrameAssemblerTerrainClipMode. Correct for sealed dungeons. Over-aggressive Skip for seen_outside=true buildings when portal is behind the camera.

  2. Gate 2 (shells): same PortalVisibilityBuilder output → envCellShellFilter. Correct. Only active for indoor root.

  3. Gate 3 (entities): parallel OLD CellVisibility BFS → VisibleCellIds (set membership), combined with PortalVisibilityBuilder-derived clip routing. The outdoor-stab bypass (ParentCellId==nullreturn true, line 1756) is the primary architectural gap.

Retail has one gate: PView. One traversal, one region per cell, every geometry type clipped to its portal-derived region. The fix is to make PortalVisibilityBuilder the sole source of truth for ALL geometry types and retire CellVisibility.VisibleCellIds as a rendering gate.

The [shell] probe evidence rules out: geometry missing, textures missing, wrong cull mode, blend/depth state errors. The shells ARE drawn correctly. The residual is purely that outdoor geometry is not gated to portal openings.