acdream/docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md
Erik ca62d745fb docs: A8.F spec — correct Step 0 (A8 batch already committed in 5dc4140)
The 2026-05-28 handoff's "uncommitted A8 batch" is stale: 5dc4140 landed
the batch after the handoff. Step 0 reduces to stripping the leftover
ACDREAM_A8_DIAG_* flags (still present in RuntimeOptions + GameWindow).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 10:46:36 +02:00

316 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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 — 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.