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>
35 KiB
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 |
ClipFrameAssembler ← PortalVisibilityBuilder (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::ConstructView → ClipPortals → AddViewToPortals, 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 byPrepareRenderBatches(~line 530), drawn in two passes (Opaque / Transparent) viaRender(pass, filter)(~line 790). - Clip mechanism: per-instance clip-slot buffer (binding=3,
_clipSlotBuffer). Each instance stores itsCellIdToSlotslot index. The shared clip-region SSBO (binding=2,ClipFrame.RegionSsbo) is handed in viaSetClipRegionSsbo. - 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 (0170–0175) showed
idx>0 + zh=0 + tr=0 + tr=0. Shells ARE drawn, textured, opaque. Maxfilter=3cells rendered at once (correct for a 3-cell visibility set). ZERONOSNAP.
- 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.Noneoverride 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 viaSetClipUbo. When the UBO count > 0 (Planes mode),terrain_modern.vertclips per-vertex viagl_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).Scissor→glScissortoTerrainScissorNdcAabb; 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).ParentCellIdset → pass iff cell ∈visibleCellIds.ParentCellId == null && IsBuildingShell && IsShellScopedSet→ anchor-cell check. BUTIsShellScopedSetalways returnsfalse(line 1761, U.1 deletion). So this branch is dead.ParentCellId == null && !(the above)→ unconditionally returnstrue(line 1756). This is the outdoor-scenery bypass: houses, trees, landblock-baked stabs all pass whenvisibleCellIdsis non-null but they have noParentCellId.
- 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:
visibleCellIdspassed by GameWindow isvisibility?.VisibleCellIds(oldCellVisibilityBFS), notpvFrame.OrderedVisibleCells(newPortalVisibilityBuilderBFS). 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 throughLoadedCell.Portals, collecting the full reachable set asVisibleResult.VisibleCellIds(HashSet). SetsCameraCellwhen inside a cell. Also producesHasExitPortalVisible(a portal withOtherCellId == 0xFFFFwas reached). - Used for:
visibility?.VisibleCellIds→ entity gate inWbDrawDispatcher.Draw. Also forcameraInsideCell,physicsRootlookup. - NOT used for: terrain gating, shell filter, clip-slot assignment. Those come from
PortalVisibilityBuilder. - Note:
FindCameraCellAABB grace-frame fallback was DELETED in Stage 3 (2026-06-02).ComputeVisibilityFromRoot(null, …)returnsnull(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). ProducesPortalVisibilityFrame: per-cellCellView(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 showsoutPolys=0 / terrain=Skip; room 0171 showsoutPolys=1 / terrain=Planes).
3.6 ClipFrame / ClipFrameAssembler / ClipPlaneSet
- What it does: CPU → GPU bridge.
ClipFrameAssembler.Assembletranslates thePortalVisibilityFrameinto aClipFrameAssembly: per-cell plane arrays packed into a SSBO (RegionSsbo), a UBO for terrain (TerrainUbo),CellIdToSlotmap,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.verthas NOgl_ClipDistancesupport. - Gating: SkyPreScene and SkyPostScene are bounded by the doorway scissor
(
BeginDoorwayScissor) whenskyDoorwayClipis set. Scene pass: no gate at all — draws every particle regardless of camera location. - Known gap: issue #104 (deferred). Inside a sealed cellar,
Sceneparticles 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.vertwhich writesgl_ClipDistancefrom 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 whenclipAssembly != null). EnvCell shells are NEVER rendered from the outdoor root. The indoor cells are not in the WbDrawDispatcher entity walk because they haveParentCellIdset to cells that may not be invisibleCellIds(which isnulloutdoors). - 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==nullentities bypassvisibleCellIds, always pass theEntityPassesVisibleCellGate. When_clipRoutingActive, they route to OutdoorSlot (clipped to OutsideView) or are culled when!OutdoorVisible. For a cellar withseen_outside=truebut 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 passEntityPassesVisibleCellGate(line 1756). When inside, clip routing is active and they route to OutdoorSlot. IfOutdoorVisibleis true (there IS an exit portal), they clip to the OutsideView AABB — but the AABB over-includes relative to the actual portal polygon. IfOutdoorVisibleis false (cellar with no direct exit portal in view) they should be culled — and the[vis]probe showsterrain=Skipfor the sealed cellar, meaning this is correct for terrain. But stabs still leak through because clip-slot routing usesoutdoorSlot(which points to the OutsideView region, computed per frame), not a true exclusion. - The deeper gap: even when
OutdoorVisible=false(terrain Skip), theClipSlotCullsentinel 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 andOutdoorVisible=false,ResolveEntitySlotreturnsClipSlotCull→culled=true→ entity dropped. This is CORRECT in theory. But the symptom persists, suggesting either: (a)_clipRoutingActiveis not set on frames where the symptom shows (possible race between PrepareRenderBatches and Render), or (b) some outdoor stabs haveserverGuid != 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, soOutsideViewis 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 showsoutPolys=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: 0174idx=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 hasCullMode.Landblockremapped toCullMode.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=truecell 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 aseen_outside=truecellar where the exit portal is simply behind you / not in the current frustum. - Gate: GATE #1 (terrain).
Skipfires whenOutsideView.IsNothingVisible— this is correct for a dungeon, but for aseen_outside=truebuilding 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 381–400) |
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:
-
Gate 1 (terrain):
PortalVisibilityBuilder→ClipFrameAssembler→TerrainClipMode. Correct for sealed dungeons. Over-aggressiveSkipforseen_outside=truebuildings when portal is behind the camera. -
Gate 2 (shells): same
PortalVisibilityBuilderoutput →envCellShellFilter. Correct. Only active for indoor root. -
Gate 3 (entities): parallel OLD
CellVisibilityBFS →VisibleCellIds(set membership), combined withPortalVisibilityBuilder-derived clip routing. The outdoor-stab bypass (ParentCellId==null→return 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.