diff --git a/docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md b/docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md new file mode 100644 index 0000000..c6a536e --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-phase-a8f-portal-frame-visibility-design.md @@ -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 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 — 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.