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

19 KiB
Raw Blame History

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 — 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:


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) ViewPolygons 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.csLoadedCell (CellId, WorldTransform, InverseWorldTransform, Portals, ClipPlanes, PortalPolygons, BuildingId), CellPortalInfo (OtherCellId 0xFFFF = exit), CellVisibility.GetVisibleCells BFS, VisibilityResult.
  • src/AcDream.App/Rendering/IndoorCellStencilPipeline.csPortalMeshBuilder.BuildTriangles (the flat path being replaced), MarkAndPunch, EnableOutdoorPass, Step-5 helpers.
  • src/AcDream.App/Rendering/GameWindow.csRenderInsideOutAcdream (~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.