diff --git a/docs/ISSUES.md b/docs/ISSUES.md index feaaeac..61e69ff 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -309,9 +309,16 @@ the indoor-lighting plumbing. ## #78 — Outdoor geometry (stabs + terrain mesh) visible inside EnvCells -**Status:** OPEN — **next-session investigation target (2026-05-25)** +**Status:** OPEN — **PROMOTED 2026-06-02 to the full render-pipeline redesign** (this IS the +core interior-seal bug; root cause now PROVEN). See +[docs/research/2026-06-02-render-pipeline-redesign-handoff.md](research/2026-06-02-render-pipeline-redesign-handoff.md) ++ [the redesign plan](superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md). Decisive evidence +(2026-06-02 [shell]/[vis] probes): the PVS + cell shells render correctly; 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 render draws the outdoor world then gates it +instead of running ONLY `DrawInside` (retail: visibility IS the cull). Fix = redesign Phase R1→R3. **Severity:** HIGH (immediate visual jank; broadened scope per 2026-05-25 PM finding) -**Filed:** 2026-05-19 (broadened 2026-05-25) +**Filed:** 2026-05-19 (broadened 2026-05-25; promoted to redesign 2026-06-02) **Component:** rendering, visibility **Description:** Standing inside Holtburg Inn looking at the floor or diff --git a/docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md b/docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md new file mode 100644 index 0000000..920dedf --- /dev/null +++ b/docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md @@ -0,0 +1,526 @@ +# 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(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 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 (0170–0175) 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). + - `Scissor` → `glScissor` 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 `ClipSlotCull` → `culled=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 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):** +```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: +```powershell +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):** `PortalVisibilityBuilder` → `ClipFrameAssembler` → `TerrainClipMode`. + 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==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. diff --git a/docs/research/2026-06-02-render-pipeline-redesign-handoff.md b/docs/research/2026-06-02-render-pipeline-redesign-handoff.md new file mode 100644 index 0000000..ef45728 --- /dev/null +++ b/docs/research/2026-06-02-render-pipeline-redesign-handoff.md @@ -0,0 +1,431 @@ +# Render Pipeline Redesign — Master Handoff (2026-06-02) + +> **Canonical pickup for the full render-pipeline redesign.** Phase W's interior "seal" +> did **not** land. The single final visual verification (user, 2026-06-02) proved the +> indoor render is fundamentally broken (issue **#78** — outdoor geometry visible inside +> EnvCells — plus transparent walls, missing-floor appearance, entity/scenery bleed, and +> no outside-looking-in). This document consolidates ALL research + the decisive live +> diagnostic evidence + the retail-faithful target + the mandate. +> +> **Read order for the next session:** this doc → the 3 research docs (§5–§7 links) → +> then **BRAINSTORM** (`superpowers:brainstorming`) the architecture **before writing any +> code**. The plan in §10 is the spine; the brainstorm refines it with the user. + +--- + +## 0. The mandate (user, 2026-06-02 — NON-NEGOTIABLE) + +Quoting the user's intent directly: + +- **FULLY WORKING** outdoor, indoor, and dungeon rendering. **No flaps, no missing + textures, no transparent walls, no ground/terrain texture leaking into the cellar, no + bleed-through.** Outside, inside, portal transitions, dungeons, particle clipping — + **EVERYTHING.** +- **No shortcuts. No bandaids. No quick fixes.** "If you have choices of doing something + fast when it might not be the best way or the most architecturally correct way, never + take that way." If the correct way is slower, take it anyway. +- **If you have to refactor and redesign the whole pipeline, DO THAT.** +- **Port from retail** decomp where needed — it is the oracle (`docs/research/named-retail/`). +- **If you need more research mid-session, do it** — never guess. +- **Start with a brainstorm** before implementing. + +This supersedes the incremental "wire 3 gaps" framing of the Phase W render plan. The +render half of Phase W is **reopened as a ground-up, retail-faithful redesign.** + +--- + +## 1. Current broken state — the visual gate (2026-06-02, Holtburg cottage) + +The user walked the cottage and reported five distinct failures: + +1. **Outdoor → looking through an open door/window:** interior walls are **transparent** + (you see into/through the house). *(= outside-looking-in not implemented; deferred U.5.)* +2. **Just inside:** you see **only the outdoor world background + NPCs/particles/doors** — + no interior shell sealing you in. +3. **Inside → looking out:** sky is visible, but you see **houses and trees through the + ground**. +4. **On the cellar stairs:** the cellar walls show, but the **floor is grey**, and NPCs / + particles / doors show **through the walls above**. +5. **In the cellar:** you see the **grey world instead of the floor**. + +Net: **the interior is not a sealed space.** This is issue **#78** ("outdoor geometry +visible inside EnvCells") in full, plus entity/scenery bleed and the missing +outside-looking-in path. + +--- + +## 2. Decisive live evidence — the root cause is PROVEN (not theorized) + +Captured this session via the in-tree probes (`ACDREAM_PROBE_CELL` / `_VIS` / `_SHELL`), +read from the UTF-16 `Tee-Object` logs. **These three facts together pinpoint the cause:** + +### 2.1 The visibility math is CORRECT +- `[cell-transit]` shows the player moving cleanly through the cottage cells: + `0xA9B40031`(outdoor) → `0170`(vestibule) → `0171`(room) → `0175`(stairs) → `0174`(cellar). + Membership works (the Stage-1/2 fix `59f3a13` holds; some doorway flips are the player + genuinely crossing the threshold, not a strobe). +- `[vis]` shows the right visible set + portal openings, e.g.: + - `root=0xA9B40171 cells=2 ids=[0171,0170] outside(polys=1,planes=4)` — from the room, + the exit portal to outdoors IS detected (terrain/sky can draw through it). + - `root=0xA9B40174 cells=1 ids=[0174] outside(polys=0,planes=0)` — the cellar is + correctly **sealed** (no exit portal in view → terrain Skip). + - Per-cell clip-plane counts are populated. **`max filter=3`** cells in any single frame. + +**⇒ The PVS (`PortalVisibilityBuilder`) is not the bug. It computes the correct answer.** + +### 2.2 The cell shells RENDER CORRECTLY — opaque, textured, complete +The `[shell]` probe (added for #78) emits, per opaque pass, the actual geometry drawn. +Distinct patterns observed (frequency × line): + +``` +205977x filter=1 ... tris=2 [0xA9B40174:gfx=1 tf=1 batch=3 idx=42 tr=0 zh=0] (cellar: 14 tris) + 1476x filter=3 ... tris=48 [0xA9B40171:...idx=270 tr=0 zh=0] [0173:idx=24] [0172:idx=144] (room+subcells: 90+8+48 tris) + 130x filter=1 ... tris=38 [0xA9B40171:...idx=270 tr=0 zh=0] (room: 90 tris) + 125x filter=2 ... [0xA9B40174:idx=42] [0xA9B40175:idx=24] (cellar+stairs) + 75x filter=1 ... [0xA9B40175:idx=24] (stairs) +``` +- **Zero `NOSNAP`** (no "geometry not prepared" cases). **Zero `zh>0`** (no missing-texture + cases). **Zero `tr`** (all opaque). Every visible cell draws its full, textured mesh. +- Per the probe's own diagnosis tree: `idx>0 + zh=0 + tr=0` ⇒ **"opaque geometry IS drawn, + with valid textures — the fault is DEPTH/OCCLUSION, or the geometry isn't the wall."** + +**⇒ The shells are not the bug either. They draw fine. This was NEVER a shader/texture/ +missing-mesh problem** (which is what naive guesses would chase). + +### 2.3 So the failure is the SEAL + the GATES +Putting 2.1 + 2.2 together with the symptoms: the visible cells draw correctly, but +- only **≤3 cells'** shells draw, and **they do not form a closed opaque occluder** over + the rest of the world, so the outdoor scene shows through wherever indoor geometry isn't; +- the **outdoor terrain + outdoor scenery entities** (the "houses and trees through the + ground" = `ParentCellId == null` stabs/scenery) are **not culled or depth-occluded** when + you're indoors — they draw and win; +- **outside-looking-in** draws no interior at all (no exterior→interior portal path; U.5). + +This is the **three-inconsistent-gates** failure (see §3), the exact problem the project's +own memory (`render — one gate (PView)`) flags as the core unsolved indoor-render issue — +"the week of 2026-05-25→31 made no indoor-render progress because the pipeline had 3 +inconsistent gates (terrain/shell/entity)." + +--- + +## 3. Root-cause analysis — three inconsistent gates, no closed-occluder seal + +acdream's render loop gates four classes of geometry, from **inconsistent sources**: + +| Geometry | Gate in code | Source of truth | Problem | +|---|---|---|---| +| **Terrain** | `terrainClipMode` (Skip/Scissor/Planes) | `ClipFrameAssembler` ← `PortalVisibilityBuilder` OutsideView | OK-ish, but only one of several gates | +| **Cell shells** | `envCellShellFilter = clipAssembly.CellIdToSlot.Keys` | `PortalVisibilityBuilder` | Draws ≤3 cells; never forms a *closed occluder* | +| **Entities** (incl. outdoor scenery, doors, NPCs) | `visibility?.VisibleCellIds` | **`CellVisibility.ComputeVisibilityFromRoot`** — a *different* traversal | Disagrees with the shell/terrain gate; **outdoor scenery (`ParentCellId=null`) not culled indoors** | +| **Particles** | *(none)* | — | Issue #104 — no cell clip at all | +| **Sky/weather** | `drawSkyThisFrame` (Stage 4) | `ClipFrameAssembler` | The only consistent one — but a top layer | + +**Two distinct architecture defects:** + +1. **Two visibility computations, not one.** `PortalVisibilityBuilder.Build` (the faithful + PView BFS) feeds the shell + terrain + sky gates. But the **entity** gate reads + `CellVisibility.ComputeVisibilityFromRoot` — an *older, separate* visibility path. They + are not guaranteed to agree, and the entity one does not properly cull outdoor scenery + when indoors. **Retail has ONE traversal (`PView`) that produces one `cell_draw_list`, + and every cell's objects are drawn (and only those) — there is no second gate.** + +2. **No "closed interior occluder" model.** Retail's interior render is *sealed by + construction*: it draws the visible cells (whose `drawing_bsp` are closed boxes — floor, + walls, ceiling), draws `LScape` only through exit-portal clip regions, does a conditional + **Z-only** clear at the doorway, and draws each cell's objects clipped to that cell. The + outdoor world is *never drawn behind a wall* because the traversal simply never visits it + and the closed cells occlude it. acdream instead draws the outdoor world (terrain + + scenery) and *then* a few cell shells on top, relying on depth/gates that don't hold — + so the world bleeds through. + +**The fix is not another gate or another clip. It is to make the render obey ONE +visibility answer for ALL geometry, with the interior sealed by drawing closed cells + +clipping LScape through portals — i.e., port retail's `PView::DrawCells` faithfully.** + +--- + +## 4. Reusable vs Redesign (from the live evidence; research doc B §5 deepens this) + +**KEEP (proven correct, build on these):** +- **Cell membership** — the transition-owned `find_cell_list` fix (`59f3a13`). `[cell-transit]` + confirms correct cell tracking. Do not reopen. +- **`PortalVisibilityBuilder`** (the PView BFS) — produces the correct visible set + + OutsideView + `OrderedVisibleCells`, with `seen`-HashSet termination (#102 closed). This + is the single visibility authority the redesign should route EVERYTHING through. +- **The sky/weather NDC-clip insight** (Stage 4, `ce2edad`/`b595cfb`): OutsideView clip + planes are screen-space half-spaces → projection-independent, so `sky.vert` clips the + dome exactly. Reusable once the base seals; keep the commits. +- **`ClipFrame` / `ClipPlaneSet` / `ClipFrameAssembler` / `PortalView`** — the clip-plane + machinery + the per-cell NDC regions. Reusable. +- **Cell shell rendering** (`EnvCellRenderer`) — draws correct opaque textured geometry. + Keep; the bug is *what gets drawn around it*, not the shells. +- **The diagnostic apparatus** — `ACDREAM_PROBE_CELL/VIS/SHELL` + the `[shell]` diagnosis + tree. Indispensable; see §8. + +**REDESIGN (the broken parts):** +- **The gating.** Collapse the two visibility computations into ONE (`PortalVisibilityBuilder`'s + visible set), and route terrain + shells + **entities** + particles through it. Delete the + `CellVisibility.ComputeVisibilityFromRoot` entity path (or make it the same answer). +- **The interior seal** — adopt retail's closed-cell + LScape-through-portal + Z-clear model + so the outdoor world is never drawn behind a wall (occlusion by construction, not by gate). +- **Outdoor-scenery gating** — `ParentCellId=null` entities (houses, trees, stabs) must be + culled / occluded when indoors and not visible through a portal. +- **Particle cell-clip** (#104) — particles need a cell and must clip to the visible set. +- **Outside-looking-in (U.5)** — the exterior→interior portal path (look into a building + from outside and see its sealed interior, not transparent walls). +- **Dungeons** (#95) — validate BFS convergence + the no-landscape path on a real dungeon. + +--- + +## 5. The retail-faithful target architecture +**→ Full reference: `docs/research/2026-06-02-retail-render-pipeline-full-reference.md`** (research doc A — +verified against the named-retail pseudo-C + `acclient.h` this session). + +### 5.1 The ten load-bearing retail facts (THE architecture to port) +1. **One cell graph, one membership answer, render obeys it.** Physics tracks `curr_cell` + through the sweep; the camera tracks `viewer_cell`; both resolve via the same + `CObjCell::GetVisible`. +2. **The top-level decision is BINARY** (`RenderNormalMode @ 0x453aa0`): viewer in an + outdoor landcell → `LScape::draw` (full landscape). Viewer in an EnvCell → **`DrawInside` + only.** It is **NOT** "draw outdoors then draw cells on top." **When inside, the full + outdoor scene is never drawn.** ← *This single fact is the inversion of acdream's bug.* +3. **`DrawInside` = one PView portal flood** (`ConstructView → DrawCells`) producing the + `cell_draw_list` + per-cell clip regions + **one** `outside_view`. +4. **Exit portals (`other_cell_id == 0xffffffff`) pull the landscape INTO the indoor + traversal** — `ClipPortals` (pc:433662) copies the doorway clip region into `outside_view`. + The outdoors is seen *only* through that clipped region. +5. **The seal sequence in `DrawCells`** (when `outside_view.view_count > 0`): + `LScape::draw` **clipped to the doorway** → conditional **Z-ONLY** `Clear(4,…)` (NOT + color) → exit-portal stencil → `DrawEnvCell` (closed geometry) → per-cell objects. +6. **No blue clear-color hole by construction** — the only clear is depth-only + conditional; + the doorway shows real terrain because LScape drew there first. +7. **Ceilings/walls sealed by dat geometry** — `drawing_bsp` is a closed box; portal holes + are stencil-masked, never filled; there is no "cap the ceiling" step. +8. **VISIBILITY *IS* THE CULL.** Only `cell_draw_list` cells render, and only **their** + `object_list` objects (drawn with `PortalList` set). There is **no second "draw all + entities then gate them" pass.** → no wall/object/particle bleed, by construction. +9. **Outside-looking-in is the mirror image** — `DrawPortal @ 0x5a5ab0` runs + `ConstructView(CBldPortal)` + `DrawCells` to render the interior through the door's clip, + the **same** machinery. +10. **Dungeons are emergent** — all-EnvCell, `seen_outside == 0`, no exit portals → + `outside_view` stays 0 → `LScape::draw` is never called → no terrain/sky, automatically. + +Plus: BFS convergence is **watermark-bounded** by per-cell `portal_view_type.update_count` +(the retail replacement for a fixed reprocess cap — fixes #102 *and* the dungeon PVS blowup +#95); `find_visible_child_cell @ 0x52dc50` resolves child cells via the portal/stab graph + +BSP `point_in_cell` (never AABB); each cell carries **three** BSPs (`drawing_bsp` render / +`physics_bsp` collision / `cell_bsp` containment). + +### 5.2 The porting checklist (doc A §7 — the spine of the plan) +- **CL-A Membership foundation** (physics owns the cell): swept-cell return, exit-portal + crossing, interior-wins + prune, commit-on-difference. *(Largely DONE — `59f3a13`.)* +- **CL-B Render-root unification:** single decision, root at physics `CurrCell`, child-cell + via graph, landscape keep/release on `seen_outside`, `grab_visible_cells`, pre-position terrain. +- **CL-C PView traversal:** BFS, InitCell, ClipPortals (both branches), AddViewToPortals/AddToCell, + `update_count` watermark, GetClip. *(`PortalVisibilityBuilder` already covers much of this.)* +- **CL-D Seal mechanics in DrawCells:** LScape-through-door FIRST, conditional Z-only clear, + exit-portal stencil, closed-geometry draw, **per-cell clipped objects**, self-contained GL state. +- **CL-E Outside-looking-in:** DrawPortal, ConstructView(CBldPortal) recursion, separate outdoor pview. +- **CL-F Entity/particle cell clipping:** graph placement, draw only visible cells, portal-clip straddlers. +- **CL-G Conformance/acceptance:** cottage sealed + sky-through-door, dungeon sealed + no terrain, + outside-looking-in, headless asserts. + +**The redesign verdict:** acdream must stop drawing the outdoor world and then gating it. +When the viewer is in an EnvCell, it must run **one `DrawInside` flood** that draws only the +visible cells + their objects, pulling LScape in only through clipped exit portals. The cull +*is* the visibility. Everything in §3 ("three inconsistent gates") collapses into this. + +--- + +## 6. Reference cross-check +**→ Full cross-check: `docs/research/2026-06-02-render-reference-crosscheck.md`** (research doc C). + +**WorldBuilder is the wrong model and must NOT be re-adopted for the seal** (10-point verdict): +WB has a hard `isInside` branch switching two completely different render paths (retail has +one); WB's "seal" is a per-**building** GPU stencil from portal-polygon rasters (flat, +building-granularity) whereas retail's is a CPU-derived `OutsideView` clip polygon from the +recursive per-**portal** BFS; **WB's flap is inherent** — the `isInside` branch flips at the +doorway and the two stencil setups tear on that frame; WB clips one stencil per building +(per-portal precision is impossible); WB works for a static dat-viewer but is +**architecturally wrong for a live client that crosses thresholds**; grafting onto WB's +two-pipe is **not recoverable** — the retail PView port is the correct fix. + +- **ADOPT:** retail PView BFS (already in acdream as `PortalVisibilityBuilder`), the + `SeenOutside` terrain gate, the `OtherCellId==0xFFFF` exit-portal sentinel, the WB **mesh + pipeline** (keep — it's just GL plumbing), `EnvCellRenderer.PrepareRenderBatches(filter)`, + entity outdoor-slot routing via `WbDrawDispatcher.ResolveEntitySlot`. +- **AVOID (do not reintroduce):** WB `RenderInsideOut`/`RenderOutsideIn` stencil two-pipe, + `BuildingPortalGPU`/`RenderBuildingStencilMask`, any `isInside` / `cameraInsideBuilding` + gate, ACViewer brute-force all-cells draw, any AABB grace-frame fallback for the visibility root. + +--- + +## 7. Current pipeline inventory + failure map +**→ Full inventory: `docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md`** (research doc B). + +### 7.1 The concrete bugs (file:line — these are the *mechanisms* behind §1's symptoms) +- **🔴 THE outdoor-scenery bleed ("houses/trees through the ground"):** + `WbDrawDispatcher.EntityPassesVisibleCellGate` hits an **unconditional `return true` at + `WbDrawDispatcher.cs:1756`** for entities with `ParentCellId == null` (outdoor scenery: + houses, trees, landblock stabs). The `IsShellScopedSet` anchor-cull branch that *should* + gate them is **dead code** (always false since U.1 deleted shell sets). ⇒ **outdoor stabs + always draw, even inside a sealed cellar.** This is the #1 visible bleed. +- **🔴 Two visibility computations:** the entity gate is fed by `CellVisibility.VisibleCellIds` + (a *parallel, separate* BFS), while shells + terrain are fed by `PortalVisibilityBuilder`. + They are not the same answer. Retail has ONE (§5.1 fact 8). +- **🟠 Terrain `Skip` over-aggression:** `TerrainClipMode.Skip` fires whenever the exit portal + is not in the current view — correct for a true dungeon, but it removes the terrain floor for + a `seen_outside=true` cottage when the player faces away from the door. (In the retail model + this is moot: terrain is only ever drawn *through* an exit-portal clip, never as a floor under + the interior.) +- **🟠 Particles:** no cell gate at all (#104). +- **🟠 `CullMode.Landblock → None` double-sided stopgap** (`EnvCellRenderer.cs:~1210`) — cells + draw double-sided to dodge a winding issue; a redesign should resolve the actual winding. +- **🟠 Outside-looking-in (U.5):** no outdoor-root shell pass → transparent walls through a door. + +### 7.2 The gate table (the inconsistency, from B) +| Geometry | Gate | Fed by | Verdict | +|---|---|---|---| +| Terrain | `TerrainClipMode` | `PortalVisibilityBuilder` | over-aggressive Skip; wrong *model* (floor vs portal-clip) | +| Cell shells | `envCellShellFilter` | `PortalVisibilityBuilder` | correct geometry, but never a closed occluder | +| **Entities** | `CellVisibility.VisibleCellIds` | **parallel BFS** | wrong source + `null`-ParentCell bypass = bleed | +| Particles | *(none)* | — | #104 | +| Sky/weather | `drawSkyThisFrame` | `PortalVisibilityBuilder` | the only consistent one (Stage 4) | + +### 7.3 KEEP (from B): `PortalVisibilityBuilder`, `ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`, +Stage-4 sky/weather clip + doorway Z-clear, `EnvCellRenderer` geometry/MDI path, +`TerrainModernRenderer`, `ResolveEntitySlot` (correct for indoor statics), all probes. +### 7.4 REDESIGN/FIX (from B): (a) delete the `ParentCellId==null → return true` bypass + gate +outdoor stabs; (b) feed entity dispatch from `pvFrame.OrderedVisibleCells`, not the parallel +`CellVisibility` BFS; (c) fix terrain `Skip`/model; (d) particle cell-gate (#104); (e) resolve +the double-sided stopgap; (f) implement U.5 outside-looking-in. + +--- + +## 8. Diagnostic apparatus (use it — evidence-first, always) + +- **Launch** (per CLAUDE.md "Running the client"): set `ACDREAM_LIVE=1`, host/port/user/pass, + `ACDREAM_DAT_DIR`, then `dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build + -c Debug 2>&1 | Tee-Object -FilePath launch.log`, in the background. The character `+Acdream` + spawns at the Holtburg cottage (`0xA9B40031`, outdoor); walk in to reach `0170→0171→0175→0174`. +- **Probes** (env vars; the log is **UTF-16** — read with PowerShell `Select-String` or the + ripgrep Grep tool, **NOT** GNU grep): + - `ACDREAM_PROBE_CELL=1` → `[cell-transit]` (membership; low volume, decisive). + - `ACDREAM_PROBE_VIS=1` → `[vis]` (the PVS frame: root cell, visible cells, OutsideView + poly/plane counts, per-cell plane counts). + - `ACDREAM_PROBE_SHELL=1` → `[shell]` (per opaque pass: `filter`, `drawCalls`, `inst`, + `tris`, and per-cell `gfx tf batch idx tr zh`). **Diagnosis tree:** `NOSNAP`/`gfx=0` ⇒ + no geometry prepared; `idx>0 + zh>0` ⇒ prepared but missing texture (invisible); + `idx>0 + zh=0 + tr=0` ⇒ opaque drawn, fault is depth/occlusion or wrong geometry. + ⚠ Unthrottled — fires every frame (183K lines in a short walk). Filter in PowerShell. +- **Screenshots:** Windows blocks foreground-steal from a background process; capturing the + AcDream window reliably needs the user to foreground it (or a `PrintWindow` approach that + is unreliable for GL surfaces). Prefer the probes + the user's description for render bugs. +- **`tools/A8CellAudit`** + the committed doorway fixture remain for offline membership checks. + +--- + +## 9. Do-NOT-repeat / settled facts + +- **Do not chase shaders/textures/missing-meshes for #78.** The `[shell]` evidence proves + shells draw opaque + textured + complete. The bug is the seal + the gating, not the shells. +- **Do not add another gate, scissor, or clip layer on top.** That is the failed incremental + approach. The redesign collapses to ONE visibility answer + a closed-occluder seal. +- **Do not reintroduce WorldBuilder's `RenderInsideOut` stencil two-pipe** (abandoned + 2026-05-30; reference-divergent from retail). Port retail's recursive PView. +- **Do not reopen the membership fix** (`59f3a13`) — `[cell-transit]` confirms it works. +- **My mistake this session (learn from it):** I trusted the prior handoff's "render infra + already exists, just wire 3 gaps" framing and built the Stage-4 sky-through-door clip on + top **without verifying the base seal end-to-end** (I can't see the screen; I leaned on the + test suite + a smoke-launch). The visual gate correctly caught that the premise was wrong. + **Lesson:** for a render seal, get the user's eyes (or the `[shell]`/`[vis]` evidence) on + the ACTUAL sealed result EARLY, before building layers on top. A green test suite proves + nothing about whether the interior looks sealed. +- **Stage-4 commits are kept** (`ce2edad`, `a8b831c`, `872dd34`, `21609a7`, `4bc99fc`, + `b595cfb`) — the sky NDC-clip + the green-tests triage are real and reusable; they are a + top layer that becomes correct once the base seals. The 5 remaining Core test failures are + pre-existing physics/collision bugs (2 step-up gaps incl. a regression from A6.P4's door + fix; 3 door-collision apparatus / A6.P5), none Phase-W's, flagged not fixed. + +--- + +## 10. The redesign plan +**→ `docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md`** (the huge staged plan). +Stages: **R0** brainstorm/lock → **R1** one-visibility-authority (kill the outdoor-scenery bleed) +→ **R2** indoor = `DrawInside`-only (stop drawing the outdoor world indoors) → **R3** the seal +(`DrawCells` faithful port: LScape-through-door → Z-only clear → closed cells) → **R4** per-cell +object + particle clip → **R5** outside-looking-in (U.5 / `DrawPortal`) → **R6** dungeons → **R7** +polish + conformance. Each ends at a **user visual gate**. The core inversion: **when inside, run +ONLY the PView flood — visibility IS the cull; the outdoor world enters only through clipped exit +portals.** + +--- + +## 11. Pickup prompt (copy-paste for the next session) + +``` +RENDER PIPELINE REDESIGN — full retail-faithful rewrite of the world render (outdoor + indoor + +dungeon + portals + particles). Continue on branch claude/thirsty-goldberg-51bb9b (do NOT +branch/worktree; do NOT push without asking; NEVER git stash/gc — a shared stash is under +investigation). Use PowerShell on Windows; the launch logs are UTF-16 (read with Select-String / +the ripgrep Grep tool, NOT GNU grep). + +MANDATE (user, non-negotiable): FULLY WORKING outdoor + indoor + dungeon rendering — no flaps, no +missing textures, no transparent walls, no terrain leaking into cellars, no entity/particle +bleed-through, outside-looking-in works. NO shortcuts, NO bandaids, NO quick fixes; take the +architecturally-correct path even if slower; redesign the whole pipeline if needed; PORT FROM +RETAIL; do more research mid-session rather than guess; START WITH A BRAINSTORM. + +READ FIRST (in order): +1. docs/research/2026-06-02-render-pipeline-redesign-handoff.md (THIS handoff: §2 proven evidence, + §3 root cause = three inconsistent gates + no closed-occluder seal, §5 retail target + porting + checklist CL-A..G, §9 do-not-repeat). +2. docs/research/2026-06-02-retail-render-pipeline-full-reference.md (the retail PView pipeline to + port — the seal mechanics in DrawCells). +3. docs/research/2026-06-02-acdream-render-pipeline-inventory-and-failures.md (the concrete bugs: + the WbDrawDispatcher.cs:1756 ParentCellId==null bypass = the outdoor bleed; the parallel + visibility BFS; terrain Skip model). +4. docs/research/2026-06-02-render-reference-crosscheck.md (why WB's two-pipe stencil is the wrong + model — do NOT reintroduce it). +5. THE PLAN: docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md (stages R0..R7). + +PROVEN ROOT CAUSE (don't re-investigate): the PVS computes correctly and the cell shells render +correctly (opaque, textured, complete — [shell] probe). The failure is the SEAL + the GATING: +acdream draws the outdoor world (terrain + scenery ENTITIES that aren't culled) and then a few cell +shells on top, relying on three inconsistent gates. Retail, when inside, runs ONLY DrawInside (one +PView flood) — visibility IS the cull, and the landscape enters only through clipped exit portals. +That inversion is the redesign. + +DO NEXT: +1. Phase R0 — BRAINSTORM the §1 architecture (handoff §5 / plan §1) with the user; resolve the open + questions (plan §3); write the per-phase design spec. NO code until the design is locked. +2. Then execute R1→R7 (plan §2), each retail-anchored, each ending at a user visual gate. R1 (one + visibility authority + kill the WbDrawDispatcher.cs:1756 bypass) kills the headline bleed first. + +EVIDENCE-FIRST: launch per CLAUDE.md "Running the client" with ACDREAM_PROBE_CELL/VIS/SHELL=1; walk +the Holtburg cottage (spawn 0xA9B40031 outdoor → 0170 vestibule → 0171 room → 0175 stairs → 0174 +cellar). The [shell] diagnosis tree + [vis] OutsideView counts are decisive. GET THE USER'S EYES on +the actual sealed result at every visual gate — never declare a seal off the test suite (that was +the mistake that produced this handoff). + +KEEP (don't rewrite): PortalVisibilityBuilder (the PVS), ClipFrame/ClipFrameAssembler/ClipPlaneSet, +EnvCellRenderer mesh path, TerrainModernRenderer, the WB mesh pipeline, the membership fix (59f3a13), +the Stage-4 sky NDC-clip + doorway Z-clear (ce2edad/b595cfb). The work is RESTRUCTURING THE +ORCHESTRATION + the entity/particle draw to be per-cell, not a from-scratch rewrite. + +TEST STATE: full suite green except 5 pre-existing Core failures (2 step-up gaps incl. an A6.P4 door +regression; 3 door-collision apparatus / A6.P5) — none render-related, flagged in the handoff, do not +chase as part of this work. +``` + +--- + +## 12. Session ledger (2026-06-02, render half) +- Stage 4 (sky/weather portal clip + Z-clear + green-tests triage) shipped: `ce2edad`, `a8b831c`, + `872dd34`, `21609a7`, `4bc99fc`, `b595cfb`. Real + reusable, but a top layer — the base seal was + never working (#78). Visual gate FAILED → this redesign handoff. +- Research this session: retail pipeline reference (doc A), acdream inventory+failures (doc B), + reference cross-check (doc C), this master handoff, the redesign plan. +- Issues to file/track: #78 (the core seal — now the redesign target), #95 (dungeon BFS), #104 + (particle cell-clip), the `WbDrawDispatcher.cs:1756` outdoor-scenery bypass, the + `CullMode.Landblock→None` double-sided stopgap, U.5 outside-looking-in. diff --git a/docs/research/2026-06-02-render-reference-crosscheck.md b/docs/research/2026-06-02-render-reference-crosscheck.md new file mode 100644 index 0000000..2293a44 --- /dev/null +++ b/docs/research/2026-06-02-render-reference-crosscheck.md @@ -0,0 +1,449 @@ +# Indoor Cell / Portal Visibility Render Reference Cross-Check +**Date:** 2026-06-02 +**Purpose:** Inform acdream's Phase U unified render pipeline redesign by documenting what +each reference client does for indoor cell rendering, portal visibility, the interior seal, +outdoor-scenery gating, and object/particle clipping — with explicit ADOPT / AVOID verdicts. + +--- + +## 1. WorldBuilder — Indoor Render Approach (RenderInsideOut Two-Pipe Stencil) + +### What WB actually does + +**Source files:** +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs` +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs` +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 880–1008 + +#### The branch decision + +`GameScene.cs:881` sets `isInside = currentEnvCellId != 0` (the camera is in ANY EnvCell). +Two completely different code paths branch on that boolean: + +- **Inside (`RenderInsideOut`)** — `VisibilityManager.RenderInsideOut` (lines 73–239) +- **Outside (`RenderOutsideIn` or fallback)** — `VisibilityManager.RenderOutsideIn` (lines 241–358) + or `RenderEnvCellsFallback` when `EnableCameraCollision` is off + +This is the split acdream inherited from Phase N.4. **It is the root cause of every +doorway seam bug.** The two paths are fundamentally incompatible at a threshold crossing — +the hand-off point is where the flap, transparent walls, and terrain bleed all happen. + +#### RenderInsideOut walkthrough (VisibilityManager.cs:73–239) + +1. **Stencil Bit 1 — doorway mask:** All portal polygons of the *camera's building* are rasterized + with `StencilOp.Replace` → Bit 1 = 1. This marks the screen pixels that correspond to doorways. + Done with `DepthFunc(Always)` so portals draw regardless of what's in front of them. + +2. **Punch depth at doorways:** The same portal geometry is drawn again with `uWriteFarDepth=1` + (the stencil shader writes `gl_FragDepth = 1.0`). This clears depth at the doorway pixels + so the outdoor terrain can bleed through them. + +3. **Render indoor cells ALWAYS** (no stencil guard). The camera building's full EnvCell set + is drawn unconditionally (`_currentEnvCellIds` = union of all cells in the camera building). + No per-portal clip. All cells in the building render even if behind the player. + +4. **Gate outdoor geometry (terrain/scenery/static) through Bit 1.** Stencil func `Equal(1, 0x01)` — + terrain, scenery, and static objects only draw where the portal polygons were rasterized. + This is the "seal" against outdoor bleed: if Bit 1 wasn't set, the pixel never gets terrain. + +5. **Other buildings' cells** (Step 5, lines 157–229): For each other building visible through + our doorways, WB does a further two-step mask — Bit 2 marks the intersection of our doorway + AND the other building's portals (stencil == 3 meaning both bits set), then draws that + building's cells only where both portals are open. Uses occlusion queries to skip buildings + that were fully occluded last frame. + +6. **RenderOutsideIn** (outside path, lines 241–358): mirrors Step 1–2 but camera is outside + looking in. Portal polygons mark Bit 1; depth is cleared at those pixels; EnvCells render + through the mask. Terrain/scenery/statics draw normally (no stencil guard). + +#### What `GetVisibleBuildingPortals` provides + +`PortalRenderManager.GetVisibleBuildingPortals` returns a `BuildingPortalGPU` per **building** +(not per cell or per portal). The `BuildingPortalGPU` is a triangle-fan tessellation of ALL +portal polygons for the building concatenated into a single VAO/VBO. This is the flat union — +there is no per-portal polygon tracking. One stencil pass per building. + +`EnvCellRenderManager.GenerateForLandblockAsync` discovers cells recursively from building +portals (`portal.OtherCellId != 0xFFFF` — exit portals are skipped). The `seenOutsideCells` +set tracks cells with `EnvCellFlags.SeenOutside` but WB only stores this for diagnostic use; +it does NOT gate the cell draw off `SeenOutside`. + +#### How WB decides cell visibility for the filter + +`VisibilityManager.PrepareVisibility` (lines 47–71): when `isInside`, adds ALL cells of every +building the camera is in (`_buildingsWithCurrentCell`). No per-portal traversal. No +per-portal clip. No `VisibleCells` stab-list from the dat. The full cell set of the building +is the filter. + +When `isInside=false`, `GetVisibleBuildingPortals` returns frustum-visible building groups; +ALL their cells are added to `visibleEnvCells`. Again, no per-portal traversal. + +**There is no WB equivalent of retail's per-cell `VisibleCells` (the PVS stab-list). WB +never reads `EnvCell.VisibleCells`.** WB's visibility is building-level, not cell-level. + +#### Why the WB two-pipe diverges from retail's recursive PView + +Retail `PView::ConstructView` (decomp ~433750) and `ClipPortals` (~433572): + +| Property | WB RenderInsideOut | Retail PView | +|---|---|---| +| **Visibility unit** | Building (all cells in a building) | Per-cell portal traversal | +| **Clip granularity** | One stencil mask per building | Per-portal screen-space clip polygon | +| **Camera branching** | Hard `isInside` branch switching two completely different code paths | No branch — "which cell is the camera in" changes only the BFS root, not the algorithm | +| **Outdoor geometry gate** | Stencil Bit 1 derived from the portal polygon raster at the wall | OutsideView clip polygon accumulated by clipping through exit portals in the BFS | +| **Per-cell PVS** | Not used | `CEnvCell.stab_list` + `seen_outside` read per cell; portal side test per edge | +| **Scenery gating** | Outdoor scenery draws only where Bit 1 is set (all portals of the camera building) | Outdoor entities/scenery assigned OutsideView clip slot; cull when OutsideView empty | +| **Terrain gate** | Depth punched at portal pixels; terrain only draws stencil==1 | TerrainClipMode: Skip when no exit portal visible, Planes/Scissor when one is | +| **Other-building cells** | 2-bit stencil composed gate with occlusion query fallback | Same recursive BFS — other buildings' cells are cells in the PVS; no separate pipe | +| **Seam at doorway** | **Inherent** — the two pipes switch at `currentEnvCellId != 0`; the frame of the switch always tears | **None** — outdoor/indoor are the same draw loop with different clip regions | + +#### Why acdream abandoned WB's two-pipe (2026-05-30) + +From `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md`: + +> "The A8.F effort tried to graft retail's recursive clip *on top of* WB's two-pipe stencil +> (a CPU-built NDC mask bridging the two pipes). That hybrid is inherently fragile and failed +> its visual gate (issue #103). You cannot make two pipes hand off seamlessly at a doorway; +> retail avoids the entire bug class by never splitting." + +The specific failure modes WB's architecture cannot fix without replacing the architecture: + +1. **The flap** — The `isInside` / `currentEnvCellId != 0` gate flips on the same frame the camera + crosses the doorway. On the flip frame WB switches from RenderInsideOut to RenderOutsideIn (or + vice versa). The stencil state from the prior frame is cleared; the new portal polygon raster + hasn't loaded. There is one frame where the indoor draw is missing OR the outdoor draw bleeds + through. This is inherent to the architecture. + +2. **Terrain bleed indoors** — WB's stencil Bit 1 mask is derived from the camera-building portal + polygons rasterized with `DepthFunc(Always)` BEFORE terrain. If the portal polygon is degenerate, + or the stencil clear races the portal raster, terrain bleeds through. The mask is not ground in + the per-cell PVS (`VisibleCells` / `SeenOutside`) so it cannot make a definitive "no terrain here + EVER" decision the way retail's `seen_outside=false` does for sealed dungeons. + +3. **Transparent walls** — In RenderOutsideIn, WB clears depth with `uWriteFarDepth=1` at the portal + stencil, then renders EnvCells into the cleared region. The wall geometry adjacent to the portal + also had its depth cleared (the portal polygon doesn't exactly hug the wall opening), so a wall + polygon's depth test can fail against the cleared far-plane, making it appear transparent. + +4. **Outdoor scenery entities indoors** — WB's `RenderInsideOut` gates terrain/scenery/static-objects + through Bit 1 (only where the portal polygon rasters hit). But if the entity's ParentCellId is + not tracked or is 0 (outdoor scenery has no indoor cell parent), it routes to the stencil + unguarded path and draws everywhere. + +5. **Cannot be fixed without rebuilding** — The stencil approach is a GPU-side approximation of + what retail does on the CPU (per-portal clip-polygon BFS). Every "fix" to the stencil approach + adds more GPU state to paper over a case where the stencil and the intended visibility diverge. + The correct fix replaces the stencil entirely with retail's CPU-driven visibility. + +**VERDICT: DO NOT REINTRODUCE the WB RenderInsideOut/RenderOutsideIn two-pipe stencil or any +approximation of it. The architecture is the bug.** + +--- + +## 2. ACViewer — Cell/Portal Rendering and Sealing Approach + +**Source files:** +- `references/ACViewer/ACViewer/Render/R_Landblock.cs` +- `references/ACViewer/ACViewer/Render/R_EnvCell.cs` +- `references/ACViewer/ACViewer/Render/Buffer.cs` +- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs` + +### What ACViewer does + +ACViewer is a **dat viewer and map editor** (MonoGame/DirectX), not a game client. Its rendering +is a brute-force draw with no runtime visibility or sealing: + +**`R_Landblock.cs:83–101`** `BuildEnvCells()`: For each cell id from 0x100 to +`0x100 + NumCells - 1` in the landblock, creates an `R_EnvCell` and adds it to the list. +All cells for the landblock are built, with no portal traversal. + +**`Buffer.Draw()` / `Buffer.DrawWithZSlicing()`**: Draws all batches in `RB_EnvCell`, +`RB_StaticObjs`, `RB_Buildings`, `RB_Scenery` unconditionally (gated only by the Z-slicing +filter for multi-floor dungeon inspection). No portal culling. No stencil. No clip. + +**`R_EnvCell.Draw()`** (`R_EnvCell.cs:70–87`): Calls `DrawEnv()` (sets xWorld + draws the +environment cell struct mesh) + `DrawStaticObjs()` (draws each stab). No filter. + +**There is no portal-based visibility in ACViewer at all.** ACViewer draws ALL cells and ALL +objects in every loaded landblock every frame. Culling is done only by the MonoGame frustum +culling on the DirectX state (backface culling ON in dungeon mode per `Buffer.cs:166`). + +### ACViewer's EnvCell data model — what it DOES read + +**`EnvCell.cs`** (Physics/Common, the ACViewer version used by `R_EnvCell`): + +- `VisibleCellIDs` — list of low-byte cell IDs from the dat (`stab_list` / `numStabs`). This + is the DAT-baked PVS for this cell. ACViewer reads it in the constructor (`VisibleCellIDs = + envCell.VisibleCells`) and builds `VisibleCells` dict via `build_visible_cells()`. +- `SeenOutside` — `envCell.SeenOutside` flag. ACViewer reads it. +- `Portals` — the `CellPortal[]` list. ACViewer reads it. +- `find_visible_child_cell(Vector3 origin, bool searchCells)` (lines 206–231): Checks if `origin` + is in this cell's AABB; if not, searches `VisibleCells.Values` (or falls back to `Portals`) for + a cell containing `origin`. This is the **retail `CEnvCell::find_visible_child_cell`** ported + to ACViewer's physics tree — it is the cell-membership resolver for moving objects in physics. + +**Critically, ACViewer reads and maintains the PVS / portal data but uses it ONLY for physics +collision, not for rendering.** The render path is entirely brute-force. + +### Does ACViewer seal interiors? + +**No.** ACViewer does not try to occlude the outdoor world when drawing from inside a building. +It draws everything: terrain + scenery + building geometry + dungeon cells simultaneously. It +relies on correct depth testing to show the right surfaces. This works for a static viewer (you +can rotate to any position and inspect geometry) but would be completely broken for a game client +(outdoor terrain bleeds through dungeon floors when the camera is inside). + +**ACViewer is GPL-licensed (read for understanding only; do not copy code).** + +### What is reusable from ACViewer + +- **The `EnvCell` data model** (especially `find_visible_child_cell`, `build_visible_cells`, + `VisibleCellIDs`, `SeenOutside`): these are faithful ports of the retail data structures. + acdream already has its own equivalent (`LoadedCell.VisibleCells`, `LoadedCell.SeenOutside` + in `CellVisibility.cs`). The ACViewer source confirms the field interpretations. +- **`CellPortal` struct** (`PolygonId`, `OtherCellId`, `OtherPortalId`, `PortalSide`): confirms + the exact field layout. acdream's `CellPortalInfo` record matches. +- **Algorithm understanding**: ACViewer's `find_visible_child_cell` confirms the retail pattern — + first check `point_in_cell(origin)` (self), then search `VisibleCells` by AABB, then fallback + to portal-linked neighbours. This is the retail `CEnvCell::find_visible_child_cell` at + `acclient_2013_pseudo_c.txt:311397`. + +--- + +## 3. ACE — Cell/Portal/Visibility Data Model + +**Source files:** +- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs` +- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs` +- `references/ACE/Source/ACE.DatLoader/Entity/CBldPortal.cs` +- `references/ACE/Source/ACE.DatLoader/Entity/BuildInfo.cs` +- `references/ACE/Source/ACE.Entity/Enum/EnvCellFlags.cs` +- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs` + +ACE is the **server** — it defines the canonical dat file format that both the retail client and +acdream read. The data model here is authoritative. + +### EnvCell fields (ACE DatLoader, `EnvCell.cs`) + +| Field | Type | Purpose | +|---|---|---| +| `Flags` | `EnvCellFlags` | Bitflags: `SeenOutside (0x1)`, `HasStaticObjs (0x2)`, `HasRestrictionObj (0x8)` | +| `Surfaces` | `List` | Surface IDs (`0x08000000 | shortId`) — the textures for this cell | +| `EnvironmentId` | `uint` | Environment dat id (`0x0D000000 | shortId`) — the prefab geometry block | +| `CellStructure` | `ushort` | Which sub-structure within the environment (one environment can have many cell shapes) | +| `Position` | `Frame` | World-space placement of this cell (position + rotation) | +| `CellPortals` | `List` | Connectivity list: `OtherCellId` (0xFFFF = exit portal), `OtherPortalId`, `PolygonId`, `ExactMatch`, `PortalSide` | +| `VisibleCells` | `List` | PVS stab-list: low-byte cell IDs of cells potentially visible from this one (precomputed by AC's content tools) | +| `StaticObjects` | `List` | Static geometry placed within this cell (furniture, decorations) | +| `RestrictionObj` | `uint` | GUID of the object controlling access (housing barriers) | +| `SeenOutside` | `bool` | Derived from `Flags.HasFlag(SeenOutside)` — this cell has line of sight to the exterior | + +**Note from the ACE comment at line 46:** `numStabs` (the `VisibleCells` count) — "I believe this +is what cells can be seen from this one. So the engine knows what else it needs to load/draw." +This confirms the stab-list is the visibility PVS the renderer should use as a filter. + +### The exit portal sentinel: `CellPortal.OtherCellId == 0xFFFF` + +`CellPortal.cs:10,19`: `OtherCellId` is a `ushort`. Value `0xFFFF` (65535) means "exit to +outdoor world" — the portal connects this cell to the exterior. This is the seal-breaking +portal. A cell with an exit portal in its `CellPortals` list has line of sight to the +outdoors, which is why retail's `SeenOutside` flag is TRUE for such cells (and recursively +for any cell that can reach an exit portal through the connectivity graph). + +When `OtherCellId != 0xFFFF`, the portal connects to another EnvCell with low-byte id +`OtherCellId` in the same landblock. The full id is `(landblockId << 16) | OtherCellId`. + +### LandblockInfo — building portal graph (`LandblockInfo.cs`, `BuildInfo.cs`, `CBldPortal.cs`) + +The `LandblockInfo` dat file (`xxxxFFFE`) contains: + +| Field | Type | Purpose | +|---|---|---| +| `NumCells` | `uint` | Total number of EnvCells in this landblock | +| `Objects` | `List` | Static objects at landblock level (not inside any EnvCell) | +| `Buildings` | `List` | One `BuildInfo` per building structure | + +Each `BuildInfo` has: +- `ModelId` — the GfxObj/Setup id of the building mesh +- `Frame` — world placement +- `Portals` — `List` — the **building-level portal list** (distinct from the per-cell `CellPortals`) + +Each `CBldPortal` has: +- `OtherCellId` — the EnvCell low-byte id that this portal opens into (0xFFFF = no indoor side) +- `OtherPortalId` — back-link into that cell's portal list +- `StabList` — list of cells visible through this portal opening (the per-building-portal PVS) + +This is the **entry-point graph** acdream uses in `EnvCellRenderManager.GenerateForLandblockAsync` +(line 657) to discover which EnvCells belong to which building: start from `BuildInfo.Portals`, +follow `CBldPortal.OtherCellId` → first EnvCell of the building → follow `CellPortal` connections +recursively to discover all cells in the building. + +**WB's cell discovery correctly skips `portal.OtherCellId == 0xFFFF`** — these are exit portals +that lead outside, not to another EnvCell (`EnvCellRenderManager.cs:658,780`). Acdream's +`CellVisibility.GetVisibleCellsFromRoot` also skips them (line 467) and sets `HasExitPortalVisible`. + +### What the seal means in the data model + +A cell is "sealed" (truly indoor, no outdoor bleed) if: +- `SeenOutside == false`: no exit portal reachable from this cell via the connectivity graph. + Retail's `CellManager::ChangePosition` releases the landscape (terrain) when `seen_outside` + is false on the current cell, because there is definitively nothing outdoor to draw. + +A cell "sees outside" if: +- `SeenOutside == true`: at least one exit portal (`OtherCellId == 0xFFFF`) is reachable. + Terrain may be visible; the outdoor world should be drawn, clipped to the visible exit portal. + +Acdream already reads this correctly. The `LoadedCell.SeenOutside` field is set from the dat +flag (GameWindow.cs:7704), and `rootSeenOutside = physicsRoot?.SeenOutside ?? true` gates terrain. + +--- + +## 4. Chorizite.ACProtocol + +`references/Chorizite.ACProtocol/` — generated from the protocol XML. No rendering-relevant files; +it covers network wire types only. Not applicable to this cross-check. + +--- + +## 5. AC2D + +`references/AC2D/` — C++ fixed-function OpenGL client. Does not implement indoor rendering (it +renders everything via the server's authoritative Z; no client-side interior/exterior split). Not +applicable to indoor rendering cross-check. + +--- + +## 6. Reusability Table + +| Reference | Component | Reusable? | License | Caveat | +|---|---|---|---|---| +| **WorldBuilder** | `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher` mesh pipeline | YES (in-tree) | MIT | Keep — Phase U is about visibility, not mesh extraction | +| **WorldBuilder** | `EnvCellRenderManager.GenerateForLandblockAsync` cell discovery via `CBldPortal` / recursive `CellPortal` | YES (in-tree, adapted) | MIT | The discovery loop is correct; WB already skips 0xFFFF exit portals | +| **WorldBuilder** | `EnvCellRenderManager.PrepareRenderBatches` frustum + filter | YES (in-tree) | MIT | Used by acdream's `EnvCellRenderer`; only the visibility FILTER needs replacing (use stab-list PVS instead of per-building set) | +| **WorldBuilder** | `PortalRenderManager.GetVisibleBuildingPortals` + `BuildingPortalGPU` mesh | AVOID | MIT | This is the per-building stencil mesh — only useful for WB's two-pipe stencil; not needed for a unified PView pipeline | +| **WorldBuilder** | `VisibilityManager.RenderInsideOut` / `RenderOutsideIn` two-pipe stencil | **DO NOT USE** | MIT | Root cause of all indoor seam bugs; see §1 | +| **WorldBuilder** | `EnvCellFlags.SeenOutside` / `CBldPortal.OtherCellId != 0xFFFF` sentinel | YES (already used) | MIT | Data semantics confirmed correct | +| **ACViewer** | `EnvCell.find_visible_child_cell` | READ-ONLY (GPL) | GPL | Confirms retail `CEnvCell::find_visible_child_cell` interpretation; already ported in acdream's `CellVisibility` | +| **ACViewer** | `EnvCell.build_visible_cells` + `VisibleCells` dict | READ-ONLY (GPL) | GPL | Confirms PVS stab-list usage; acdream has `LoadedCell.VisibleCells` equivalent | +| **ACViewer** | Brute-force render of all cells (Buffer.cs) | **DO NOT USE** | GPL | No visibility, no seal, not usable for a game client | +| **ACE** | `EnvCell` dat file format (all fields) | YES (already used) | AGPL | acdream reads these via DatReaderWriter; field semantics confirmed authoritative | +| **ACE** | `CellPortal.OtherCellId == 0xFFFF` exit-portal sentinel | YES (already used) | AGPL | Critical for detecting the indoor/outdoor boundary | +| **ACE** | `LandblockInfo.Buildings` / `BuildInfo.Portals` entry-point graph | YES (already used) | AGPL | The cell discovery starting point; WB and acdream both use this correctly | +| **ACE** | `CBldPortal.StabList` (per-building-portal PVS) | INVESTIGATE | AGPL | Per-portal stab-list not currently used by acdream; may supplement the per-cell stab-list for cross-building portal resolution | + +--- + +## 7. Recommendations for Phase U Redesign + +### Adopt + +1. **Retail PView portal-traversal as the single unified visibility pass** (retail anchor: + `PView::ConstructView` ~433750, `ClipPortals` ~433572, `GetClip` ~432344). + acdream already has `PortalVisibilityBuilder` and `CellVisibility.GetVisibleCellsFromRoot` + which are correct unit-tested ports. These are the keepers. + +2. **Single code path regardless of camera position.** The camera being inside or outside an EnvCell + changes only which `LoadedCell` is the BFS root — null (outdoor root: player in a LandCell) vs + non-null (indoor root: player in an EnvCell). The draw algorithm does NOT branch on this. + +3. **Per-cell PVS stab-list (`LoadedCell.VisibleCells`) as the filter ground.** Retail's + `grab_visible_cells` (decomp 311878) loads the stab-list into `VisibleCells` at cell entry + time. The per-frame BFS walks portal connectivity PLUS the stab-list to determine the drawable + set. The stab-list is precomputed by AC's content tools and is more conservative than a + per-frame portal clip (it includes cells reachable from any viewpoint in the cell, not just + from the current camera position). Use it as the BFS frontier expansion to avoid missed cells + at glancing angles. + +4. **`SeenOutside` gate for terrain** (already in acdream at GameWindow.cs:7177). + `seen_outside=false` → skip terrain entirely (pure dungeon). `seen_outside=true` and an + exit portal is visible → draw terrain clipped to the `OutsideView` clip region. + This is retail's `CellManager::ChangePosition` @ `0x004559B0` behavior. + +5. **`CellPortal.OtherCellId == 0xFFFF` as the outdoor-world gate** (already in acdream at + `CellVisibility.cs:467`, `PortalVisibilityBuilder`). Every exit portal reached in the BFS + contributes to `OutsideView`. When `OutsideView` is empty, NOTHING outdoor draws (terrain, + scenery, outdoor entities all cull). This is the closed seal. + +6. **Outdoor scenery/entity gating via ParentCellId** (already in `WbDrawDispatcher.ResolveEntitySlot`): + - Live-dynamic entities (server GUID != 0): always slot 0 (no clip) — retail draws players/NPCs + through depth without portal clipping. + - Indoor entities (ParentCellId is a full EnvCell id): route to that cell's clip slot; cull if + the cell is not in the visible set. + - Outdoor scenery/statics (ParentCellId == null or is a LandCell id): route to `OutdoorSlot`; + cull when `OutdoorVisible = false` (no exit portal in view). **This is the outdoor-scenery seal.** + +7. **The WB-derived mesh pipeline** (`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, + `TerrainModernRenderer`) is NOT the visibility problem and should not be replaced. Phase U + replaces the draw ORCHESTRATION (what gets drawn, when, with what clip), not the mesh + extraction (what vertices are in the VBO). + +8. **`EnvCellRenderer.PrepareRenderBatches` with an explicit `filter` set** (already wired: + `envCellShellFilter` in GameWindow.cs:7333). The filter set is the BFS-visible cell ids from + `ClipFrameAssembly.CellIdToSlot.Keys`. This correctly excludes cells outside the visible set + (e.g. the other side of a multi-story building when the player is only in one floor). + +### Avoid + +1. **WB's `RenderInsideOut` / `RenderOutsideIn` two-pipe stencil** — abandoned 2026-05-30, do not + reintroduce. The architecture is the bug, not a parameter of the architecture. + +2. **Per-building stencil mesh** (`BuildingPortalGPU`, `PortalRenderManager.RenderBuildingStencilMask`, + the `_stencilShader` + `InitializeStencilShader` machinery from WB) — only useful for WB's + stencil two-pipe. If acdream needs per-portal depth-clip, the retail mechanism is a software + clip plane (gl_ClipDistance) set from the per-portal NDC clip region, not a GPU stencil. + +3. **The `isInside` / `cameraInsideBuilding` gate** — this is the two-pipe switch. Phase U's + redesign must not have any version of this. The outdoor case is `root == null` (player in a + LandCell); the indoor case is `root != null` (player in an EnvCell). These are inputs to the + SAME algorithm, not selectors for different algorithms. + +4. **ACViewer's brute-force all-cells draw** — usable for map viewing tools, not for a game + client. The `Buffer.Draw()` approach will render hundreds of EnvCells including those in + completely different buildings on the other side of the landblock, causing massive overdraw + and incorrect visibility. + +5. **Any "grace frame" or fallback AABB resolver for the portal-visibility root.** The root + comes from the physics-ownership answer (`CellGraph.CurrCell`) exclusively — retail's + `CellManager::ChangePosition` reads the transition-owned `curr_cell` with no AABB fallback. + Stage 3 (2026-06-02) deleted the FindCameraCell AABB grace-frame fallback from acdream. + +6. **Re-porting the outdoor-terrain or EnvCell mesh extraction from retail decomp.** The WB + inventory doc (`docs/architecture/worldbuilder-inventory.md`) classifies these as green + (already correctly extracted from WB into `src/AcDream.{Core,App}/Rendering/Wb/`). The + rendering *orchestration* is 🔴 (must come from retail), the mesh *extraction* is 🟢 + (WB has a tested port). Do not re-port what WB already got right. + +--- + +## 8. Summary of WB-vs-Retail Divergence (10-line version) + +1. WB branches hard on `isInside` (camera in ANY EnvCell) → two completely different render paths. +2. Retail has ONE path — portal-traversal BFS from the camera cell; indoor and outdoor are just cells. +3. WB's "seal" is a per-building GPU stencil derived from portal polygon rasters (flat, building-level). +4. Retail's seal is the CPU-derived `OutsideView` clip polygon (recursive, per-portal, per-cell). +5. WB uses NO per-cell PVS stab-list (`VisibleCells`) for rendering; retail uses it as the BFS frontier. +6. WB's outdoor gate (terrain/scenery draw only where stencil=1) fails at doorway-crossing frames (the flap). +7. Retail's outdoor gate (terrain clips to `OutsideView`; skip when empty) is frame-exact and derived from the same BFS as the cell draw. +8. WB cannot express per-portal clip precision (one stencil per building); retail clips each portal opening independently. +9. WB's approach is sound for a static dat-viewer where you never cross thresholds; it is architecturally wrong for a live game client. +10. The Phase U unified pipeline (retail PView port) is the correct fix; grafting anything onto WB's two-pipe stencil is not. + +--- + +## Key File Paths Referenced + +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs` — WB RenderInsideOut/RenderOutsideIn (full stencil two-pipe; read for understanding; DO NOT reintroduce) +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs` — WB building portal GPU mesh + stencil shader +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs` — WB cell discovery loop (reusable), PrepareRenderBatches with filter (reusable) +- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/GameScene.cs` lines 880–1008 — the `isInside` branch; root of the two-pipe problem +- `references/ACViewer/ACViewer/Physics/Common/EnvCell.cs` — `find_visible_child_cell`, `build_visible_cells`, `VisibleCellIDs`, `SeenOutside` (read-for-understanding; GPL; don't copy) +- `references/ACViewer/ACViewer/Render/Buffer.cs` / `R_EnvCell.cs` / `R_Landblock.cs` — brute-force all-cells draw (read-for-understanding; GPL; DO NOT use) +- `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs` — authoritative field list including `VisibleCells`, `SeenOutside`, `CellPortals` +- `references/ACE/Source/ACE.DatLoader/Entity/CellPortal.cs` — `OtherCellId == 0xFFFF` exit-portal sentinel +- `references/ACE/Source/ACE.DatLoader/FileTypes/LandblockInfo.cs` + `Entity/BuildInfo.cs` + `Entity/CBldPortal.cs` — building portal entry-point graph +- `src/AcDream.App/Rendering/CellVisibility.cs` — acdream's BFS visibility system (correct, keep) +- `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` — acdream's recursive portal-clip BFS (correct, keep) +- `src/AcDream.App/Rendering/ClipFrameAssembler.cs` — acdream's per-cell clip slot assembly (correct, keep) +- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — entity outdoor-slot routing via `ResolveEntitySlot` (correct, keep) +- `docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md` — Phase U decision rationale diff --git a/docs/research/2026-06-02-retail-render-pipeline-full-reference.md b/docs/research/2026-06-02-retail-render-pipeline-full-reference.md new file mode 100644 index 0000000..0f6b727 --- /dev/null +++ b/docs/research/2026-06-02-retail-render-pipeline-full-reference.md @@ -0,0 +1,810 @@ +# Retail AC — the FULL render pipeline (outdoor + indoor + dungeon + seal), implementable reference + +> **Purpose.** The single, exhaustive, port-ready reference for how the **retail** AC client +> (Sept 2013 EoR build, PDB-named) renders the world: outdoor landscape, building interiors, +> dungeons, portals, the "seal" (drawing the outside through a doorway with no blue hole, +> capped ceilings, no wall-bleed), and object/particle clipping. This is the foundation of the +> Phase W / Phase U unified-render redesign. **The code is modern; the behavior is retail — +> port this faithfully, with no shortcuts.** +> +> **Sources.** Consolidates the four 2026-06-02 decomp studies +> (`-opus48-a`, `-opus48-b`, `-sonnet46`, `-codex`) and the approved design spec +> (`docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md`), +> then **deepens the DRAW/seal pipeline** with the actual pseudo-C read this session from +> `docs/research/named-retail/acclient_2013_pseudo_c.txt` and verbatim structs from +> `docs/research/named-retail/acclient.h`. Citations are `Class::method @ 0xADDR (pc:LINE)` for +> decomp and `acclient.h:LINE` for structs. Membership/transition material (Section A) is +> deliberately summarized — it is fully covered in the four studies and is *not* the render +> redesign's spine; the render pipeline (Sections 2–7) is the new, deep material. + +--- + +## 1. Executive summary — the ONE retail pipeline (≤12 bullets) + +1. **One cell graph, one membership answer, render obeys it.** Physics tracks the player's cell + as `SPHEREPATH::curr_cell` carried *through* the collision sweep; the camera tracks its own + `viewer_cell` via a second (spring-arm) transition. Both resolve through the **same** + `CObjCell::GetVisible(objcell_id)` graph. There is **no** separate render cell system, **no** + static "re-derive the cell from XYZ", and **no** "underground" boolean. + +2. **Top-level decision is binary, per frame** (`SmartBox::RenderNormalMode @ 0x00453aa0`, + pc:92635). `is_player_outside`/viewer-cell test → **outdoor** path = `LScape::draw` (full + terrain+sky+buildings); **EnvCell** path = `RenderDevice::DrawInside(viewer_cell)`. It is + *not* "landscape then inside" at the top level — when inside, **only** `DrawInside` runs. + +3. **`DrawInside` (`PView`) is one portal-flood traversal** (`PView::DrawInside @ 0x005a5860` → + `ConstructView @ 0x005a57b0` → `DrawCells @ 0x005a4840`). It produces an ordered + `cell_draw_list` (visible EnvCells) plus per-cell screen **clip regions** (`portal_view`) and + one accumulated `outside_view` (the outdoors seen through exit portals). + +4. **The landscape is pulled INTO the indoor traversal through exit portals.** A portal with + `other_cell_id == 0xffffffff` is an *exit portal*. `ClipPortals` (`@ 0x005a5520`, pc:433662) + copies its screen clip region into `this->outside_view` (gated by `draw_landscape`). This is + the seam that makes the outside visible through a doorway. + +5. **The seal sequence in `DrawCells`** (pc:432715+), when `outside_view.view_count > 0`: + (a) `LScape::draw(lscape)` with `Render::PortalList = this` → terrain+sky+rain+exterior, + **clipped to the doorway**; (b) a **conditional Z-only clear** `Clear(4, …)` (flag 4 = Z + buffer, NOT color; conditional on `portalsDrawnCount`); (c) per-cell exit-portal **stencil** + via `DrawPortalPolyInternal`; (d) per-cell **`DrawEnvCell`** (the closed interior geometry); + (e) per-cell **`DrawObjCellForDummies`** with `PortalList` set to that cell's view (objects). + +6. **There is no blue clear-color hole, by construction.** The only clear in the indoor path is + `Clear(4, …)` = depth only, and even that is conditional. The doorway shows real terrain/sky + because the landscape was drawn first, clipped to the exit-portal region. + +7. **Ceilings/walls/floors are sealed by the dat geometry.** Each EnvCell's `drawing_bsp` is a + *closed* box (floor + walls + ceiling) with holes only where `CCellPortal`s exist. There is no + "cap the ceiling" step; `DrawCells` draws each visible cell's `drawing_bsp`. Portal openings + are masked, not filled. + +8. **Visibility IS the cull.** Only cells reached by the portal BFS are in `cell_draw_list`; only + their objects (`object_list`, drawn per-cell with `PortalList` set) are drawn. An object/ + particle in a non-visible cell is never iterated → no wall bleed-through. Membership comes from + the *same* physics `enter_cell`/`leave_cell` graph. + +9. **Outside-looking-in is the mirror image, same machinery.** While drawing the landscape, when + the camera can see a building's door, `PView::DrawPortal @ 0x005a5ab0` runs + `ConstructView(CBldPortal, polygon, …) @ 0x005a59a0` — a viewer-vs-portal-plane side test, a + `GetClip`, then it **recurses `ConstructView(interior_cell, other_portal_id)`** and `DrawCells` + the interior through the door's clip region. Outside↔inside is one recursive portal-clipped + traversal over the shared graph. + +10. **Dungeons are emergent, not flagged.** A dungeon is an all-EnvCell landblock (terrain heights + 0, ≥1 EnvCell, no buildings) where every cell has `seen_outside == 0` and no exit portals. So + `outside_view.view_count` stays 0, `LScape::draw` is never reached, and there is no terrain/ + sky — automatically. Same `DrawInside` path as a cottage interior. + +11. **Terrain/sky/landscape state keys off `seen_outside`.** `CellManager::ChangePosition @ + 0x004559b0` keeps the landscape loaded + sunlight/outdoor-ambient live iff the current cell is + a landcell **or** `CObjCell::seen_outside` is set; otherwise it `release_all`s the landscape + and uses flat indoor ambient. `CEnvCell::grab_visible_cells @ 0x0052e220` loads the landscape + iff `seen_outside`. + +12. **BFS convergence is watermark-bounded, not capped.** `ConstructView` uses a per-cell + `portal_view_type.update_count` watermark + a `cell_todo_list` worklist; a cell can be + re-processed only for genuinely new view slices (`update_count → view_count`). This is the + retail replacement for acdream's fixed `MaxReprocessPerCell` cap (issue #102) and the right + fix for dungeon PVS blowup (#95). + +--- + +## 2. Render-decision tree — outdoor / indoor / both + +### 2.1 The single per-frame gate: `SmartBox::RenderNormalMode @ 0x00453aa0` (pc:92635) + +Verbatim control flow (pc:92642-92684), with the decompiler-garbled predicate named: + +```c +if (render_device->m_bOpenScene != 0) { + // edi_2 == SmartBox::is_player_outside(this)-equivalent: viewer is in an OUTDOOR landcell + // ebx_1 == "outside is relevant" = (viewer outside) || (viewer_cell->seen_outside != 0) + if (edi_2 != 0 || this->viewer_cell->seen_outside != 0) ebx_1 = 1; else ebx_1 = 0; + + // FOV / view-distance setup (omitted) ... + + if (edi_2 == 0) { // ── VIEWER IS INSIDE AN ENVCELL ── + if (ebx_1 != 0) { // cell can see outside: + uint eax = Position::get_outside_cell_id(&this->viewer); // pc:92669 + LScape::update_viewpoint(this->lscape, eax); // pre-position terrain + } + Render::update_viewpoint(&this->viewer); + render_device->vtable->DrawInside(render_device, this->viewer_cell); // pc:92675 PView + } else { // ── VIEWER IS OUTSIDE (landcell) ── + LScape::update_viewpoint(this->lscape, this->viewer.objcell_id); // pc:92679 + Render::update_viewpoint(&this->viewer); + Render::set_default_view(); + Render::useSunlightSet(1); + LScape::draw(this->lscape); // pc:92683 full outdoor render + } +} +D3DPolyRender::FlushAlphaList(0); +// ... target bounding-box callback + rendering callback ... +``` + +**Key facts:** + +- **Exactly two branches**, chosen by `edi_2` = "is the viewer cell an outdoor landcell?" When + outside, the *only* draw is `LScape::draw`. When inside, the *only* draw is `DrawInside` — the + landscape, if shown, is drawn **inside** `DrawInside`/`DrawCells` (Section 4), *not* here. +- **`ebx_1` (= outside-relevant)** controls whether the landscape's *viewpoint* is updated before + the indoor draw, so that if a doorway shows terrain it is centered on the right landblock. The + *actual* terrain draw-through-door decision is `outside_view.view_count > 0` inside `DrawCells`. +- The predicate maps to **`SmartBox::is_player_outside @ 0x00451e80` (pc:90996)**: + ```c + int is_player_outside(SmartBox* this) { + if (this->player == 0) return 0; + uint lowWord = (uint16_t)this->player->m_position.objcell_id; // pc:91004 + return lowWord < 0x100; // (decompiler garbles the compare; semantics: outdoor iff < 0x100) + } + ``` + Outdoor landcell ids are `0x0001..0x0040`; EnvCell ids are `>= 0x0100`. This low-word test is + the *type* discriminator used everywhere (`find_cell_list` pc:308753; `GetVisible` pc:308209). + +### 2.2 The decision matrix (port this exactly) + +| Viewer cell | `seen_outside` | Top-level draw | Terrain/sky? | +|---|---|---|---| +| Outdoor landcell (`id&0xFFFF < 0x100`) | n/a | `LScape::draw` (full) | Yes (always). Buildings recurse via `CBldPortal`/`DrawPortal`. | +| EnvCell (cottage/inn interior) | **1** | `DrawInside` | **Only through exit portals** (`outside_view>0` → `LScape::draw` clipped to doorway). | +| EnvCell (dungeon / sealed room) | **0** | `DrawInside` | **No** (no exit portal reachable → `outside_view==0` → `LScape::draw` never called). | + +**There is no "both at once" at the top level.** "Both" happens *only inside* the indoor path: +`DrawCells` draws the landscape (through exit portals) **then** the interior cells, in one pass. + +### 2.3 `RenderDeviceD3D::DrawInside @ 0x0059f0d0` (pc:427843) — thin forwarder + +```c +return PView::DrawInside(RenderDeviceD3D::indoor_pview, arg2); // pc:427847 +``` +The render device owns two persistent `PView` instances: `indoor_pview` (rooted at the viewer +cell) and `outdoor_pview` (used by `DrawPortal` for outside-looking-in, Section 6.2). + +--- + +## 3. PView traversal — step by step + +`PView` ("portal view") is the heart of indoor rendering: a breadth-first (worklist) portal flood +over the shared `CEnvCell` graph that yields one ordered visible-cell list + per-cell clip regions. + +### 3.1 Entry: `PView::DrawInside @ 0x005a5860` (pc:433793) + +```c +Render::object_scale = 1; /* object_scale_vec = (1,1,1) */ +CEnvCell::curr_view_push(viewer_cell); // pc:433800 push a view slot +PView::add_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); // pc:433801 seed PVS from stab list +Frame::cache(&identityFrame); Render::positionPush(3, &identityFrame); +Render::copy_view(viewer_cell->portal_view[num_view-1], nullptr, 4); // pc:433814 seed root view = full screen +ConstructView(this, viewer_cell, 0xffff); // pc:433817 build visible set +PView::DrawCells(this, …); // pc:433819 draw it +Render::framePop(); +PView::remove_views(this, viewer_cell->num_stabs, viewer_cell->stab_list); +viewer_cell->num_view -= 1; // pop the view slot +``` +`add_views`/`remove_views` (`@ 0x005a5210`) push/pop a per-cell `portal_view_type` slot for every +cell in the root's stab list, so the BFS has somewhere to accumulate clip regions for those cells. + +### 3.2 The BFS: `PView::ConstructView(CEnvCell*, ushort) @ 0x005a57b0` (pc:433750) + +Verbatim: +```c +this->outside_view.view_count = 0; // reset the outdoor-through-portal accumulator +PView::master_timestamp += 1; // new traversal stamp (cycle guard) +this->cell_todo_num = 0; this->cell_draw_num = 0; +PView::InitCell(this, root, portalId); // compute root's per-portal in/seen flags + clip +PView::InsCellTodoList(this, root, 0); // push root onto the worklist +while (true) { + if (cell_todo_num <= 0) return; + cell = cell_todo_list[--cell_todo_num]->cell; // POP (LIFO: index num-1) + if (cell == 0) return; + // append to the visible draw list (grow by 0x1e if needed): + cell_draw_list[cell_draw_num++] = cell; // pc:433783 + cell->portal_view[cell->num_view - 1]->cell_view_done = 1; // pc:433784 + if (PView::ClipPortals(this, cell, 0) != 0) // pc:433786 clip this cell's portals + PView::AddViewToPortals(this, cell); // pc:433787 enqueue visible neighbours +} +``` +**Outputs:** `cell_draw_list[0..cell_draw_num]` (visible EnvCells in pop order), each cell's +`portal_view[...]->view` (accumulated screen clip polys), and `this->outside_view` (exit-portal +clip regions). Note it is technically a LIFO worklist (a stack), but converges to the same visible +set; "BFS" is used loosely in the studies. + +### 3.3 `PView::InitCell @ 0x005a4b70` (pc:432896) — per-portal sidedness + clip init + +For a cell's current view slot (`portal_view[num_view-1]`): +- Early-out if `esi[0xe] == 0` (no active view — `update_count`/`view_count` empty). pc:432903. +- `Render::positionPush(3, &cell->pos)` to enter cell-local space. +- For each portal: compute the portal polygon's plane vs the **viewer viewpoint** + (`Render::FrameCurrent->viewer.viewpoint`), set per-portal `seen`/`inflag` + (`portal_info { int seen; int inflag; }`, acclient.h:32459) according to `portal_side` — i.e. + which portals face the viewer and can be seen through. Compute `max_indist` and set + `update_count = view_count` (the watermark — see §5.4). This is "which of my portals are live + for this view slice." + +### 3.4 `PView::AddToCell @ 0x005a4d90` (pc:433050) — incremental view-slice merge + +When a cell is reached **again** through a *different* portal (a second clip region), `AddToCell` +processes only the **new** view slices, from the cell's `update_count` watermark up to its current +`view_count` (pc:433056-433080). This is how a far cell seen through two doorways accumulates two +clip regions without re-walking the slices it already has — the watermark guarantees each slice is +expanded once. + +### 3.5 `PView::ClipPortals @ 0x005a5520` (pc:433572) — clip portals + feed `outside_view` + +Two phases. **Phase 1 (pc:433583-433620):** for each portal with `seen != 0 && inflag != 1`, +resolve `other_cell_ptr` (via `CEnvCell::GetVisible(other_cell_id)`, cached into the portal); set a +local `var_c = 1` if the portal has *any* destination — a loaded neighbour, **or** the exit +sentinel `other_cell_id == 0xffffffff`, or a resolvable neighbour. If `var_c == 0` (no portal goes +anywhere) the function returns 0 → the cell contributes no neighbours. + +**Phase 2 (pc:433622-…):** for each live view slice `i` of this cell, `Render::set_view`, then per +portal compute its screen clip via `GetClip` (`@ 0x005a4320`, projects the portal poly to screen, +runs `polyClipFinish`, honors `Sidedness`). The decisive branch on the portal's destination +(pc:433651-433701): + +```c +GetClip(this, portal.portal_side, portal.portal, &clip_view, &clipNonEmpty, 1); +if (clipNonEmpty != 0) { + if (portal.other_cell_id == 0xffffffff) { // pc:433662 ── EXIT PORTAL ── + if (this->draw_landscape != 0) { // pc:433664 indoor PView built with draw_landscape=1 + if (cliplandscape != 0) + Render::copy_view(this /*->outside_view*/, &clip_view, clipNonEmpty); // pc:433674 + else /* draw_landscape */ + Render::copy_view(this /*->outside_view*/, nullptr, 0); + } + } else if (portal.other_cell_ptr != 0) { // pc:433687 ── INTERIOR PORTAL ── + // OtherPortalClip intersects clip with the neighbour's own portal opening, then + // copies the resulting clip region into the neighbour cell's portal_view (set_view/copy_view) + OtherPortalClip(this, portal, &clip_view, &clipNonEmpty); // pc:433692 + // → records the clipped view onto edi_1 (the neighbour's portal_view at +0x134/+0x138) + } +} +``` +**This is the load-bearing seam.** The exit-portal branch (`0xffffffff`) is the *only* place the +outdoors enters the indoor traversal: it accumulates the doorway's screen region into +`this->outside_view`. The interior branch propagates the clipped region into the neighbour cell so +that cell is later drawn only through the intersection of all portals it was seen through. + +### 3.6 `PView::AddViewToPortals @ 0x005a52d0` (pc:433446) — enqueue neighbours + +For each portal of the cell whose `other_cell_ptr` exists and is active: +```c +neighbour = portal.other_cell_ptr; +if (neighbour not yet inited this traversal) { + InitCell(this, neighbour, portal.other_portal_id); // pc:433480 set up neighbour's view slot + InsCellTodoList(this, neighbour, portal.max_indist); // pc:433485 enqueue it + if (portal.flag >= 0) SetOtherSeen(this, cell, portalIdx); // pc:433490 mark exit-seen / matching portal +} else { + AddToCell(this, neighbour, portal.other_portal_id); // pc:433494 merge a new clip slice +} +``` +`SetOtherSeen @ 0x005a4e30` records the reciprocal portal as seen (prevents re-entering the cell +you just came from with a redundant slice). `InsCellTodoList @ 0x005a4f50` pushes onto the worklist +(growing it as needed). + +### 3.7 The exterior→interior sibling: `ConstructView(CBldPortal*, CPolygon*, int, int) @ 0x005a59a0` (pc:433827) + +Used by `DrawPortal` (Section 6.2) when the camera is **outside** and can see a building door: +```c +// side-test the building portal polygon vs the viewer viewpoint: +d = dot(FrameCurrent->viewer.viewpoint, portal->plane.N) + portal->plane.d; +side = (d < 0.0002) ? NEGATIVE : (|d|other_cell_id); // the room behind the door + if (arg5 != 2) DrawPortalPolyInternal(portal, …); // stencil the doorway + ConstructView(this, interior, bldPortal->other_portal_id); // pc:433879 RECURSE into the interior + } +} +``` +So **the same `ConstructView`** builds the interior visible set whether entered from inside (the +`CEnvCell` overload) or from outside through a building portal (the `CBldPortal` overload). This is +why outside↔inside is seamless. + +--- + +## 4. Seal mechanics — `PView::DrawCells @ 0x005a4840` (pc:432709), the exact sequence + +This is the function the prior studies under-covered. Read this section against the verbatim +pseudo-C; it has **three sequential per-cell loops** plus the landscape-through-door block. + +### 4.1 The landscape-through-door block + conditional Z-clear (pc:432715-432732) + +```c +if (this->outside_view.view_count > 0) { // pc:432715 an exit portal was visible + Render::useSunlightSet(1); + Render::PortalList = this; // pc:432718 tell LScape to clip to outside_view + LScape::draw(this->lscape); // pc:432719 DRAW terrain+sky+rain+exterior, CLIPPED + D3DPolyRender::FlushAlphaList(0); + render_device->m_nFrameStamp += 1; + + // ── CONDITIONAL Z-ONLY CLEAR ── + bool nothingDrawn; + if (forceClear == 0) { + nothingDrawn = (D3DPolyRender::portalsDrawnCount == 0); // pc:432727 + D3DPolyRender::portalsDrawnCount = 0; + } + if (forceClear != 0 || !nothingDrawn) + render_device->vtable->Clear(4, 0x820fc0, 1.0f); // pc:432732 flag 4 == Z-BUFFER ONLY + ... (loops below run inside this `if`) ... +} +``` +**Critical seal facts:** +- **`Clear(4, …)`** — the `4` is the **depth/Z buffer bit only**, NOT color. There is *no* + `Clear(color)` anywhere in this path. The "blue hole" acdream sees is the *absence* of the + `LScape::draw` step (the outdoors is never injected), not a stray blue clear. +- **The clear is conditional** on `portalsDrawnCount` (and `forceClear`). It re-establishes a clean + depth field for the interior cells after the landscape (which is at far depth) was drawn through + the doorway, so interior geometry composites correctly over it. Color is preserved → the terrain + pixels in the doorway survive. +- **`Render::PortalList = this`** before `LScape::draw` is what clips the *entire landscape draw* + to the union of exit-portal screen regions in `outside_view`. Outside the doorway region, the + landscape contributes nothing. +- **`0x820fc0`** is the clear-region/rect parameter (the area to clear), not a color. + +### 4.2 Loop 1 — exit-portal stencil masks (pc:432737-432808) + +Iterates `cell_draw_list` from `cell_draw_num-1` down to 1 (reverse). For each cell whose +`structure->drawing_bsp != 0`: +```c +SetCurrentMaterial(render_device, nullptr, 0); +Render::SetSurfaceArray(cell->surfaces); +Render::object_scale = 1; Render::positionPush(3, &cell->pos); +viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1; // -1 == "one default view" +for (view = 0; view < |viewCount|; view++) { // pc:432772 + CEnvCell::setup_view(cell, view); // pc:432774 select this clip slice + for (j = 0; j < cell->num_portals; j++) { + if (cell->portals[j].other_cell_id == 0xffffffff) // pc:432785 EXIT portal + D3DPolyRender::DrawPortalPolyInternal(cell->portals[j].portal, 0); // pc:432786 STENCIL the opening + } +} +Render::framePop(); +``` +This stencils each exit-portal polygon (per clip slice) so the interior geometry drawn next is +masked to leave the doorway showing the already-drawn landscape. (`setup_view` re-binds the +per-slice clip region so multi-doorway cells are handled correctly.) + +### 4.3 Loop 2 — draw the closed interior geometry (pc:432815-432866) + +Runs **unconditionally** (outside the `outside_view>0` block — i.e., for dungeons too). Reverse +iterate `cell_draw_list`; for each cell with `drawing_bsp != 0`: +```c +SetCurrentMaterial(...); Render::SetSurfaceArray(cell->surfaces); +Render::object_scale = 1; Render::positionPush(3, &cell->pos); +viewCount = (cell->num_view != 0) ? cell->portal_view[num_view-1]->view_count : -1; +for (view = 0; view < |viewCount|; view++) { + CEnvCell::setup_view(cell, view); // pc:432852 select clip slice + render_device->vtable->DrawEnvCell(cell); // pc:432853 DRAW floor+walls+CEILING (drawing_bsp) +} +Render::framePop(); +``` +**`DrawEnvCell` draws the cell's full closed mesh** (floor, four walls, ceiling) from +`structure->drawing_bsp`, back-face-culled and clipped to the cell's accumulated `portal_view` +slices. The ceiling is sealed *because it is part of the cell mesh* — there is no separate cap step. +Portal openings are not filled (Loop 1 stenciled them). + +### 4.4 Loop 3 — per-cell objects/particles, clipped (pc:432870-432882) + +```c +for (cell = cell_draw_num-1; cell >= 0; cell--) { + eax = cell_draw_list[cell]; + // PortalList = the cell's CURRENT portal_view slot (its accumulated clip region): + Render::PortalList = *(eax->portal_view.data[eax->num_view - 1] ... ); // pc:432877 + render_device->vtable->DrawObjCellForDummies(eax); // pc:432878 draw this cell's objects +} +Render::object_scale = 1; Render::useSunlightSet(1); // restore +``` +**`DrawObjCellForDummies(cell)`** draws the objects registered in that cell's `object_list` +(the same list physics `enter_cell`/`leave_cell` maintains), with `Render::PortalList` set to the +cell's clip region — so objects (and their attached particles) are clipped to the portal opening(s) +through which their cell is visible. **An object in a cell not in `cell_draw_list` is never drawn** +→ no bleed-through. An object straddling a portal is clipped to the opening. + +### 4.5 Why the seal holds — the four guarantees + +1. **No blue hole:** outdoors is drawn first (`LScape::draw`, clipped to `outside_view`); the only + clear is Z-only and conditional; color survives in the doorway. +2. **Sealed ceiling/walls:** each visible cell's `drawing_bsp` is a closed box; `DrawEnvCell` draws + it whole; portal holes are stencil-masked, not filled. +3. **No outdoor bleed-in:** the landscape only paints through exit-portal clip regions; if + `outside_view.view_count == 0` (dungeon), `LScape::draw` is never called at all. +4. **No object/particle bleed:** objects are drawn per-cell, clipped to that cell's `PortalList`, + only for cells in `cell_draw_list`. + +--- + +## 5. Data structures (annotated, verbatim from `acclient.h`) + +### 5.1 The cell hierarchy + +```c +// acclient.h:30915 — the base cell. ONE graph for physics AND render. +struct CObjCell : SerializeUsingPackDBObj, CPartCell { + LandDefs::WaterType water_type; + Position pos; // cell-to-world frame + unsigned int num_objects; + DArray object_list; // RENDER + physics: objects in this cell (Loop 3) + unsigned int num_lights; DArray light_list; + unsigned int num_shadow_objects; + DArray shadow_object_list; // PHYSICS: collision objects registered here + unsigned int restriction_obj; + ClipPlaneList **clip_planes; + unsigned int num_stabs; + unsigned int *stab_list; // STATIC visibility set (PVS): cell ids this cell can see + int seen_outside; // boolean: this interior can reach the outdoors ★ + LongNIValHash* voyeur_table; + CLandBlock *myLandBlock_; +}; + +// acclient.h:31880 — adds a building pointer +struct CSortCell : CObjCell { CBuildingObj* building; }; + +// acclient.h:31886 — outdoor surface cell of a landblock (8×8 grid → ids 0x01..0x40) +struct CLandCell : CSortCell { CPolygon** polygons; BoundingType in_view; }; + +// acclient.h:32072 — interior cell (building room OR dungeon room; id >= 0x100) +struct CEnvCell : CObjCell { + unsigned int num_surfaces; CSurface** surfaces; // material/surface array (RENDER) + CCellStruct *structure; // geometry + 3 BSP trees (see 5.3) + CEnvironment *env; + unsigned int num_portals; CCellPortal* portals; // portal graph edges + unsigned int num_static_objects; + IDClass* static_object_ids; Frame* static_object_frames; CPhysicsObj** static_objects; + RGBColor *light_array; int incell_timestamp; + MeshBuffer *constructed_mesh; int use_built_mesh; unsigned int m_current_render_frame_num; + unsigned int num_view; // RENDER: # of active per-portal view slots + DArray portal_view; // RENDER: per-portal clip state (see 5.4) ★ +}; +``` +`seen_outside` is the dat flag `EnvCellFlags.SeenOutside = 0x1` (ACViewer +`ACE.Entity/Enum/EnvCellFlags.cs:7`). **Confirmed verbatim** at acclient.h:30929. + +### 5.2 The portals + +```c +// acclient.h:32300 — interior↔interior (room↔room) AND interior→exterior +struct CCellPortal { + unsigned int other_cell_id; // 0xFFFFFFFF (low 0xFFFF) ⇒ EXIT PORTAL (opens to the outdoors) ★ + CEnvCell *other_cell_ptr; // cached resolved neighbour (or null until GetVisible) + CPolygon *portal; // the portal polygon (its plane = the doorway plane) + int portal_side; // which half-space is "inside" this cell + int other_portal_id; // index of the matching portal in the neighbour + int exact_match; +}; + +// acclient.h:32094 — outdoor landblock → building interior (the door from the street) +struct CBldPortal { + int portal_side; + unsigned int other_cell_id; // the interior EnvCell this building portal leads into + int other_portal_id; + int exact_match; + unsigned int num_stabs; unsigned int* stab_list; // PVS of interior cells visible through this door + float sidedness; +}; +``` +Held by `CBuildingObj : CPhysicsObj { num_portals; CBldPortal** portals; num_leaves; +CPartCell** leaf_cells; ... }` (acclient.h:31908), which a `CLandCell`/`CSortCell` references. + +### 5.3 Per-cell geometry: `CCellStruct` (acclient.h:32275) — THREE BSP trees + +```c +struct CCellStruct { + ... vertex_array; num_portals; CPolygon** portals; surface_strips; polygons; + BSPTree* drawing_bsp; // RENDER — back-face order the closed cell mesh (DrawEnvCell) + ... physics_polygons; + BSPTree* physics_bsp; // PHYSICS — collide the sphere against cell walls/floor + BSPTree* cell_bsp; // CONTAINMENT — point/sphere-in-cell tests (point_in_cell, membership) +}; +``` +A dungeon cell and a building-interior cell use the **same** struct; only their portal topology and +`seen_outside` differ. **The closed mesh (with ceiling) is authored in the dat** — there is no +ceiling-cap code path. + +### 5.4 The render view state — `portal_view_type` (acclient.h:32346) + the watermark + +```c +struct portal_view_type { // one slot per active view of a CEnvCell (CEnvCell.portal_view[]) + DArray portal; // per-portal { int seen; int inflag; } (acclient.h:32459) + view_type view; // the screen-clip geometry (poly+vertex) + float max_indist; + unsigned int view_count; // how many view slices this cell currently has + int cell_view_done; + int view_timestamp; + int update_count; // ★ WATERMARK: slices [update_count..view_count) are "new" +}; +struct view_type { unsigned vertex_count_total; DArray poly; DArray vertex; }; +struct view_poly { int vertex_count; int vertex_index; float xmin, xmax, ymin, ymax; }; // 2D screen clip rect/poly +``` + +**The `update_count` watermark — how it bounds the BFS without a fixed cap (issue #102):** +When a cell is *first* reached, `InitCell` sets `update_count = view_count` (the slices it has). +When the cell is reached *again* through another portal, `AddViewToPortals → AddToCell` only +processes slices from `update_count` up to the (now larger) `view_count` — i.e., the genuinely new +clip regions — then advances `update_count`. A cell therefore expands each distinct view slice +**exactly once**; the traversal terminates when no portal produces a new slice. `master_timestamp` +(bumped per `ConstructView`) + `view_timestamp`/`cell_view_done` are the per-traversal cycle guards. +**This is the retail replacement for acdream's `PortalVisibilityBuilder.MaxReprocessPerCell = 4`** +and the correct fix for dungeon PVS blowup (#95): the watermark naturally converges; a fixed cap +either truncates (missing cells) or never converges (blowup). + +### 5.5 The traversal owner — `PView` (acclient.h:45934) + +```c +struct PView { + portal_view_type outside_view; // ★ accumulated clip region of the outdoors seen thru exit portals + int draw_landscape; // 1 for indoor_pview ⇒ exit portals feed outside_view (ClipPortals) + CBldPortal **outdoor_portal_list; // building doors the camera can see (used by DrawPortal) + DArray cell_draw_list; // ★ ordered visible cells (the BFS output) + unsigned int cell_draw_num; + DArray cell_todo_list; // the worklist + unsigned int cell_todo_num; + LScape *lscape; // the landscape to draw through doorways +}; +``` + +### 5.6 The physics-membership structs (summary — full detail in the studies) + +```c +// acclient.h:32625 — the per-transition working state +struct SPHEREPATH { + ... CObjCell* begin_cell; Position* begin_pos; Position* end_pos; + CObjCell* curr_cell; Position curr_pos; // ★ ACCEPTED membership (the answer) + ... CObjCell* check_cell; Position check_pos; // candidate this sub-step + SPHEREPATH::InsertType insert_type; + CObjCell* backup_cell; ... + int hits_interior_cell; int cell_array_valid; ... +}; +// acclient.h:31574 — the COLLISION candidate set (distinct from curr_cell) +struct CELLARRAY { int added_outside; int do_not_load_cells; unsigned int num_cells; + DArray cells; }; // CELLINFO = { uint cell_id; CObjCell* cell; } +``` + +--- + +## 6. Dungeons, outside-looking-in, object/particle clipping + +### 6.1 Dungeons — emergent, no flag + +- **Data:** a dungeon landblock has terrain heights all 0, ≥1 EnvCell, and **no buildings** (ACE's + `IsDungeon` heuristic, `references/ACE/.../Landblock.cs:575` — a *server* heuristic; the **client + needs no such flag**). Its EnvCells have `seen_outside == 0` and **no exit portals**. +- **Movement:** identical to a building interior — `transition` advances `curr_cell` across + `CCellPortal`s; `find_transit_cells`' exit-portal flag (`var_44`) never fires (no exit portals), + so `add_all_outside_cells` is never called → outdoor cells never enter the candidate set. +- **Loading:** `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878) — adds self + every + `stab_list` cell to the visible table, then `if (seen_outside == 0) return;` (**pc:311893** — + dungeon stops here, never touches the landscape); a `seen_outside` cell tail-calls + `LScape::grab_visible_cells`. **This is the exact load-time decision** "stream the outdoor world + or not." +- **Render:** `DrawInside` runs; `ConstructView` floods the dungeon cells; **no exit portal is ever + reached**, so `outside_view.view_count` stays 0; `DrawCells`' landscape block (§4.1) is skipped; + Loops 2+3 still draw the closed cells + their objects. Result: sealed dungeon, no terrain, no sky + — by construction, same path as a cottage. + +### 6.2 Outside-looking-in — a building interior seen through its door from the street + +Driven by the **outdoor** render (`LScape::draw`), which calls `PView::DrawPortal @ 0x005a5ab0` +(pc:433895) for each building door the camera can see: +```c +Render::m_pRenderer->polyListFinishInternal(); ACRender::backup_curr_state(); +bldPortal = this->outdoor_portal_list[arg2->portal_index]; // the building's door +portal = arg2->portal; // its polygon +PView::add_views(this, bldPortal->num_stabs, bldPortal->stab_list); // seed interior PVS +result = ConstructView(this, bldPortal, portal, arg3, arg4); // pc:433910 build interior visible set thru door +if (result == 0) { // door not actually visible + if (arg4 == 3) DrawPortalPolyInternal(portal, 0); // just stencil the closed door + ACRender::restore_curr_state(); +} else { + if (arg4 != 1) PView::DrawCells(this, …); // pc:433924 DRAW the interior through the door + ACRender::restore_curr_state(); + Render::positionPush(3, CBuildingObj::curr_pos); Render::obj_view_set(); +} +PView::remove_views(this, bldPortal->num_stabs, bldPortal->stab_list); +``` +So the **same `ConstructView` + `DrawCells`** that render an interior from inside also render it +from outside through the door — entered via the `CBldPortal` overload of `ConstructView` +(§3.7) which side-tests the door plane, clips to the opening, and recurses into the room. The +`outdoor_pview` instance is used here (vs `indoor_pview` for `DrawInside`). This is the path +acdream is missing ("outside-looking-in shows no interior" residual, spec §1a). + +### 6.3 Object / particle clipping to the visible cell set + +- **Membership:** objects live in a cell's `object_list` (and collision objects in + `shadow_object_list`), maintained by physics `enter_cell @ 0x00510ed0` / `leave_cell @ + 0x00510f50` — the **same** graph render reads. A particle attached to an object inherits the + object's cell. +- **Draw + clip (indoor):** Loop 3 of `DrawCells` (§4.4) iterates **only** `cell_draw_list`, draws + each cell's `object_list` via `DrawObjCellForDummies`, with `Render::PortalList` set to that + cell's clip region. Non-visible cells' objects are never iterated → no bleed. +- **Child-cell resolution for entities:** `CEnvCell::find_visible_child_cell @ 0x0052dc50` + (pc:311397) — given a point, returns the exact child cell containing it: + ```c + if (point_in_cell(this, p)) return this; // pc:311402 + if (arg3 == 0) { // search via portals: + for (portal in this->portals) { + n = CCellPortal::GetOtherCell(portal); + if (n && n->point_in_cell(p)) return n; // pc:311424 + } + return 0; + } else { // search via stab_list (PVS): + for (id in this->stab_list) { + c = CEnvCell::visible_cell_table[id]; + if (c && c->point_in_cell(p)) return c; // pc:311456 + } + } + ``` + Used for the 3rd-person camera offset (which child cell is the eye in?) and for placing + entities/particles into the right cell **via the graph/BSP, never an AABB**. This is the + retail-faithful replacement for acdream's `CellVisibility.FindCameraCell` AABB + grace-frame + resolver. + +--- + +## 7. PORTING CHECKLIST — the spine of the redesign + +Ordered list of every behavior a faithful C# port must implement to get a fully-sealed +outdoor+indoor+dungeon render with **no bleed, no flaps, no transparent walls, no blue hole**. +Cross-referenced to the Phase W staged plan; each behavior cites its retail anchor. + +### CL-A. Membership foundation (prerequisite — physics owns the cell) +*(Detail in the four studies + spec §1/§1a; summarized here because render roots on it.)* +- [ ] **A1.** `ResolveWithTransition` returns the swept `sp.CurCellId` (mirror + `SetPositionInternal @ 0x00515330` reading `sphere_path.curr_cell`), **not** a static + `ResolveCellId(origin,…)`. Demote `ResolveCellId` to seed-only (spawn/teleport/server-set). +- [ ] **A2.** Replace `FindEnvCollisions`' early static re-derive (`TransitionTypes.cs:1947→1949`) + with a retail directed exit-portal crossing (`CEnvCell::find_transit_cells @ 0x0052c820` + exit-portal path) — become outdoor by crossing the doorway polygon, not by re-resolving XYZ. +- [ ] **A3.** Port the `find_cell_list` interior-wins pick (`@ 0x0052b4e0`, pc:308814-308819) + + `do_not_load_cells` prune (pc:308829-308867) into `CellTransit`; re-gate `FindTransitCellsSphere`'s + unconditional `exitOutside=true` (`CellTransit.cs:95-123`). +- [ ] **A4.** Commit-on-difference: write `CellGraph.CurrCell` once, fire a "cell changed" event + only when it differs (mirror `change_cell @ 0x00513390`, the `if (this->cell != curr_cell)` guard). + +### CL-B. Render root unification (Stage 3) +- [ ] **B1.** Port the **single per-frame decision** (`RenderNormalMode @ 0x00453aa0`): viewer cell + is an outdoor landcell (`id&0xFFFF < 0x100`, `is_player_outside @ 0x00451e80`) → outdoor path; + else → indoor `DrawInside`. Remove the `ACDREAM_A8_INDOOR_BRANCH` two-pipe split. +- [ ] **B2.** Root render visibility at the **physics `CellGraph.CurrCell`** (the U.4c flap fix), + not an independent AABB `FindCameraCell`. Delete the 3-frame grace-frame hack. +- [ ] **B3.** 3rd-person camera offset resolves its child cell via + `CEnvCell::find_visible_child_cell @ 0x0052dc50` (graph/BSP), rooted at the player cell — never + an AABB reclassification. (Eye drives projection; player cell drives the visibility root.) +- [ ] **B4.** Port the **landscape keep/release policy** (`CellManager::ChangePosition @ + 0x004559b0`, pc:94601-94682): keep landscape loaded + sunlight/outdoor-ambient live iff the + current cell is a landcell OR `seen_outside`; else `LScape::release_all` + flat indoor ambient. +- [ ] **B5.** Port `CEnvCell::grab_visible_cells @ 0x0052e220` (pc:311878): add self + `stab_list` + to the visible set; load the landscape **iff `seen_outside`** (the dungeon gate, pc:311893). +- [ ] **B6.** Pre-position the through-door terrain via `Position::get_outside_cell_id @ + 0x004527b0` + `LScape::update_viewpoint` before the indoor draw (when `seen_outside`). + +### CL-C. PView traversal (Stage 4, the big one) +- [ ] **C1.** Port the BFS `PView::ConstructView(CEnvCell*) @ 0x005a57b0`: reset `outside_view`, + bump `master_timestamp`, `InitCell(root)`, push root, then pop-and-expand into `cell_draw_list` + via `ClipPortals` + `AddViewToPortals` until the worklist drains. +- [ ] **C2.** Port `PView::InitCell @ 0x005a4b70`: per-portal sidedness vs the **viewer viewpoint** + → `seen`/`inflag`; compute `max_indist`; set `update_count = view_count`. +- [ ] **C3.** Port `PView::ClipPortals @ 0x005a5520` with **both** branches: exit portal + (`other_cell_id == 0xffffffff`) → `copy_view` into `outside_view` (gated by `draw_landscape`); + interior portal → `OtherPortalClip` → propagate clipped region into the neighbour's `portal_view`. +- [ ] **C4.** Port `PView::AddViewToPortals @ 0x005a52d0`: enqueue uninited neighbours + (`InitCell` + `InsCellTodoList` + `SetOtherSeen`); merge a new slice into already-inited + neighbours (`AddToCell @ 0x005a4d90`). +- [ ] **C5.** Implement the **`update_count` watermark** convergence (per-cell, slices + `[update_count..view_count)` processed once) — **delete the fixed `MaxReprocessPerCell` cap** + (closes #102; correct dungeon-PVS fix for #95). +- [ ] **C6.** Port `PView::GetClip @ 0x005a4320` (project portal poly → screen, `polyClipFinish`, + honor `Sidedness`) producing 2D `view_poly` clip rects/polys. + +### CL-D. Seal mechanics in `DrawCells` (Stage 4) +- [ ] **D1.** When `outside_view.view_count > 0`: set `PortalList = this`, `LScape::draw(lscape)` + (terrain+sky+rain **clipped to the doorway**) FIRST (`DrawCells @ 0x005a4840`, pc:432715-432719). +- [ ] **D2.** Implement the **conditional Z-only clear** (`Clear(4, …)`, pc:432731-432732): depth + bit only, NOT color; conditional on `portalsDrawnCount`/`forceClear`. **Never `Clear(color)` in + the indoor path** — that is the blue-hole bug. +- [ ] **D3.** **Loop 1** — per visible cell, per view slice (`setup_view`), `DrawPortalPolyInternal` + every exit portal (`other_cell_id == 0xffffffff`) to stencil the openings (pc:432785-432786). +- [ ] **D4.** **Loop 2** — per visible cell, per view slice, `DrawEnvCell` the closed + `structure->drawing_bsp` (floor+walls+**ceiling**). Runs for dungeons too (unconditional). No + ceiling-cap step — the mesh is closed (pc:432852-432853). +- [ ] **D5.** **Loop 3** — per visible cell, set `PortalList` to the cell's clip region, then + `DrawObjCellForDummies` (the cell's `object_list`) — objects/particles clipped to the doorway + (pc:432876-432878). +- [ ] **D6.** Renderer self-contained GL state: the indoor pass must SET every GL state it depends + on (view-proj, BLEND, DepthMask, Cull, FrontFace, A2C) — never inherit (memory + `render-self-contained-gl-state`; `EnvCellRenderer` hit this 3× in U.4). + +### CL-E. Outside-looking-in (Stage 4/5) +- [ ] **E1.** Port `PView::DrawPortal @ 0x005a5ab0` on the **outdoor** path: for each visible + building door (`outdoor_portal_list`), `add_views(stab_list)`, + `ConstructView(CBldPortal,polygon)`, then `DrawCells` the interior through the door's clip; if + the door isn't actually visible, just `DrawPortalPolyInternal` (stencil the closed door). +- [ ] **E2.** Port the `ConstructView(CBldPortal*) @ 0x005a59a0` exterior→interior recursion: + side-test the door plane vs the viewer, `GetClip` to the opening, `GetVisible(other_cell_id)`, + optional `DrawPortalPolyInternal`, then **recurse `ConstructView(interior, other_portal_id)`**. +- [ ] **E3.** Use a **separate `outdoor_pview`** instance for E1/E2 (vs `indoor_pview` for + `DrawInside`), matching `RenderDeviceD3D::indoor_pview`/`outdoor_pview_1`. + +### CL-F. Entity / particle cell clipping (Stage 5) +- [ ] **F1.** Entities/particles are placed into cells via the **physics graph** (`object_list`), + resolved with `find_visible_child_cell @ 0x0052dc50` — never an AABB. +- [ ] **F2.** Entities/particles draw **only** for cells in `cell_draw_list`, clipped to the cell's + `PortalList` (Loop 3). Non-visible-cell entities are not drawn → no NPC/door/smoke bleed-through. +- [ ] **F3.** An entity straddling a portal is clipped to the portal opening (inherits the cell's + clip region), not drawn full-screen. + +### CL-G. Conformance / acceptance gates +- [ ] **G1.** Cottage (`seen_outside`): flicker gone (CL-A); interior sealed; sky/rain visible + through the door; **no blue hole**; no transparent walls; no bleed-through. +- [ ] **G2.** Dungeon (`seen_outside == 0` everywhere): sealed; **no terrain/sky**; + `outside_view.view_count == 0`; traversal converges (watermark) without blowup (#95). +- [ ] **G3.** Outside-looking-in: standing in the street facing an open cottage door, the interior + (walls/floor/objects) renders through the doorway (CL-E). +- [ ] **G4.** Headless asserts: doorway `outside_view` non-empty + `LScape::draw` invoked; + sealed-cellar `outside_view` empty + `LScape::draw` NOT invoked; PVS root id == physics + `CurrCell.Id` every frame; a cell receiving two clip slices is processed once per slice. + +--- + +## Appendix — primary decomp address index (all read/verified this session) + +``` +Top-level / landscape policy + SmartBox::RenderNormalMode 0x00453aa0 pc:92635 (binary decision; DrawInside vs LScape::draw) + SmartBox::is_player_outside 0x00451e80 pc:90996 (low-word objcell_id < 0x100) + Position::get_outside_cell_id 0x004527b0 pc:91552 (interior pos → outdoor landcell id) + CellManager::ChangePosition 0x004559b0 pc:94601 (keep/release landscape on seen_outside) + CEnvCell::grab_visible_cells 0x0052e220 pc:311878 (self+stab; landscape iff seen_outside @311893) + RenderDeviceD3D::DrawInside 0x0059f0d0 pc:427843 (→ PView::DrawInside(indoor_pview)) +PView traversal + seal + PView::DrawInside 0x005a5860 pc:433793 + PView::ConstructView(CEnvCell) 0x005a57b0 pc:433750 (the BFS worklist) + PView::ConstructView(CBldPortal) 0x005a59a0 pc:433827 (exterior→interior recursion) + PView::InitCell 0x005a4b70 pc:432896 (per-portal sidedness; update_count=view_count) + PView::AddToCell 0x005a4d90 pc:433050 (incremental new-slice merge) + PView::AddViewToPortals 0x005a52d0 pc:433446 (enqueue neighbours / SetOtherSeen) + PView::ClipPortals 0x005a5520 pc:433572 (exit→outside_view @433662; interior→OtherPortalClip) + PView::OtherPortalClip 0x005a5400 pc:433524 + PView::GetClip 0x005a4320 pc:432344 (portal poly → screen clip) + PView::SetOtherSeen 0x005a4e30 pc:433089 + PView::InsCellTodoList 0x005a4f50 pc:433183 + PView::add_views / remove_views 0x005a5210 pc:433382 + PView::DrawCells 0x005a4840 pc:432709 (LScape-thru-door + Z-clear + 3 loops) + PView::DrawPortal 0x005a5ab0 pc:433895 (outside-looking-in entry) + LScape::draw 0x00506330 (terrain+sky; clipped via Render::PortalList) + CEnvCell::find_visible_child_cell 0x0052dc50 pc:311397 (point → child cell via portals/stab_list) +Membership (summary; see studies) + CTransition::validate_transition 0x0050aa70 pc:272547 + CTransition::check_other_cells 0x0050ae50 pc:272717 + CObjCell::find_cell_list 0x0052b4e0 pc:308742 + CPhysicsObj::SetPositionInternal 0x00515330 pc:283399 + CPhysicsObj::change_cell 0x00513390 pc:281192 + CEnvCell::find_transit_cells 0x0052c820 pc:309968 + CLandCell::add_all_outside_cells 0x00533630 pc:317499 + CEnvCell::check_building_transit 0x0052c5d0 pc:309827 + CObjCell::GetVisible 0x0052ad40 pc:308209 (≥0x100→CEnvCell::GetVisible else CLandCell) + CEnvCell::GetVisible 0x0052dc10 pc:311378 + +Structs (acclient.h) + CObjCell 30915 (object_list 30920; shadow_object_list 30924; num_stabs/stab_list 30927-28; seen_outside 30929) + CEnvCell 32072 (structure 32076; portals 32079; surfaces 32075; num_view/portal_view 32089-90) + CLandCell 31886 | CSortCell 31880 | CBuildingObj 31908 + CCellPortal 32300 | CBldPortal 32094 | CCellStruct 32275 (drawing_bsp/physics_bsp/cell_bsp) + portal_view_type 32346 (view_count, cell_view_done, view_timestamp, update_count) | view_type 32338 + view_poly 32465 | portal_info 32459 (seen, inflag) | PView 45934 (outside_view, draw_landscape, + outdoor_portal_list, cell_draw_list, cell_todo_list, lscape) + SPHEREPATH 32625 | CELLARRAY 31574 | CELLINFO 31925 + +Reference cross-checks + ACE Transition.cs:984 / PhysicsObj.cs:1171 / ObjCell.cs:335 / EnvCell.cs:311 / CellArray.cs:17 + ACE Landblock.cs:575 (IsDungeon, server heuristic — client needs no flag) + ACViewer EnvCellFlags.cs:7 (SeenOutside=0x1); Buffer.cs (brute-force draw, NO PView — divergent) + WorldBuilder PortalService / VisibilityManager.RenderInsideOut (flat stencil — Silk.NET base, NOT the algorithm) +``` + +> **Divergence note (do not copy the references' render model).** ACViewer brute-force draws all +> loaded EnvCells with a `DungeonMode` cull toggle; WorldBuilder uses a flat `RenderInsideOut` +> stencil pass. **Neither implements retail's portal-clipped `PView` / landscape-through-door.** +> For Sections 2–7 the decomp is the sole authority and it wins. WorldBuilder's classes are a +> useful Silk.NET *implementation* base (buffer management, shader plumbing) but the *algorithm* +> is `PView` as documented above. diff --git a/docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md b/docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md new file mode 100644 index 0000000..04f9ce3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-render-pipeline-redesign-plan.md @@ -0,0 +1,169 @@ +# Render Pipeline Redesign — Full Staged Plan (2026-06-02) + +> **Read the master handoff first:** `docs/research/2026-06-02-render-pipeline-redesign-handoff.md` +> (§2 evidence, §3 root cause, §5 retail target + porting checklist CL-A..G). +> Then the 3 research docs. Then **BRAINSTORM** (Phase R0) before any code. + +## The mandate (user, 2026-06-02 — non-negotiable, repeated so it's never lost) +**FULLY WORKING outdoor + indoor + dungeon rendering. No flaps, no missing textures, no +transparent walls, no terrain leaking into cellars, no entity/particle bleed. NO shortcuts, +NO bandaids, NO quick fixes — if the architecturally-correct way is slower, take it. Refactor +or redesign the whole pipeline if that's what it takes. Port from retail. Do more research +mid-session rather than guess. Start with a brainstorm.** + +--- + +## 1. The target architecture (retail-faithful — the ONE model) + +From the retail decomp (handoff §5; doc A). The single inversion that fixes everything: + +> **When the viewer is in an EnvCell, run ONLY `DrawInside` — the one PView portal flood. +> Do NOT draw the outdoor world and then gate it. Visibility IS the cull: only the visible +> cells and their per-cell objects render; the landscape (terrain/sky/rain) is pulled in +> ONLY through clipped exit portals, followed by a conditional depth-only clear.** + +``` +RenderWorld(viewer): + if viewer.cell is outdoor landcell: + DrawOutside() # LScape: terrain + scenery + sky + weather (today's outdoor path) + # buildings seen from outside render their interiors via DrawPortal (R5, outside-looking-in) + else: # viewer in an EnvCell + DrawInside(viewer.cell): # ONE flood — nothing else + frame = ConstructView(cell) # PView BFS → cell_draw_list + per-cell clip + outside_view + if frame.outside_view nonEmpty: + DrawLScapeClippedTo(frame.outside_view) # terrain/sky/rain through the doorway + ConditionalDepthOnlyClear(frame.outside_view) # Z only — never color → no blue hole + for cell in frame.cell_draw_list (closest-first): + DrawCellShell(cell, clip=frame.clip[cell]) # closed geometry: floor+walls+ceiling + DrawCellObjects(cell, clip=frame.clip[cell]) # ONLY this cell's objects (statics/entities) + DrawCellParticles(cell, clip=frame.clip[cell]) # ONLY this cell's particles +``` + +**Components to KEEP (research says these are correct):** `PortalVisibilityBuilder` (the BFS), +`ClipFrame`/`ClipFrameAssembler`/`ClipPlaneSet`/`PortalView` (the clip machinery), `EnvCellRenderer` +mesh/geometry path, `TerrainModernRenderer`, the WB mesh pipeline, the membership fix (`59f3a13`), +the Stage-4 sky NDC-clip + doorway Z-clear, the diagnostic probes. + +**The work is RESTRUCTURING THE ORCHESTRATION + the entity/particle draw, not a from-scratch +rewrite.** No stencil two-pipe, no `isInside` gate, no AABB grace-frame, no WB `RenderInsideOut` +(handoff §6/§9). + +--- + +## 2. The phases (each is visually-verifiable; retail-anchored; no bandaids) + +> Sizing note: this is a multi-session arc (M1.5 "indoor world feels right"). Each phase ends +> at a **user visual gate** (the only acceptance that counts for a render seal — handoff §9 +> lesson). Do NOT batch phases past a gate. The phases are ordered so each is independently +> testable and the bleed is killed early. + +### Phase R0 — Brainstorm + lock the design (FIRST, with the user) +- `superpowers:brainstorming` the §1 architecture with the user. Confirm the single-flood model, + the per-cell object draw, the keep/redesign split. Resolve the open questions in §3. +- Write the detailed per-phase spec (`docs/superpowers/specs/2026-06-…-render-redesign-design.md`). +- **No code.** Gate: design approved. + +### Phase R1 — One visibility authority + kill the outdoor-scenery bleed +**Retail anchor:** CL-B (render-root unification), CL-F (visibility is the cull). Fact 8 (§5.1). +- Make `PortalVisibilityBuilder.Build`'s `OrderedVisibleCells` / `CellViews` the **single** + visibility answer. Route the **entity** dispatch off it (the same `pvFrame` the shells/terrain + use) instead of the parallel `CellVisibility.ComputeVisibilityFromRoot` BFS. +- **Delete the `ParentCellId==null → return true` bypass** at `WbDrawDispatcher.cs:1756`. Outdoor + scenery (houses/trees/stabs) is gated to `OutdoorVisible` (drawn only when an exit portal makes + the outdoors visible, and then clipped to it). This is the unification, not a special-case patch. +- Decommission the duplicate `CellVisibility` entity path (or make it return the identical set). +- **Gate (visual):** standing in the cellar, **no houses/trees/outdoor stabs are visible.** The + `[shell]`/`[vis]`/entity probes confirm one visibility set drives all geometry. + +### Phase R2 — The binary render decision: indoor = `DrawInside` only +**Retail anchor:** CL-B1 (single decision), `RenderNormalMode @ 0x453aa0`, fact 2 (§5.1). +- Restructure `GameWindow.OnRender` so that when `CellGraph.CurrCell` is an EnvCell, the **full + outdoor draw is NOT issued** — only the indoor flood runs. The outdoor terrain/scenery/sky are + reachable only via the exit-portal path (R3). When outdoors, the existing outdoor path runs. +- Remove the "draw outdoor world, then draw cell shells on top, gated" structure. The render root + is the physics `CurrCell` (already wired, Stage 3); the *consequence* (only-DrawInside-when-inside) + is the new part. +- **Gate (visual):** indoors you no longer see the full-screen outdoor "world background"; you see + the interior (walls/floor/ceiling) and, through the door, the outside (still rough until R3). + +### Phase R3 — The seal mechanics (`DrawCells` faithful port) — THE seal +**Retail anchor:** CL-D, `PView::DrawCells @ 0x5a4840` (the three-loop seal sequence), fact 5–7. +- In the indoor flood, when `outside_view` is non-empty: draw `LScape` (terrain/sky/rain) **clipped + to the doorway** (reuse the Stage-4 sky NDC-clip + the terrain OutsideView clip) → **conditional + depth-only clear** (the Stage-4 Z-clear) scissored to the doorway → then the cells. +- Verify cell shells are **closed** (floor + walls + ceiling from `drawing_bsp`); the `[shell]` + evidence shows geometry is present — confirm the floor face is in the mesh and faces correctly. +- Resolve the terrain *model*: terrain is drawn ONLY through the exit-portal clip, never as a floor + under the interior. Remove the `TerrainClipMode.Skip`-as-floor-removal confusion. +- Self-contained GL state per draw (memory `render-self-contained-gl-state`). +- **Gate (visual):** the cottage interior is **fully sealed** — opaque walls, solid floor, ceiling, + **sky + rain visible through the door only** (no blue hole, no full-screen), no terrain under the + floor, no grey-floor. The cellar is sealed. + +### Phase R4 — Per-cell object + particle clipping (no bleed) +**Retail anchor:** CL-F, fact 8; `find_visible_child_cell @ 0x52dc50`; #104. +- Make the object draw **per visible cell** (iterate `cell_draw_list`, draw each cell's objects + clipped to that cell's region) instead of one global entity pass. Entities straddling a portal + are portal-clipped. +- Give particles a cell (`OwnerCellId` from the owning entity's `ParentCellId`) and clip them to + the visible set (#104). +- **Gate (visual):** no NPC / door / smoke bleed through walls; an NPC just outside the door is + visible through the doorway but not through the wall. + +### Phase R5 — Outside-looking-in (U.5) +**Retail anchor:** CL-E, `DrawPortal @ 0x5a5ab0`, `ConstructView(CBldPortal) @ 0x5a59a0`, fact 9. +- When outdoors and a building's door/window is in view, render the building's **interior through + that portal's clip** (the mirror of DrawInside, entered from the outdoor cell). Same machinery. +- **Gate (visual):** looking through the cottage door/window from outside shows the **sealed + interior**, not transparent walls. + +### Phase R6 — Dungeons +**Retail anchor:** fact 10 (emergent), the `update_count` watermark (fact 12 — #95), CL-C5. +- Validate the all-EnvCell / `seen_outside==0` / no-exit-portal path on a real dungeon: no terrain, + no sky, sealed cells, BFS convergence (the watermark bounds the reprocesses — confirm #95 closed). +- **Gate (visual):** a real dungeon is sealed, no terrain/sky, no FPS collapse from the BFS. + +### Phase R7 — Polish + conformance +- Resolve the `CullMode.Landblock→None` double-sided stopgap (the actual winding). +- Textures: confirm no missing textures anywhere (the `[shell]` zh=0 evidence says good; verify + across dungeons). +- Conformance tests (headless): the PView frame product, the seal asserts (handoff §8 apparatus). +- Update roadmap; flip the M1.5 milestone; memory notes. +- **Final gate (visual):** cottage + dungeon + outside-looking-in, all sealed and seamless. + +--- + +## 3. Open questions for the R0 brainstorm +- **Outdoor scenery while a door is open:** when indoors looking out, the visible outdoor scenery + (the cottage across the street) must draw — but clipped to the doorway. Does that come for free + from "LScape through the exit portal" (terrain + scenery both), or does scenery need its own + exit-portal-clipped pass? (Retail draws LScape — which includes scenery — through the clip.) +- **Building shells from outdoors:** when outdoors, the cottage's *exterior* shell must draw (it's a + building). Is that an EnvCell shell drawn from the outdoor root, or part of the outdoor scenery? + Reconcile with R5 (outside-looking-in) so the exterior + the door-interior compose correctly. +- **The `EnvCellRenderer` filter vs per-cell-clip:** today shells use one `Render(filter)` with + per-cell clip slots. R4 wants per-cell object draw. Confirm the EnvCellRenderer + WbDrawDispatcher + can both be driven per-cell from `cell_draw_list` without a global pass. +- **Two cameras (eye vs player cell):** the U.4c flap fix roots visibility at the player cell while + projecting from the eye. Confirm the redesign preserves that (the eye can be outside the player + cell in 3rd person) — `find_visible_child_cell` for the camera child. +- **Scope of the entity-draw restructure:** is per-cell object draw a refactor of `WbDrawDispatcher` + or a new dispatch path? (Per CLAUDE.md: don't break the working mesh pipeline; restructure the + orchestration around it.) + +## 4. Risks +- **Big restructure of the render loop** — do it behind the visual gates, one phase at a time; + keep the outdoor path working throughout (it's the 99% case). +- **Per-cell object draw vs MDI batching** — the modern dispatcher batches across entities; a naive + per-cell loop could regress perf. Design the per-cell clip to preserve batching (the clip-slot SSBO + already supports per-instance clip; the cull is the membership, not a per-cell draw call necessarily). +- **Don't reintroduce the abandoned approaches** (handoff §9): no stencil two-pipe, no isInside gate, + no AABB grace-frame. +- **Get the user's eyes early** — every phase ends at a visual gate; never declare a seal off tests. + +## 5. The no-shortcuts rules (enforce on every task) +1. If a task tempts a fast-but-wrong path, take the retail-faithful path; note the tradeoff in the commit. +2. No suppression flags, grace periods, or "if (problem) return early" guards at a symptom site. +3. Every AC-specific behavior cites a retail decomp anchor (address + pseudo-C line). +4. Mid-session research over guessing — if the retail behavior is unclear, read the decomp / attach cdb. +5. Each phase ends GREEN (build + tests) AND at a user visual gate. The seal is verified on screen.