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>
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 ofAppendSlot'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), thenadd_visible_cell(stab_list[i])for every stab; thenif (seen_outside == 0) return;elseLScape::grab_visible_cells(...). It populates the staticvisible_cell_table(whichCEnvCell::GetVisiblereads) from the cell'sstab_list, and keeps the landscape grabbed only whenseen_outside.CellManager::ChangePosition(94601) is the only caller, on position/cell change (94646 / 94653 / 94659). The landscape keep-vs-release decision keys on the stableseen_outsideflag (94649 keep, 94658LScape::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 flagebx_1 = (… || viewer_cell->seen_outside != 0)reads the stable per-cellseen_outside, then callsDrawInside(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):InitCell→InsCellTodoList(0)→ loop {pop nearest from the distance-prioritycell_todo_list, append tocell_draw_list,ClipPortals,AddViewToPortals}.InitCell(432896): per-portal sidedness classification (sets each portal'sseenflag) + nearest-vertex distance for the todo key. This is the same family as ourCameraOnInteriorSide.ClipPortals(433572): for eachseen && !inflagportal,GetClipprojects + clips the opening against the cell's current view. If the portal is0xFFFFanddraw_landscape, the clipped region iscopy_view-ed intooutside_view(433662-433676). OtherwiseOtherPortalClip(reciprocal) thencopy_viewinto the neighbour.AddViewToPortals(433446): enqueue a neighbour on first discovery (ecx_5==0→InitCell+InsCellTodoList); on view-growth (ecx_5 != view_count) →AddToCell+FixCellList(re-incorporate in place + advance theupdate_countwatermark). 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 iffoutside_view.view_count > 0(432715). So retail's terrain decision is per-frame and keyed onoutside_viewemptiness — exactly like ours. Retail does not flap because itsoutside_viewdoes 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).PhysicsDataCachealready 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'sEnvCellLandblock.SeenOutsideCellsalready tracks it;tools/A8CellAudit/Program.cs:200already 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:
VisibleCells←envCell.VisibleCellseach OR-ed with the landblock mask (envCellId & 0xFFFF0000u), identical to the prefix logicPhysicsDataCachealready uses.SeenOutside←envCell.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_tableeven 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 ourCameraOnInteriorSide; 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):
- A camera cell whose PVS contains no exit-portal cell and is not itself
SeenOutsidemust produce an emptyOutsideView(terrainSkip) — terrain is correctly absent in a windowless interior. - A
SeenOutsidecamera cell crossing the threshold must produce a stable non-emptyOutsideViewacross pose — nopolys=0/polys=1interleave.
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 (
lookupreturns 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
0xFFFFportal) where the mid→exit reaching path is deliberately broken by a back-facing intermediate portal. Assert the exit cell stays inOrderedVisibleCellsandOutsideViewstays 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). SeenOutsideinvariants (§5.3): windowless interior → emptyOutsideView; threshold cell → stable non-empty across a swept camera pose.
- Flap regression: a synthetic cottage chain (camera cell → mid cell → exit cell with a
- 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=1showsOutsideViewnon-empty and narrowing (nopolys=0/polys=1interleave 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 1 — LoadedCell.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 3 — SeenOutside 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, narrowingOutsideViewat the threshold (U.4c-3 gate). Apparatus before fix (project memoryapparatus-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).
SeenOutsidedat 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 viaA8CellAudit).
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):RenderNormalMode92649;CellManager::ChangePosition94601 (grab calls 94646/94653/94659, keep/release 94649/94658);grab_visible_cells311878;PView::DrawInside433793;add_views433382;remove_views432319;ConstructView433750;InitCell432896;ClipPortals433572 (0xFFFF→outside_view433662-433676);AddViewToPortals433446 (fixpointAddToCell/FixCellList433494-433502);OtherPortalClip433524;DrawCells432709 (landscape gate 432715). Struct:CEnvCellacclient.h~30925 (num_stabs/stab_list/seen_outside). - acdream anchors:
PortalVisibilityBuilder.cs(walk 116-228,CameraOnInteriorSide237);CellVisibility.cs(LoadedCell24);ClipFrameAssembler.cs(empty→Skip 173-179);GameWindow.cs(cell hydration 5696);PhysicsDataCache.cs(VisibleCells read 178-186);EnvCellSceneryInstance.cs(SeenOutsideCells91);tools/A8CellAudit/Program.cs:200. - Project memory:
bn-decomp-field-names(trustacclient.hover 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).