acdream/docs/superpowers/specs/2026-05-31-phase-u4c-stabilize-portal-visibility-design.md
Erik 31f265d8ec docs(render): Phase U.4c — design spec (stabilize portal visibility / fix the flap)
Grounds the visible-cell SET in the stable per-cell PVS (stab_list) + seen_outside,
refreshed on cell entry, the way retail does (grab_visible_cells 311878, add_views
433382, DrawInside 433793). Our PortalVisibilityBuilder rebuilds the set per-frame
from a pose-brittle CameraOnInteriorSide walk, so a flipped side-test drops the exit
cell, empties OutsideView, and TerrainMode.Skip flaps terrain/shells off at the
doorway. Both stable inputs already live in-process (envCell.VisibleCells,
envCell.Flags & SeenOutside); U.4c is plumbing + grounding, not new dat parsing.

Apparatus-first: characterize the flap on a live ACDREAM_PROBE_VIS capture + port the
add_views/ClipPortals/AddToCell semantics to pseudocode before implementing; the
builder is not declared correct until a live [vis] shows non-empty + narrowing
OutsideView. No hysteresis band-aid (forbidden). Indoor rendering untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:44:14 +02:00

21 KiB

Phase U.4c — Stabilize portal visibility (fix the threshold "flap") — design spec

Status: design approved 2026-05-31 (brainstorm). Milestone: M1.5 — "Indoor world feels right." Predecessor: Phase U (unified retail-faithful render pipeline) shipped through U.4; indoor rendering visually verified correct. This is the one residual. Handoff / decision context: docs/research/2026-05-30-phase-u4-shipped-and-flap-handoff.md; parent spec docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md.


1. The problem (recap, precisely root-caused)

Crossing a Holtburg cottage doorway (cellar → ground floor → outside), terrain + building-shells briefly vanish, leaving only un-gated geometry (particles + live entities) over the bluish clear color. This is the "flap."

It is not a mystery. ACDREAM_PROBE_VIS=1 [vis] lines for the same camera cell across adjacent frames:

root=0xA9B40171  cells=4  ids=[...,0xA9B40170]   outside(polys=1,planes=4)   ← window cell reached → terrain draws
root=0xA9B40171  cells=3  ids=[0xA9B40171,75,74]  outside(polys=0,planes=0)   ← window cell dropped → terrain SKIPPED

Over one cellar traversal: 10 empty-OutsideView frames interleaved with 16 non-empty, for the same cells. The ground-floor cell 0xA9B40170 (which holds the window / 0xFFFF exit portal) flickers in and out of the visible set as the camera moves a few centimetres.

Mechanism in our code

PortalVisibilityBuilder.Build discovers the visible-cell set purely by a per-frame portal-graph walk. Each hop is gated by CameraOnInteriorSide (line 237) — a hard plane-side test (Dot(planeN, localCam)+D vs a ±0.01 epsilon). When the camera sits near a portal plane, a centimetre of motion flips that test; the sole multi-hop chain reaching 0xA9B40170 breaks; the cell drops from the set; its 0xFFFF portal never contributes to OutsideView; ClipFrameAssembler.Assemble maps the empty OutsideView to TerrainClipMode.Skip and OutdoorVisible=false (lines 173-179) → terrain and building-shells flap off.

The root is structural: we rebuild the cell set every frame from a pose-brittle walk. Retail does not.


2. Goal & non-goals

Goal. Make the visible-cell set — and therefore OutsideView and the terrain-draw decision — stable across camera pose, the retail way: ground it in the per-cell precomputed PVS (stab_list) refreshed on cell entry. The per-frame portal-clip walk stays per-frame; it refines where each cell draws, never which cells exist. The threshold crossing becomes seamless.

Non-goals (this phase).

  • Any functional change to indoor cell-shell / entity / terrain rendering (EnvCellRenderer, WbDrawDispatcher, TerrainModernRenderer, ClipFrame/ClipFrameAssembler, the mesh/terrain clip shaders, the two U.4 GL-state fixes). U.4c is visible-set stability only — it changes what feeds those consumers, not the consumers. (The one exception is the explicitly-optional, behaviour-neutral cosmetic sweep of AppendSlot's 3-state collapse in §8, taken only if trivial.)
  • U.5 (outdoor-camera → building-interior peering) and U.6 (dungeon-scale validation, #95 / residual #102). Deferred, separately tracked.
  • A hysteresis / last-frame-region band-aid. Explicitly forbidden (workaround). The set must be made stable by construction, not smoothed after the fact.

3. The retail oracle (what we port)

All line numbers in docs/research/named-retail/acclient_2013_pseudo_c.txt; struct lines in docs/research/named-retail/acclient.h. Read during the U.4c brainstorm; this is the evidence the design rests on.

3.1 The two stable anchors, set on cell entry — NOT per frame

  • CEnvCell::grab_visible_cells (311878). On the camera cell: add_visible_cell(self), then add_visible_cell(stab_list[i]) for every stab; then if (seen_outside == 0) return; else LScape::grab_visible_cells(...). It populates the static visible_cell_table (which CEnvCell::GetVisible reads) from the cell's stab_list, and keeps the landscape grabbed only when seen_outside.
  • CellManager::ChangePosition (94601) is the only caller, on position/cell change (94646 / 94653 / 94659). The landscape keep-vs-release decision keys on the stable seen_outside flag (94649 keep, 94658 LScape::release_all). So the PVS + the landscape-loaded decision are refreshed on cell entry and held stable between entries.
  • SmartBox::RenderNormalMode (92649) — top-level draw dispatch: the draw-landscape flag ebx_1 = (… || viewer_cell->seen_outside != 0) reads the stable per-cell seen_outside, then calls DrawInside(viewer_cell).

3.2 The per-frame view is seeded from the stable PVS

PView::DrawInside (433793), the per-frame indoor entry, in order:

CEnvCell::curr_view_push(arg2)                     // push a view accumulator on the camera cell
PView::add_views(this, num_stabs, stab_list)       // SEED: push an accumulator on every PVS cell
…
ConstructView(this, arg2, 0xffff)                  // per-frame portal-clip walk (refine regions)
DrawCells(this, …)                                 // draw; landscape iff outside_view non-empty
PView::remove_views(this, num_stabs, stab_list)    // teardown
  • PView::add_views (433382): for each stab id, cell = CEnvCell::GetVisible(id); if (cell) curr_view_push(cell). It makes every PVS cell a live participant with its own view accumulator before the walk — not just cells the walk reaches. (It seeds an empty accumulator, not full-screen.)

3.3 The per-frame walk (what stays per-frame)

  • ConstructView (433750): InitCellInsCellTodoList(0) → loop {pop nearest from the distance-priority cell_todo_list, append to cell_draw_list, ClipPortals, AddViewToPortals}.
  • InitCell (432896): per-portal sidedness classification (sets each portal's seen flag) + nearest-vertex distance for the todo key. This is the same family as our CameraOnInteriorSide.
  • ClipPortals (433572): for each seen && !inflag portal, GetClip projects + clips the opening against the cell's current view. If the portal is 0xFFFF and draw_landscape, the clipped region is copy_view-ed into outside_view (433662-433676). Otherwise OtherPortalClip (reciprocal) then copy_view into the neighbour.
  • AddViewToPortals (433446): enqueue a neighbour on first discovery (ecx_5==0InitCell + InsCellTodoList); on view-growth (ecx_5 != view_count) → AddToCell + FixCellList (re-incorporate in place + advance the update_count watermark). This is the fixpoint: a cell accumulates view from multiple incoming portals and is re-clipped when its region grows.
  • DrawCells (432709): landscape (LScape::draw) is drawn iff outside_view.view_count > 0 (432715). So retail's terrain decision is per-frame and keyed on outside_view emptiness — exactly like ours. Retail does not flap because its outside_view does not spuriously empty, because the set it walks is grounded in the stable PVS.

3.4 Struct truth (resolves a decomp-name hazard)

acclient.h CEnvCell (~30925): unsigned int num_stabs; unsigned int *stab_list; int seen_outside; — three distinct fields. seen_outside is a genuine int boolean. (The Binary Ninja pseudo-C at 311044 assigns a Frame array to a field it labels seen_outside; that is BN aliasing a different offset — cf. project memory bn-decomp-field-names. Trust acclient.h.)

3.5 The data is already in our process

Both stable inputs exist in-process today; only the render path drops them:

  • stab_list = envCell.VisibleCells (List<ushort>, landblock-local). PhysicsDataCache already reads it into full ids (lines 178-186) and notes it "reserved for the optional find_cell_list visibility filter."
  • seen_outside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside) — a direct dat flag. WB's EnvCellLandblock.SeenOutsideCells already tracks it; tools/A8CellAudit/Program.cs:200 already reads it.

So U.4c is plumbing existing data + grounding logic, not new dat parsing and not guessing.


4. Architecture — three layers

   On cell entry (camera changes cell):
     LoadedCell already hydrated with VisibleCells (PVS, full ids) + SeenOutside (Layer 1)
                     │
   Per frame:        ▼
     PortalVisibilityBuilder.Build(cameraCell, …)
        • seed participants from cameraCell.VisibleCells       (Layer 2 — the add_views analog)
        • closest-first portal-clip walk refines each region   (unchanged)
        • OutsideView accumulates exit-portal contributions
                     ▼
     ClipFrameAssembler.Assemble(...)  (UNCHANGED — already consumes CellViews + OutsideView)
                     ▼
     ACDREAM_PROBE_VIS [vis] — now stable: OutsideView non-empty + narrowing, no polys=0/1 flap

Each layer is independently testable; the interfaces below are the contract.


5. Components

5.1 Layer 1 — LoadedCell carries the stable inputs (data plumbing)

Add to LoadedCell:

/// <summary>The stab_list PVS as full (landblock-prefixed) cell ids — retail
/// CEnvCell.stab_list. The stable set of cells potentially visible from this cell,
/// precomputed by the AC content tools. Refreshed only at hydration (cell entry).</summary>
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();

/// <summary>Retail CEnvCell.seen_outside: this cell sees the exterior (an exit portal
/// is reachable from it). Gates whether the landscape is drawn for this camera cell.</summary>
public bool SeenOutside;

Populated at the existing hydration site (GameWindow.cs:5696, the EnvCell-build method) from data already on envCell:

  • VisibleCellsenvCell.VisibleCells each OR-ed with the landblock mask (envCellId & 0xFFFF0000u), identical to the prefix logic PhysicsDataCache already uses.
  • SeenOutsideenvCell.Flags.HasFlag(EnvCellFlags.SeenOutside).

This adds two fields + ~4 lines to an existing method — within Code Structure Rule 1 ("a handful of fields and a one-paragraph method to wire an extracted class in is fine"). No new dat read.

5.2 Layer 2 — PortalVisibilityBuilder seeds from the PVS (the add_views analog)

Before the portal-clip walk, the builder makes every cell in cameraCell.VisibleCells a participant (a keyed entry in CellViews with a live accumulator), resolving each via the existing lookup. The existing closest-first walk + OtherPortalClip then refine each participant's clip region and accumulate OutsideView from exit portals. The window cell 0xA9B40170 is therefore present every frame regardless of whether a transient CameraOnInteriorSide flip broke its reaching chain.

The builder signature and PortalVisibilityFrame shape are unchanged; the seeding is internal. ClipFrameAssembler, ClipFrame, the shaders, EnvCellRenderer, and terrain are untouched — they already consume CellViews / OutsideView correctly.

The one load-bearing detail, resolved in Task 1 (not guessed here): retail's add_views seeds empty accumulators (curr_view_push), and the fixpoint (AddToCell/FixCellList on view-growth, §3.3) lets a cell accumulate region from multiple incoming portals and re-clip its outgoing (including 0xFFFF) portals when its view grows. Our current builder enqueues-once and unions growth into CellViews without re-clipping the grown cell's outgoing portals (PortalVisibilityBuilder.cs:210-226). Two sub-hypotheses for why retail's OutsideView stays non-empty where ours empties:

  • H1 — set grounding: the window cell stays a participant via add_views / visible_cell_table even when the per-frame chain breaks; once it is a guaranteed participant, its exit portal contributes. Fix = seed participants from the PVS (+ port the growth re-clip so a multi-path cell's exit portal re-contributes against its grown region).
  • H2 — stable side test: retail's chain does not break because InitCell's sidedness is computed on a more stable quantity / convention than our CameraOnInteriorSide; fix = also make our side test robust (epsilon / reciprocal-aware), with PVS grounding as the structural net.

Task 1 disambiguates these on a live ACDREAM_PROBE_VIS capture at the cottage threshold, porting the exact add_views / ClipPortals / AddToCell / FixCellList semantics, BEFORE any further wiring. The implementation follows the evidence; both sub-hypotheses share the same primary change (PVS grounding), so Task 1 is a refinement of one design, not a fork.

5.3 Layer 3 — the terrain/shell decision is anchored

With the set grounded (Layer 2), OutsideView empties only when genuinely no exit portal is in view (facing a wall), never spuriously. The camera cell's stable SeenOutside is the retail-faithful anchor (it matches RenderNormalMode / grab_visible_cells gating the landscape data) and yields two falsifiable invariants for tests (§7):

  1. A camera cell whose PVS contains no exit-portal cell and is not itself SeenOutside must produce an empty OutsideView (terrain Skip) — terrain is correctly absent in a windowless interior.
  2. A SeenOutside camera cell crossing the threshold must produce a stable non-empty OutsideView across pose — no polys=0/polys=1 interleave.

Layer 3 is an anchor + test oracle, not a new draw gate. We do not make terrain ignore OutsideView (that would diverge from DrawCells §3.3). The flap is killed by making OutsideView stable (Layer 2), not by floating the terrain decision off it.


6. Error handling / safe direction

  • Camera cell with empty / missing VisibleCells (degenerate or pre-PVS dat): fall back to today's pure-walk behaviour — no regression, no crash.
  • PVS cell not currently loaded (lookup returns null): skip it (it cannot draw).
  • Genuinely degenerate exit-portal data (the safe-direction backstop, not the common-case mechanism): over-include (terrain draws slightly wide) rather than vanish — a vanish is the flap; over-draw is benign under the depth test. This applies only to missing/degenerate data, never as a substitute for the faithful grounding of §5.2.
  • 8-plane cap / scissor fallbacks: unchanged — already handled in ClipPlaneSet / ClipFrameAssembler.

7. Testing strategy

  • Unit (GL-free), PortalVisibilityBuilderTests:
    • Flap regression: a synthetic cottage chain (camera cell → mid cell → exit cell with a 0xFFFF portal) where the mid→exit reaching path is deliberately broken by a back-facing intermediate portal. Assert the exit cell stays in OrderedVisibleCells and OutsideView stays non-empty. This is the RED→GREEN test for the flap.
    • PVS-empty fallback: camera cell with empty VisibleCells → behaviour identical to the current pure-walk builder (pin no regression).
    • SeenOutside invariants (§5.3): windowless interior → empty OutsideView; threshold cell → stable non-empty across a swept camera pose.
  • The real gate is visual + the runtime probe (unit tests on synthetic data did not catch #103): at the Holtburg cottage doorway, cellar → ground → out, from several angles and zooms — ACDREAM_PROBE_VIS=1 shows OutsideView non-empty and narrowing (no polys=0/polys=1 interleave for the same cell), and the threshold is seamless: no terrain or building-shell flicker. This is the acceptance gate.
  • No regression to the indoor case (walls solid, no terrain bleed) or the outdoor default.

8. Implementation staging

Build + test green at every stage. Branch: claude/thirsty-goldberg-51bb9b (continue; preserve the two git stash entries).

U.4c is entirely CPU-side (the builder + LoadedCell data); there is no new GPU/shader work — the GPU gate shipped in U.3/U.4. So "validate before wiring" means: characterize the flap and port the retail semantics to pseudocode first; validate the implementation on a live [vis] capture before declaring the builder correct.

Stage Deliverable Gate
U.4c-1 Characterize + pseudocode (oracle first). Live ACDREAM_PROBE_VIS capture of the current flap → confirm it is the set-drop and identify which portal/cell side-test flips (sharpens H1 vs H2, §5.2). Port add_views / ClipPortals / AddToCell / FixCellList to a short pseudocode note (grep→decompile→pseudocode→port). Pseudocode note committed; capture confirms the set-drop mechanism
U.4c-2 Layer 1LoadedCell.VisibleCells + SeenOutside; hydrate at GameWindow:5696. Build green; fields populated (probe/unit)
U.4c-3 Layer 2 — builder seeds participants from the PVS + the Task-1 semantics; flap-regression unit test RED→GREEN. If a live [vis] capture still flaps, the side test is the residual (H2) → add the robust side test here. dotnet test green incl. the new regression test AND a live [vis] capture at the threshold shows non-empty + narrowing OutsideView (no polys=0/polys=1 interleave) — the apparatus gate
U.4c-4 Layer 3SeenOutside invariant tests; confirm ClipFrameAssembler consumes the stabilized frame unchanged. Unit green
U.4c-5 Visual gate at the Holtburg cottage threshold (several angles / zooms). Seamless threshold — acceptance

Optional sweeps only if trivial and in-area (defer anything non-trivial): AppendSlot's 3-Count==0-state collapse (branch IsNothingVisible / UseScissorFallback before the call), orphaned LandblockEntriesWithoutAnimatedIndex, dead BuildingShellAnchorPass/Reject counters.


9. Risks

  • #103/#98 recurrence (designing on a half-read). Medium → mitigated. The flap is characterized on a live [vis] capture and the retail semantics ported to pseudocode in Task 1 (oracle first); the builder is not declared correct until a live [vis] capture shows a non-empty, narrowing OutsideView at the threshold (U.4c-3 gate). Apparatus before fix (project memory apparatus-for-physics-bugs).
  • PVS seeding over-includes cells (draws cells the camera cannot actually see). Low — gated by the per-frame clip (an unreached participant with an empty refined region draws nothing); worst case is benign over-draw under the depth test, never a hidden-geometry or flap regression.
  • Growth re-clip changes traversal cost. Low — M1.5 interior chains are short (≤ ~15 cells); the fixpoint watermark bounds re-processing (already the U.2a termination guarantee).
  • SeenOutside dat flag absent on some cells. Low — falls back to the §6 pure-walk path; the flag is present on Holtburg cottage/inn cells (verified available via A8CellAudit).

10. Reference index

  • Handoff: docs/research/2026-05-30-phase-u4-shipped-and-flap-handoff.md
  • Parent spec: docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md
  • Retail decomp (acclient_2013_pseudo_c.txt): RenderNormalMode 92649; CellManager::ChangePosition 94601 (grab calls 94646/94653/94659, keep/release 94649/94658); grab_visible_cells 311878; PView::DrawInside 433793; add_views 433382; remove_views 432319; ConstructView 433750; InitCell 432896; ClipPortals 433572 (0xFFFFoutside_view 433662-433676); AddViewToPortals 433446 (fixpoint AddToCell/FixCellList 433494-433502); OtherPortalClip 433524; DrawCells 432709 (landscape gate 432715). Struct: CEnvCell acclient.h ~30925 (num_stabs / stab_list / seen_outside).
  • acdream anchors: PortalVisibilityBuilder.cs (walk 116-228, CameraOnInteriorSide 237); CellVisibility.cs (LoadedCell 24); ClipFrameAssembler.cs (empty→Skip 173-179); GameWindow.cs (cell hydration 5696); PhysicsDataCache.cs (VisibleCells read 178-186); EnvCellSceneryInstance.cs (SeenOutsideCells 91); tools/A8CellAudit/Program.cs:200.
  • Project memory: bn-decomp-field-names (trust acclient.h over BN labels); apparatus-for-physics-bugs (live capture before fix); render-self-contained-gl-state (don't touch the renderers).
  • Related issues: #103 (superseded arc), #78 (inn through-floor — relate), #95 / #102 (U.6).