# Verbatim Retail Indoor Render Port (`DrawInside` / `DrawCells`) — Design — 2026-06-06 > **Why this exists.** Two weeks of patching the indoor renderer have not produced > retail's seamless inside↔outside↔inside behavior. The interior **walls/floor render > grey** (the clear color shows through) and geometry **bleeds** between cells. Every > attempt kept an *approximation layer* over retail's membership logic and patched its > symptoms. This spec stops that: it ports retail's `DrawCells` **verbatim at the > algorithm level** and **deletes the approximation layer** that keeps reintroducing the > bug. Scope agreed with the user: **A + B** (indoor seal + look-out, plus look-in from > outside). The outdoor `LScape` branch is out of scope — it already works. > **Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`, > HEAD `8116d10`. PowerShell on Windows; launch logs are UTF-16. Do NOT branch/worktree, > push, or `git stash`/`gc`. --- ## 1. The problem, located in the code The indoor draw lives in `RetailPViewRenderer.DrawInside` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`). Its loop *structure* already mirrors retail (landscape → exit masks → shells → objects, reverse `OrderedVisibleCells`, per-cell, per-slice). **Two things defeat it:** 1. **Dropped shells → grey.** `RetailPViewRenderer.cs:52` `drawableCells = clipAssembly.CellIdToSlot.Keys`, and every loop does `if (!drawableCells.Contains(cellId)) continue;`. A visible cell is drawn **only if `ClipFrameAssembler` assigned it a clip-slot.** Any cell whose view did not yield a slot is silently skipped → its sealed shell is never drawn → the clear color shows → **grey**. Retail draws **every** cell in `cell_draw_list`. 2. **No trim → bleed (and the half-character).** `RetailPViewRenderer.cs:237` `UseIndoorMembershipOnlyRouting()` sets `_envCells.SetClipRouting(null)` — the per-cell trim was **globally disabled** as an emergency fix for "characters/shells sliced at stair/door boundaries." So cells that *do* draw are not trimmed to the opening they're seen through → geometry bleeds; and the reason it had to be disabled is the clip was being applied to **objects/characters** (which retail never hard-clips), slicing them. Both failure modes come from the `ClipFrameAssembler` slot-pool + `drawableCells` filter sitting on top of the (faithful) membership. **That layer is the "bad code."** ## 2. What retail does (the oracle) From the named decomp (Sept 2013 EoR). `SmartBox::RenderNormalMode` (0x453aa0) branches on `is_player_outside`: outside → `LScape::draw`; inside → `DrawInside(viewer_cell)`. `PView::DrawInside` (0x5a5860) seeds the root cell's view to the full screen, then runs two phases: - **`ConstructView` (0x5a57b0)** — build membership. Distance-priority flood from the root; each popped cell is **appended to `cell_draw_list`** (once); `ClipPortals` clips its portals against its view; `AddViewToPortals` propagates the clipped openings to neighbours and enqueues new ones. `cell_draw_list` is the **single** membership source. - **`DrawCells` (0x5a4840)** — draw, three loops over **reverse** `cell_draw_list` (far→near): - **Landscape:** if `outside_view.view_count > 0` → `LScape::draw` clipped to `outside_view`, then `Clear(DEPTH)`. - **Loop 1 — exit-portal masks:** per cell, per `portal_view` slice: `setup_view(cell, slice)`; for each exit portal `DrawPortalPolyInternal` (depth mask for the opening). - **Loop 2 — shells:** per cell, per slice: `setup_view(cell, slice)`; `DrawEnvCell(cell)` — the **closed cell mesh** (walls/floor/ceiling), hard-clipped to the slice. - **Loop 3 — objects:** per cell: `Render::PortalList = cell.portal_view[last]`; `DrawObjCellForDummies(cell)` — the cell's objects, **visibility-gated by the portal view, not hard-clipped** (so whole creatures are never sliced). The trim mechanism is `setup_view` + `polyClipFinish` (0x6b6d00): clip geometry to the slice's **convex screen region** (CPU, every frame). The two facts that make retail seamless: **(i)** every `cell_draw_list` cell gets its closed shell drawn (it seals); **(ii)** shells are hard-clipped per-slice, objects are only visibility-gated. ## 3. Goal & success criteria - Standing **anywhere inside** (room, cellar, on stairs), the interior is **sealed**: no grey, no see-through walls, cellar floor + stairs present, character whole. - **Seamless transitions:** room↔room, room↔cellar, outside→inside (walk in), and **look-in** from outside through an open door (B). - **Look-out:** windows/doors show the outdoor world through the opening. - The draw is a **literal translation** of `DrawCells` (every visible cell's shell, per-slice trim on shells only, objects visibility-gated), with the slot-pool/filter layer **deleted**. - Acceptance is **visual** (the user's eyes) — pure logic is unit-tested, the draw is GPU. Non-goals: rewriting the outdoor `LScape` branch; fixing any residual texture-pipeline issue that survives a correct seal (would be a separate, evidence-led follow-up). ## 4. Architecture The pipeline is one binary branch (retail `RenderNormalMode`), already in place at `GameWindow.cs:7343`: `ShouldRenderIndoor` → indoor `DrawInside` vs outdoor `LScape` + look-in `DrawPortal`. This spec rewrites the **indoor draw body** and the **look-in body**; it does not change the branch or the outdoor body. ### 4.1 Components | Unit | Role | Change | |---|---|---| | `PortalVisibilityBuilder` → `PortalVisibilityFrame.OrderedVisibleCells` + per-cell `CellView` | retail `ConstructView` / `cell_draw_list` + `portal_view` | **KEEP** (faithful, unit-tested) | | `PortalProjection.ProjectToClip` / `ClipToRegion` | retail `GetClip` / `polyClipFinish` (homogeneous) | **KEEP** (ported this session) | | `ClipPlaneSet.From(CellView)` | NDC convex region → ≤8 `gl_ClipDistance` planes / scissor AABB / nothing-visible | **KEEP**, call **per slice** | | `EnvCellRenderer.Render(pass, {cellId})` | draw one cell's closed shell | **KEEP**; drive per-cell, per-slice | | `WbDrawDispatcher.Draw(...)` | draw entity meshes | **KEEP**; drive per-cell, **no clip** | | `ClipFrame` | upload clip region to the shader (SSBO) + terrain clip UBO | **SIMPLIFY** to one region (the current slice) | | `RetailPViewRenderer.DrawInside` / `DrawPortal` | the indoor / look-in orchestration | **REWRITE** to the verbatim `DrawCells` loop | | `ClipFrameAssembler` (+ `ClipFrameAssemblerTests`) | the slot-pool that produces `CellIdToSlot` / `ClipViewSlice[]` | **DELETE** | | `drawableCells` filter | "draw only cells with a slot" | **DELETE** (draw all `OrderedVisibleCells`) | | `UseIndoorMembershipOnlyRouting` / clip-off compromise | globally disables the trim | **DELETE** | | `InteriorEntityPartition` | bucket entities by cell | **KEEP** as the cell→objects map (not as an eligibility filter); call with **all** visible cells | | `InteriorRenderer` | outdoor entity-bucket wrapper | **KEEP** (outdoor path) — re-evaluate if it becomes dead | ### 4.2 The new `DrawCells` loop (verbatim translation) `RetailPViewRenderer.DrawInside(viewerCell)` becomes, in pseudocode: ``` frame = PortalVisibilityBuilder.Build(viewerCell, eye, lookup, viewProj) // cell_draw_list + per-cell CellView cells = frame.OrderedVisibleCells // NO drawableCells filter objectsByCell = InteriorEntityPartition.Partition(cells, landblockEntries).ByCell // --- Landscape through outside_view (look-out) --- if frame.OutsideView not empty: for slice in frame.OutsideView.Polygons: setSliceClip(slice) // ClipPlaneSet.From(slice-as-CellView) drawLandscapeSlice(slice) // GameWindow callback (terrain/sky/scenery clipped to slice) clearDepth(outsideView bounds) // --- Loop 1: exit-portal depth masks (only needed with look-out) --- for cell in reverse(cells) where cell.drawing_bsp: for slice in cell.CellView.Polygons: setSliceClip(slice); drawExitPortalMasks(cell) // depth-only, punches the openings // --- Loop 2: SHELLS (the seal) --- for cell in reverse(cells) where cell.drawing_bsp: for slice in cell.CellView.Polygons: planes = ClipPlaneSet.From(singlePolygonRegion(slice)) // <=8 planes, or scissor, or nothing if planes.IsNothingVisible: continue applyShellClip(planes) // gl_ClipDistance (or scissor) EnvCellRenderer.Render(Opaque, {cell}); Render(Transparent, {cell}) clearShellClip() // --- Loop 3: OBJECTS (no hard clip) --- for cell in reverse(cells): if objectsByCell[cell] empty: continue WbDrawDispatcher.Draw(objectsByCell[cell], frustum, animatedIds) // depth + frustum + membership; NO clip drawCellParticles(cell) ``` Two differences from retail are intentional GL adaptations, both faithful in result: - **Trim is `gl_ClipDistance` (set per slice), not CPU `polyClipFinish`.** Same convex-region clip; `ClipPlaneSet.From` already produces the planes. A slice that exceeds 8 edges degrades to its scissor AABB (over-includes a sliver, never drops geometry). - **Objects are membership-gated, not hard-clipped.** Retail visibility-tests objects against `PortalList`; we draw the cell's objects (depth + frustum) without clip planes — this is what prevents the half-character. (A per-object portal-view visibility test is a possible future refinement if objects visibly poke past a doorway; the cell shells + depth occlude most cases.) ### 4.3 Look-in (B) — `DrawPortal` Identical loop, seeded by `PortalVisibilityBuilder.BuildFromExterior` (exterior-facing portals) instead of the root cell. It reuses Loops 1–3 unchanged; there is no second draw engine. Runs in the outdoor branch after `LScape`, before scene particles, exactly where it is wired today (`GameWindow.cs:7552`). ### 4.4 Clip application detail `setSliceClip` / `applyShellClip` turn one `ClipPlaneSet` into GPU state: - `Count > 0` → upload the ≤8 planes (one region, slot 0) and enable that many `gl_ClipDistance` outputs; the existing mesh/terrain shaders already read a clip region and write `gl_ClipDistance`, so the shader side is unchanged — only the *feed* shrinks from a slot pool to one region. - `UseScissorFallback` → `glScissor` to `ScissorNdcAabb` (mapped to pixels), no clip planes. - `IsNothingVisible` → draw nothing for that slice. `clearShellClip` disables all `gl_ClipDistance` + scissor so Loop 3 (objects) and downstream passes are unclipped. ## 5. Data flow (per indoor frame) ``` ShouldRenderIndoor(player) == true → RetailPViewRenderer.DrawInside(viewerCell, eye, viewProj, callbacks) Build → cells + per-cell CellView + OutsideView InteriorEntityPartition → objectsByCell look-out: per OutsideView slice → setSliceClip → DrawLandscapeSlice (GameWindow GL callback) → clearDepth Loop1 exit masks (reverse cells, per slice) Loop2 shells (reverse cells, per slice, clip ON) → EnvCellRenderer.Render({cell}) Loop3 objects (reverse cells, clip OFF) → WbDrawDispatcher.Draw + DrawCellParticles ``` GameWindow keeps providing the GL-bound callbacks it already passes today (`DrawLandscapeSlice`, `ClearDepthSlice`, `DrawCellParticles`, `EmitDiagnostics`); only their *orchestration* inside `RetailPViewRenderer` changes. ## 6. Error handling / edge cases - **Empty `CellView` for a visible cell** (`ClipPlaneSet.IsNothingVisible`): skip that slice's draw, but the cell may still draw via its other slices. (A cell with *no* non-empty slice is effectively not visible — consistent with retail, where it would not be in `cell_draw_list`.) - **Slice > 8 edges:** scissor-AABB fallback (over-include, never drop). Expected to be rare (a single doorway opening is ~4–6 edges). - **Eye standing in a portal / behind-eye portal:** handled upstream by the faithful `ProjectToClip` (eye-plane clip) + the existing `EyeInsidePortalOpening` flood gate in the builder — unchanged by this spec. - **No exit portal:** `OutsideView` empty → no landscape/look-out, no depth-clear; interior still fully sealed by Loop 2. ## 7. Testing - **Unit (already green, must stay):** `PortalVisibilityBuilderTests` (membership/cell list), `PortalProjectionTests` (clip math), `ClipPlaneSet` behavior. App suite baseline 205/205. - **New unit:** the `DrawCells` orchestration is GL-bound, so extract the pure decision — *"which (cell, slice) pairs are drawn, in what order"* — into a testable function over a `PortalVisibilityFrame`, and assert: (a) **every** `OrderedVisibleCells` cell with a non-empty view appears in the shell pass (regression test for the grey: no cell is dropped); (b) reverse (far→near) order; (c) objects pass has no clip state. This pins the two bugs from §1 as tests. - **Integration:** visual, with light `[shell]`/`[vis]` probes confirming `draw=[…]` equals the visible-cell set (no cell dropped) at the cottage + cellar. **Acceptance is the user's eyes** on a sealed cottage + cellar with seamless transitions. ## 8. Risks & mitigations - **Per-slice shell clip re-slices shells at boundaries** (the symptom that caused the emergency clip-off). Mitigation: the slices now come from the *faithful* `ClipToRegion` (not the old degenerate projection), and **only shells are clipped** (objects never are). If a shell still gaps, that is a too-small slice — a visible, localized clip-math case to fix, **not** a return to dropping cells. - **Texture-pipeline grey could survive a correct seal.** HEAD's commit notes "interior walls grey." If, after every visible cell's shell draws, walls are still untextured (vs. clear-color grey), that is a *separate* surface/texture bug (out of scope here) — but the seal must be correct first to even tell the two apart. - **Per-cell draw-call count.** Indoor frames have a handful of visible cells × a few slices → tens of draws, not thousands. Acceptable; matches retail's per-cell-per-slice cadence. ## 9. File-level change list - **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (the `DrawCells` loop; delete `drawableCells`, `UseIndoorMembershipOnlyRouting`, slot routing). - **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` + `tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`; the `ClipViewSlice`/slot types if unused after the rewrite. - **Simplify:** `src/AcDream.App/Rendering/ClipFrame.cs` (one region per slice, no slot pool); `src/AcDream.App/Rendering/ClipPlaneSet.cs` stays as-is (already does the per-slice math); `EnvCellRenderer` / `WbDrawDispatcher` clip-routing API trimmed to "set one region / clear". - **Keep, re-purpose:** `InteriorEntityPartition` (cell→objects map for all visible cells). - **Light touch:** `GameWindow.cs` indoor/look-in call sites (callbacks unchanged; remove references to deleted types). - **Untouched:** `PortalVisibilityBuilder`, `PortalProjection`, the outdoor `LScape` branch, `SkyRenderer`, `TerrainModernRenderer`, `ParticleRenderer`. ## 10. Decomp references - `SmartBox::RenderNormalMode` 0x453aa0 — the `is_player_outside` branch. - `PView::DrawInside` 0x5a5860 — seed full-screen view, `ConstructView`, `DrawCells`. - `PView::ConstructView` 0x5a57b0 — `cell_draw_list` build (= `OrderedVisibleCells`). - `PView::DrawCells` 0x5a4840 — the three loops (this spec's §4.2). - `CEnvCell::setup_view` / `ACRender::polyClipFinish` 0x6b6d00 — per-slice convex clip (= `ClipPlaneSet` + `gl_ClipDistance`). - `RenderDeviceD3D::DrawObjCellForDummies` 0x5a0760 — objects gated by `PortalList`, not hard-clipped. - `PView::GetClip` 0x5a4320 / `PrimD3DRender::xformStart` 0x59b990 — homogeneous projection (= `ProjectToClip`). ## 11. Prior art / context - `docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md` — the grey = shell-sealing / flood-root, **not** the projection; "draw every visible cell's shell." - `docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md` — the attempt history (the slot-pool/filter layer this spec deletes). - `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` — the `DrawCells` model. - This session: `PortalProjection.ProjectToClip`/`ClipToRegion` (homogeneous `GetClip` port) + `PortalVisibilityBuilder` made faithful — the membership + clip-math this draw builds on.