# Phase A8.F — Retail portal-frame visibility port (design) **Date:** 2026-05-29 **Phase:** A8.F — *Portal-frame visibility*. The faithful retail-`PView` recursive portal-clip port that completes A8's indoor-visibility goal by fixing the residual **cellar flap** — the last A8 indoor-rendering defect. **Status:** Design approved 2026-05-29. Ready for `superpowers:writing-plans`. **Branch:** `claude/strange-albattani-3fc83c` (worktree) **Builds on (does NOT supersede):** [2026-05-26-phase-a8-wb-full-port-design.md](2026-05-26-phase-a8-wb-full-port-design.md) — the A8 WB full port (per-building cell scoping + stencil pipeline + EntitySet partition + RenderOutsideIn). That work is **already committed** (`5dc4140`, landed after the 2026-05-28 handoff was written); Step 0 below only strips its leftover diagnostic flags. **Required predecessor reading:** - [docs/research/2026-05-28-a8-cellar-flap-option2-handoff.md](../../research/2026-05-28-a8-cellar-flap-option2-handoff.md) — pickup handoff (note: its "option 2 = WB-faithful recursive" premise is corrected here). - [docs/research/2026-05-28-a8-cellar-flap-root-cause.md](../../research/2026-05-28-a8-cellar-flap-root-cause.md) — the offline root-cause evidence. - Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` — the `PView` functions in the anchors table below. **This is the only oracle** (WB and ACViewer do not implement this; verified 2026-05-29). --- ## TL;DR The cellar flap (outdoor terrain leaking through the ground-floor windows when the camera is in a cottage cellar) is **inherent to WorldBuilder's flat-stencil indoor algorithm and cannot be fixed by porting WB more faithfully.** Verified against WB source: `VisibilityManager.RenderInsideOut` marks *all* of the camera building's exit portals into one stencil bit and relies on depth occlusion — there is no per-portal clipping and no recursion anywhere in WB's visibility path. `EnvCellRenderManager.Render` is a flat filtered draw. ACViewer has no portal clipping at all. The recursion the handoff reached for lives in **retail's `PView`**, not WB. Retail keeps a screen-space **visible region** (a set of 2D convex polygons) and walks the portal graph from the camera cell; at each portal it projects the opening to screen and **intersects** it with the region the current cell inherited. Exit portals union their clipped region into a special **`outside_view`** — "where outdoors may draw, clipped to every portal opening in the chain." From a cellar, the ground-floor windows are reached only through the narrow stairwell, so their `outside_view` contribution is `window ∩ stairwell` = a sliver, not the full window. **That sliver is the fix**, and the same logic is correct for stacked floors and deep dungeons. We port retail's builder faithfully as **pure CPU math** (projection + 2D convex-polygon intersection + a BFS), and map its *enforcement* onto the existing A8 **stencil** pipeline (a literal port is impossible — retail enforces the clip during software rasterization; acdream is a GPU pipeline). All three wire-ins land in one pass before a single visual gate. --- ## Background: why this is needed and what it replaces ### The A8 WB port fixed the 16-cells problem, not the flap The A8 WB full port (2026-05-26) correctly scoped indoor rendering to the camera building's cells (fixing the "16 cottage cells at full screen extent" Z-fight / #78) and stencil-gated outdoor visibility through exit portals. Its own design doc attributed "per-portal recursive culling" to WB — **that attribution is imprecise.** WB's mechanism is per-building *cell scoping* (which cells render) plus a *flat* exit-portal stencil; it has no per-portal clip region. The cellar flap is the residual that this flat stencil cannot express, because it marks the ground-floor windows identically whether you are standing in the ground-floor room or two portals down in the cellar. ### What A8.F adds A8.F adds the missing mechanism — retail's per-portal **clip frames** — on top of the A8 infrastructure. It changes the *source* of the indoor stencil mask from "all exit portals, flat" to "exit portals recursively clipped to their portal chain," and folds in the Job-A/Job-B decoupling (below). It reuses the A8 stencil GL plumbing, far-depth punch, `EnvCellRenderer`, terrain dispatcher, EntitySet partitions, and the Step-5 occlusion-query helpers. It does **not** revert A8. ### The Job-A/Job-B coupling (a pure port bug, fixed here) acdream's current `RenderInsideOutAcdream` wraps the terrain/scenery/shell *draws* inside `if (didInsideStencil)` and redefines `didInsideStencil` as "stencil mask non-empty." WB draws exterior geometry **unconditionally** and gates only the stencil *state* (`didInsideStencil` = "inside a building"). The acdream form couples "is any portal visible?" to "draw exterior at all?", which is what made the earlier targeted fix regress (empty mask → no exterior drawn). A8.F restores WB's structure as part of the rewrite — this is strictly *more* faithful to WB, not a deviation. --- ## Decision record (brainstorm outcomes, 2026-05-29) | # | Question | Decision | |---|---|---| | Q1 | Which fix family? | **A — port retail's `PView` clip-frame recursion** (faithful-to-retail). Faithful-to-WB is off the table because WB itself flaps. | | Q2 | Staging of the three wire-ins? | **All three in one pass** before the first visual gate (full faithful port). | | Q3 | Baseline before A8.F? | A8 batch is **already committed** (`5dc4140`, landed after the handoff). Step 0 reduces to stripping the leftover `ACDREAM_A8_DIAG_*` flags (still present in `RuntimeOptions`/`GameWindow`), keeping the `ACDREAM_PROBE_VIS` apparatus. | | Q4 | Per-cell *geometry* clip (#2) enforcement on GPU? | **Observable-result fidelity, not naive per-cell stencil** — depth-test for opaque cells (same observable result, keeps MDI batching); targeted stencil only where it changes the picture (translucent geometry; a cell with no depth occluder in front). | | Q5 | Phase label? | **A8.F** — added to the roadmap with this spec. | --- ## Architecture Two layers. The builder is pure CPU and faithful to retail line-by-line; the enforcement is GPU and maps retail's software-raster clip onto stencil. ### Pure-CPU layer (GL-free, unit-testable — sits beside `CellVisibility`) All new pure types live in `src/AcDream.App/Rendering/` in the GL-free style of `CellVisibility` (no `Silk.NET`/GL types; `System.Numerics` only) so they unit-test without a GPU context, consistent with the established pattern. - **`ViewPolygon`** — a 2D screen-space (NDC) convex polygon plus its bounding rect. Mirrors retail `view_poly { vertex_count, vertex_index, xmin,xmax,ymin,ymax }` (acclient.h:32465). - **`CellView`** — a *set* of `ViewPolygon` (a cell's accumulated clip region) plus the union bounding rect. Mirrors retail `view_type { DArray poly, ... }` (acclient.h:32338). A cell's region can be multiple convex polys. - **`ScreenPolygonClip`** — 2D convex-polygon intersection (Sutherland–Hodgman). Port of retail `ACRender::polyClipFinish` (+ the accumulation in `Render::copy_view`, decomp:344784). The single primitive everything leans on. Returns the clipped polygon (or empty). Heavily unit-tested with golden cases (full overlap, partial, disjoint, sliver, shared-edge degeneracy). - **`PortalProjection`** — project a cell-portal polygon (cell-local → world via `LoadedCell.WorldTransform`, then world → clip space via the frame's view-projection) and produce a screen-space (NDC) polygon, **with near-plane clipping performed in homogeneous clip space before the perspective divide**. A portal straddling the camera near-plane inverts if you divide-then-clip; retail handles this via plane-distance sidedness math (`PView::ConstructView(CBldPortal)` decomp:433832-433845, epsilon `0.0002`) plus the clipper. Tested explicitly with a near-plane-crossing portal. - **`PortalVisibilityBuilder`** — the BFS port of retail `PView::ConstructView` (decomp:433750) → `ClipPortals` (433572) → `AddViewToPortals` (433446), using `GetClip` (432344) / `OtherPortalClip` (433524) for the per-portal projection+intersect step. It **extends `CellVisibility`'s existing cell-graph BFS** (it already resolves the camera cell and walks portals with clip-plane side tests) by carrying a `CellView` clip region per reached cell and accumulating an `OutsideView`. Output per frame: - **`OutsideView`** — set of `ViewPolygon` where terrain/exterior may draw (exit portals, recursively clipped). The flap fix. - **per-cell `CellView` map** — keyed by full cell id, for #2. - **cross-building views** — entry clip regions for other buildings seen through our portals, for #3 (feeds the existing Step-5 path). - Seeded from the camera cell with a full-screen `CellView`, mirroring `PView::DrawInside` (433793) seeding the camera cell's view via `copy_view(..., 4)`. ### GPU-enforcement layer (reuses A8 stencil) - **`IndoorCellStencilPipeline`** (modify) — gains an entry that marks a set of screen-space (NDC) `ViewPolygon`s into the stencil (identity view-projection / NDC passthrough), replacing the flat world-space `PortalMeshBuilder.BuildTriangles` exit-portal path. Far-depth punch and the Step-5 occlusion-query helpers are retained unchanged. - **`RenderInsideOutAcdream`** (rewrite) — drive from the builder output and restore WB's structure (exterior draws unconditional; only stencil *state* gated). Three wire-ins: 1. **Terrain/scenery/shells** gated to the `OutsideView` stencil (the flap fix). 2. **EnvCell geometry** clipped to per-cell `CellView` per Q4 (depth for opaque; targeted stencil for translucent / no-occluder). 3. **Cross-building** cells via the existing Step-5 3-bit helpers, fed the cross-building clip regions (replaces the env-gated `ACDREAM_A8_STEP5` opt-in). ### Per-frame data flow ``` camera pos → CellVisibility.FindCameraCell (existing) → PortalVisibilityBuilder (BFS, CPU) (new, extends CellVisibility) ├─ OutsideView (terrain region polys) ├─ per-cell CellView (geometry clip) └─ cross-building views (Step 5 entry regions) → IndoorCellStencilPipeline (GPU stencil mark + far-depth punch) → RenderInsideOutAcdream: Step 3 draw camera-building cells (depth) wire-in #2 per-cell-clipped geometry Step 4 terrain/scenery/shells gated to OutsideView (unconditional draw, gated state) Step 5 cross-building via clip regions → LiveDynamic drawn last (unchanged) ``` --- ## Q4 detail — per-cell geometry clipping without breaking the batcher Retail runs `setup_view` per cell and rasterizes the cell within its clip region. A naive GPU port (stencil-mark a cell's region → draw the cell → reset, per cell) would issue dozens of extra stencil cycles per frame and **break `EnvCellRenderer`'s MDI batching** (it batches all filtered cells into a few draws). For **opaque** cell geometry, depth-testing already produces retail's observable result (the nearer cell wins per pixel). So: - **Opaque cells:** rely on depth (no per-cell stencil). Same observable result; batching kept. - **Translucent cells, and any cell with no depth occluder between it and the camera through a non-direct portal chain:** apply the per-cell `CellView` via targeted stencil. These are the cases where depth alone diverges from retail. This is a deliberate fidelity-vs-perf call: faithful to the *observable* retail result while not naively tanking the renderer. Flagged in the decision record (Q4). --- ## Retail decomp anchors (`acclient_2013_pseudo_c.txt`) | Function | Line | Role | |---|---|---| | `PView::DrawInside` | 433793 | entry — seed camera-cell view, build, draw | | `PView::ConstructView` (CEnvCell) | 433750 | BFS builder (todo-queue) | | `PView::ClipPortals` | 433572 | per-portal project + intersect; route exit→outside_view, interior→neighbor | | `PView::AddViewToPortals` | 433446 | enqueue neighbors with non-empty clip | | `PView::GetClip` | 432344 | project a portal poly to screen + clip against current view | | `PView::OtherPortalClip` | 433524 | clip against the neighbor's matching portal | | `PView::InitCell` | 432896 | init a cell's `portal_view` | | `PView::DrawCells` | 432709 | consumer — `setup_view` per cell, draw portals + cell | | `CEnvCell::setup_view` | 309746 | load a cell's active clip view | | `Render::copy_view` | 344784 | accumulate/merge clipped polygons into a view | | `Render::set_view` | 343750 | load active clip region into raster globals | | `D3DPolyRender::DrawPortalPolyInternal` | 424490 | depth-mask the (clipped) portal silhouette | | `ACRender::polyClipFinish` | (by name) | 2D convex polygon clipper | | `CEnvCell::find_visible_child_cell` | 311397 | point-in-child-cell resolution (camera tracking; not draw clip) | Struct fields (`acclient.h`): `CEnvCell`:32072, `portal_view_type`:32346, `portal_info`:32458, `view_poly`:32465, `view_type`:32338, `CCellPortal`:32300, `CBldPortal`:32094, `PView`:45934. ## acdream code anchors - `src/AcDream.App/Rendering/CellVisibility.cs` — `LoadedCell` (CellId, WorldTransform, InverseWorldTransform, Portals, ClipPlanes, PortalPolygons, BuildingId), `CellPortalInfo` (OtherCellId 0xFFFF = exit), `CellVisibility.GetVisibleCells` BFS, `VisibilityResult`. - `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — `PortalMeshBuilder.BuildTriangles` (the flat path being replaced), `MarkAndPunch`, `EnableOutdoorPass`, Step-5 helpers. - `src/AcDream.App/Rendering/GameWindow.cs` — `RenderInsideOutAcdream` (~11012), the indoor call site (~7636), the `didInsideStencil` gate (~11167). - `src/AcDream.App/Rendering/Wb/BuildingLoader.cs`, `Wb/Building.cs` (`EnvCellIds`, `ExitPortalPolygons`). --- ## Step 0 — strip leftover A8 diag flags (first execution step) The A8 WB-port batch is **already committed** (`5dc4140`, landed after the 2026-05-28 handoff was written), so there is no batch to commit — `5dc4140` is the baseline and the known-good "working-with-cellar-flap" rollback point. What remains is the diag-flag cleanup that the batch did not include: 1. Remove the temporary `ACDREAM_A8_DIAG_*` step-disable flags from `RuntimeOptions` (~18 occurrences) and their use sites in `RenderInsideOutAcdream` (the `diagDisableStep*` locals, ~6 in GameWindow.cs). 2. **Keep** the `ACDREAM_PROBE_VIS` apparatus (`[buildings]`/`[envcells]`/`[stencil]`/ `[draworder]` probes) — it is the visual-gate evidence tool. 3. `dotnet build` green, `dotnet test` green (App baseline ~90; Core baseline maintained). 4. Commit the cleanup, so A8.F's diff starts from a flag-free baseline. --- ## Testing strategy **Unit (pure CPU, alongside `CellVisibility`'s existing tests):** - `ScreenPolygonClip`: convex-intersection golden cases (full, partial, disjoint→empty, sliver, shared-edge, winding). - `PortalProjection`: a portal fully in front; a portal straddling the near plane (asserts no inversion); a portal fully behind (empty). - `PortalVisibilityBuilder` against the offline cellar/inn fixtures (`tools/A8CellAudit`): - **Cellar** (camera cell with zero exit portals; ground floor reached via the stairwell): `OutsideView` for the ground-floor windows ⊆ the stairwell portal's screen region (a sliver), **not** the full window silhouette. This is the regression test for the flap. - **Inn / multi-cell ground floor:** an adjacent cell's window seen across a same-level doorway still contributes its (mostly unclipped) region — daylight preserved. - Determinism: same inputs → identical `OutsideView` polygon set. **Conformance:** we cannot extract retail's exact screen polygons, so assert the *clipping relationship* (deeper-cell exit region ⊆ the intersection of the portal-chain openings) rather than golden pixel coords. The `A8CellAudit` `portals` mode reproduces the topology offline. **Visual gate (single, at the end — `ACDREAM_PROBE_VIS=1` evidence read from the log first):** - Cottage **cellar**: no flap (at most a daylight sliver up the stairwell, never the full outdoor world through the floor). - Cottage interior + inn: walls/furniture render; windows show correct daylight; multi-room daylight preserved. - Dungeon if reachable on the server (deep portal chain). - No regression to the A8 baseline (no see-through walls; building shells intact; LiveDynamic entities present indoors). --- ## Acceptance criteria - [ ] Step 0 baseline committed; `ACDREAM_A8_DIAG_*` removed; `ACDREAM_PROBE_VIS` retained. - [ ] `PortalVisibilityBuilder` + `ScreenPolygonClip` + `PortalProjection` + view data model land as GL-free, unit-tested types; every ported piece cites its retail anchor in comments. - [ ] All three wire-ins (terrain `OutsideView`, per-cell geometry, cross-building) implemented. - [ ] Job-A/Job-B decoupled (exterior geometry draws unconditionally; only stencil state gated). - [ ] `dotnet build` + `dotnet test` green; new unit/conformance tests pass (incl. the cellar sliver regression test). - [ ] Visual gate passed by the user (cellar flap gone; no A8 regressions). - [ ] Roadmap updated (A8.F shipped row); memory updated with the durable WB-vs-retail finding. --- ## Risks / open questions 1. **Near-plane projection** is the highest-risk piece (homogeneous clip before divide). Mitigated by an explicit unit test and porting retail's sidedness math rather than guessing. 2. **Cross-building (#3) clip-region feeding** — the existing Step-5 path was gated off because its portal list wasn't proven equivalent to WB's `_visibleBuildingPortals`. Driving it from the builder's cross-building views should make it correct; verify the building-entry portal set matches before enabling by default. 3. **Per-cell stencil (#2) perf** — keep opaque cells on the depth path; measure if the translucent/no-occluder stencil set is small in practice (expected: yes). 4. **`CellView` as multiple convex polys** — a non-convex portal opening or the union of several exit portals yields multiple polygons; the stencil mark must render all of them (union). `ScreenPolygonClip` operates per-convex-poly; the builder holds the set. ## Out of scope / future - Replacing the stencil enforcement with a software-style scissor or compute-shader clip (the stencil mapping is sufficient and reuses A8 infrastructure). - Outdoor→indoor (`RenderOutsideIn`) changes beyond feeding it the same projection helpers if trivial; its existing A8 behavior stands unless the visual gate surfaces a defect. ## Roadmap entry Add to `docs/plans/2026-04-11-roadmap.md` (ahead → shipped on completion): **A8.F — Retail portal-frame visibility port.** Ports retail `PView` recursive portal-clip (`ConstructView`/`ClipPortals`/`GetClip`) as a GL-free builder producing a recursively-clipped `OutsideView`; maps enforcement onto the A8 stencil pipeline; fixes the cellar flap (last A8 indoor defect). Builds on the A8 WB full-port baseline.