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>
316 lines
19 KiB
Markdown
316 lines
19 KiB
Markdown
# 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 (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.
|