docs: Phase A8.F design — retail portal-frame visibility port (cellar-flap fix)

Faithful port of retail PView recursive portal-clip visibility
(ConstructView/ClipPortals/GetClip) to fix the residual A8 cellar flap.
Key finding: WB has no per-portal recursion — the flat-stencil algorithm
cannot express the fix; the recursion is retail-only. Builder ports as
GL-free CPU math producing a recursively-clipped OutsideView; enforcement
maps onto the existing A8 stencil pipeline. Builds on (does not supersede)
the A8 WB full-port baseline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:44:44 +02:00
parent 5dc4140c11
commit d9d0809549

View file

@ -0,0 +1,313 @@
# 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 ships as the baseline commit (Step 0 below).
**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 | Commit the uncommitted A8 batch first? | **Yes** — strip `ACDREAM_A8_DIAG_*`, keep the `ACDREAM_PROBE_VIS` apparatus, commit as the baseline (Step 0). |
| 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<view_poly> poly, ... }`
(acclient.h:32338). A cell's region can be multiple convex polys.
- **`ScreenPolygonClip`** — 2D convex-polygon intersection (SutherlandHodgman). 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 — commit the A8 baseline (first execution step)
Before any A8.F code:
1. Remove the temporary `ACDREAM_A8_DIAG_*` step-disable flags from `RuntimeOptions` and their
use sites in `RenderInsideOutAcdream` (the `diagDisableStep*` locals).
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 working A8 batch as the clean baseline (`feat(render): Phase A8 — WB full port
baseline (per-building scoping + stencil pipeline)` or similar), so A8.F's diff is
reviewable in isolation and there is a known-good "working-with-cellar-flap" rollback point.
---
## 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.