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>
19 KiB
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:
- docs/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 — the offline root-cause evidence.
- Retail decomp:
docs/research/named-retail/acclient_2013_pseudo_c.txt— thePViewfunctions 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 retailview_poly { vertex_count, vertex_index, xmin,xmax,ymin,ymax }(acclient.h:32465).CellView— a set ofViewPolygon(a cell's accumulated clip region) plus the union bounding rect. Mirrors retailview_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 retailACRender::polyClipFinish(+ the accumulation inRender::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 viaLoadedCell.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, epsilon0.0002) plus the clipper. Tested explicitly with a near-plane-crossing portal.PortalVisibilityBuilder— the BFS port of retailPView::ConstructView(decomp:433750) →ClipPortals(433572) →AddViewToPortals(433446), usingGetClip(432344) /OtherPortalClip(433524) for the per-portal projection+intersect step. It extendsCellVisibility's existing cell-graph BFS (it already resolves the camera cell and walks portals with clip-plane side tests) by carrying aCellViewclip region per reached cell and accumulating anOutsideView. Output per frame:OutsideView— set ofViewPolygonwhere terrain/exterior may draw (exit portals, recursively clipped). The flap fix.- per-cell
CellViewmap — 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, mirroringPView::DrawInside(433793) seeding the camera cell's view viacopy_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-spacePortalMeshBuilder.BuildTrianglesexit-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:- Terrain/scenery/shells gated to the
OutsideViewstencil (the flap fix). - EnvCell geometry clipped to per-cell
CellViewper Q4 (depth for opaque; targeted stencil for translucent / no-occluder). - Cross-building cells via the existing Step-5 3-bit helpers, fed the cross-building
clip regions (replaces the env-gated
ACDREAM_A8_STEP5opt-in).
- Terrain/scenery/shells gated to the
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
CellViewvia 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.GetVisibleCellsBFS,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), thedidInsideStencilgate (~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:
- Remove the temporary
ACDREAM_A8_DIAG_*step-disable flags fromRuntimeOptions(~18 occurrences) and their use sites inRenderInsideOutAcdream(thediagDisableStep*locals, ~6 in GameWindow.cs). - Keep the
ACDREAM_PROBE_VISapparatus ([buildings]/[envcells]/[stencil]/[draworder]probes) — it is the visual-gate evidence tool. dotnet buildgreen,dotnet testgreen (App baseline ~90; Core baseline maintained).- 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).PortalVisibilityBuilderagainst the offline cellar/inn fixtures (tools/A8CellAudit):- Cellar (camera cell with zero exit portals; ground floor reached via the stairwell):
OutsideViewfor 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
OutsideViewpolygon set.
- Cellar (camera cell with zero exit portals; ground floor reached via the stairwell):
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_VISretained. 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 testgreen; 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
- 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.
- 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. - 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).
CellViewas 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).ScreenPolygonClipoperates 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.