# R-A2b — Portal-Flood Bounded Propagation (the indoor "flap" fix) **Date:** 2026-06-09 **Branch:** `claude/thirsty-goldberg-51bb9b` **Phase:** full retail render port (Option A) → R-A2b **Status:** design — approved direction (Option A, the faithful clip), pending written-spec review. > **Revives** `docs/superpowers/specs/2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION > banner = "bounded propagation") and `docs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md`. > Both were marked `⛔ SUPERSEDED` on the strength of a single `maxPop=1` capture that turned out to be the > **wrong reproduction** (camera-turn at rest, not a doorway crossing). This session re-ran the pin with a > slow walk-through and measured `maxPop=16` on a fifth of frames — the churn is real. Their banners are > corrected to redirect here. --- > ## ⚠️ REVISION (2026-06-09, writing-plans decomp pass): approach changed A → B (back-portal side-cull) > Reading retail `PView::InitCell` (`:432896`; side test at `:432962`) + `AddToCell` (`:433050`) during > plan-writing showed WHY retail never churns: its per-portal **side test culls the "back" portal** (the > doorway just flooded through — the viewpoint is on its exit side), so retail's flood is an acyclic tree > and the `0171↔0173` mutual cycle **cannot form**. Retail has **no** eye-in-opening bypass of that cull. > > Our flood forms the cycle because the back portal `0173→0171` **is traversed** where retail culls it > (`[pv-trace]`: `pop 0173 p0->0171 grew=True`). The re-enqueue churn (what §4 Option A targeted) is a > *consequence* of that non-retail cycle. The user chose the more-faithful **Option B**: cull the back > portal like retail (kill the cycle at its source), **keep** the forward-portal clip-empty void rescue, > and remove the now-dead `MaxReprocessPerCell` cap. **§4 (Option A coverage test) is superseded by §4-B > below.** > > **Open — WHY is the back portal traversed (this pins the exact fix; plan Phase 1 verifies before code):** > - **(B1) the bypass:** `EyeInsidePortalOpening` switches off the side-cull (`Build` lines ~208-216: > `!CameraOnInteriorSide(...) && !eyeInsideOpening`) when the eye is within 1.75 m of a doorway → fix: > drop `&& !eyeInsideOpening` from the side-cull (back portals cull; the *separate* clip-empty rescue at > `Build` ~241-250 still rescues FORWARD portals, so the 2026-06-05 void fix is preserved). > - **(B2) the side test itself:** `CameraOnInteriorSide` (`PortalVisibilityBuilder.cs:717-724`) returns > true for the back portal where retail's `InitCell` test (`eax_9 == portal_side`, `:432962`) culls it → > fix: align our side test to retail's convention. > - **Discriminator:** the back portal's signed distance `D` to the doorway plane at the churn frames — > `> 1.75 m ⇒ B2` (bypass is off; the side test passed on its own); `≤ 1.75 m ⇒ B1` (bypass in play). > At `root=0171`, `p1->0173` was measured at `D=-2.73 m` (bypass off) — *indicating B2* — but the churn > cluster was at a different eye pose with no captured `D`, so Phase 1 confirms before the fix. --- ## 1. Summary The indoor **flap** (grey/background flashing through doorways while *moving*) is a portal-flood **re-enqueue churn** in `PortalVisibilityBuilder.Build`. When the camera crosses an interior doorway, the two rooms sharing that doorway (`0171`↔`0173` at the Holtburg cottage) mutually re-contribute through the shared portal. Each pass, the near-side re-clip produces a **drifted near-duplicate** region; the reciprocal leaves it non-empty; `AddRegion`'s exact-polygon dedup doesn't recognize it → `grew=true` → the neighbour re-enqueues. It ping-pongs to the `MaxReprocessPerCell=16` cap, which cuts the flood at an **arbitrary depth**. Because the cut depth depends on the exact eye position, sub-cm eye creep makes the visible cell set swing (2↔4 cells) frame-to-frame → the grey flap. **The fix — see the REVISION banner above: Option B (back-portal side-cull), not the Option A coverage test described in this paragraph.** Retail's flood is acyclic because its per-portal side test culls the back portal; our flood cycles because the back portal is traversed (sub-mechanism B1/B2 pinned by plan Phase 1). Fix: cull the back portal like retail (kill the cycle), keep the forward-portal clip-empty void rescue, remove the now-dead `MaxReprocessPerCell` + `popCounts` cap. Scope: `PortalVisibilityBuilder` only — no camera, rooting, clip-math, or seal change. > > _(Original Option A text, superseded — kept for the record:)_ port retail's *bounded* propagation: a > candidate contribution already covered by the neighbour's accumulated view does not count as growth; only > the uncovered remainder propagates. Mirrors retail's "redundant → empty before `copy_view`". This is a > non-retail mechanism bounding a cycle retail never forms — Option B removes the cycle instead. --- ## 2. Diagnosis — verified this session (the verify-first gate) The 2026-06-08 handoff gated the fix on a measurement gate (`docs/research/2026-06-08-indoor-flap-edgeon-vs-camera-position-handoff.md` §5). Results: ### 2.1 §5.3 — retail's clip collapses at edge-on (the "port clip robustness" idea is dead) `PView::GetClip` (`:432344`) → `ACRender::polyClipFinish` (`:702749`) bails when the clipped polygon drops below 3 vertices (`:702863`, no guard band). `ClipPortals` (`:433654`) only propagates `if (ecx_8 != 0)`. `ConstructView` (`:433750`) rebuilds the flood every frame, no cross-frame hysteresis. Our `PortalProjection.ClipToRegion` collapses identically. **Edge-on area-collapse is geometric — there is no retail clip robustness to port.** That option is off the table. ### 2.2 §5.1 — the flap is a same-root flood oscillation, not a root-swap `analyze_flap_vis.py` over `launch-camprobe.log`: of ~4,009 `vis` transitions, **3,984 are same-root vs 25 root-changes (99.4 % same-root)**. The flap is a flood-membership oscillation *inside* room `0171`, not the "going-outside" root swap, and not the root doorway's D5 rescue flip (3,836/3,984 transitions had no change in the root doorway's clip/D-band/side). ### 2.3 The mechanism — `[pv-trace]` in `launch-camprobe.log` At a near-stationary eye (`157.30, 7.8x, 96.25`, ~1 cm creep), one `Build` call shows `0171` popped ~19× and `0173` ~20×, each round `p1->0173 addCell polys=1 clipVerts=4 recip=1->1 grew=True queued=True`, the `processed` watermark climbing 0→1→…→19 until the cap binds at 16. The mutual contribution does **not** shrink (constant `clipVerts=4`, `polys=1`) — it is the same doorway aperture, drifting. Per-cell view counts swing 1↔53 and cells `016F`/`0172` flicker in/out → the flap. ### 2.4 Confirmation — `launch-churn-confirm.log` (live walk-through, this session) `analyze_churn_confirm.py`: **44.4 % of frames `maxPop ≥ 2`; worst `maxPop = 16`** (cap saturated, 3,745 frames); root `0171` `maxPopMax=16`; `[flap]` vis oscillation reproduced (187 transitions, vis `2/3/4`). The calm baseline (player at rest, root `0172`) sits at `maxPop=1` — **that is exactly the position the 2026-06-08 "refuted (maxPop=1)" capture sampled.** The DO-NOT was an unrepresentative sample; the churn is confirmed at flap-time. ### 2.5 Why retail doesn't churn (termination primitive) `Render::copy_view` (`:344784`) — the slice-adder — **just appends** (with internal consecutive-vertex cleanup); it has **no redundancy check** (confirmed by reading it). So retail's termination is **upstream**: a redundant re-contribution does **not generate a new propagatable slice** — via the clip going empty (`GetClip`/`OtherPortalClip` < 3 verts) and/or the monotonic `update_count` watermark (each slice processed once). The exact primitive (empty-clip vs watermark vs both) is confirmed in the plan by tracing the `ClipPortals`/`AddToCell`/`AdjustCellView` mutual-cycle in full. Either way the *observable* contract is: **a redundant contribution adds no new visible area, so it does not grow the view.** Our `ApplyReciprocalClip` → `AddRegion` path violates that — it leaves the redundant contribution non-empty (`recip=1->1`) and `AddRegion`'s polygon-equality dedup can't catch the drifted near-duplicate → spurious `grew`. --- ## 3. Retail grounding (the traversal being matched) From `docs/research/named-retail/acclient_2013_pseudo_c.txt`: - `PView::ConstructView` (`:433750`): per-frame flood — `cell_todo_num=0`, seed root, pop one cell at a time, append to `cell_draw_list` (= membership), `ClipPortals(cell, 0)`, then `AddViewToPortals`. - `PView::ClipPortals` (`:433572`): processes the cell's view slices `[update_count, view_count)`; per portal `GetClip`; exit portal → `copy_view`/landscape; neighbour → `OtherPortalClip`. Propagates **only when the clipped result is non-empty** (`ecx_8 != 0` / `eax_16 != 0`). - `PView::AddViewToPortals` (`:433446`): first discovery (`processed_stamp==0`) → `InitCell` + `InsCellTodoList` (enqueue once); growth (`processed_stamp != view_stamp`) → `AddToCell` + `FixCellList`, then `processed_stamp = view_stamp` (**no re-enqueue**). - `PView::AddToCell` (`:433050`): incremental — clips the cell's portals against **only the newly-added slices**; does not re-contribute to `OutsideView`. - `PView::OtherPortalClip` (`:433524`): reciprocal back-clip; yields empty for a redundant back-contribution. - `Render::copy_view` (`:344784`): appends a slice; **no dedup** (confirms the empty-for-redundant decision is upstream, in the clip). **Takeaway:** retail re-processes growth (faithful — keep it), but a redundant re-contribution adds **no new visible area** → no new propagatable slice → termination (via empty clip and/or the monotonic watermark; §2.5). Our divergence is purely that redundant re-contributions stay non-empty and grow the view. --- ## 4. The fix (design — Option A) **Scope: `PortalVisibilityBuilder` only.** **4.1 Bounded growth (the core change).** A candidate contribution to a neighbour grows the neighbour's view (and may re-enqueue) **only by the area not already covered by that neighbour's accumulated view**. Concretely, before unioning a candidate region into `frame.CellViews[neighbour]`, intersect/subtract it against the neighbour's existing accumulated regions and keep only the **uncovered remainder**; `grew` is true iff that remainder is non-empty. A drifted near-duplicate of an already-covered region has ~zero uncovered area → `grew=false` → no re-enqueue → the mutual cycle terminates. This is retail's "redundant → empty," expressed on our region representation, and it is **drift-tolerant by construction** (it tests *coverage*, not polygon equality — so it is NOT the rejected epsilon-dedup band-aid). **4.2 Remove the band-aid.** Delete `MaxReprocessPerCell` and `popCounts` and the per-pop re-enqueue cap logic in both `Build` and `BuildFromExterior`. With redundant contributions no longer growing the view, termination is structural (each cell's genuinely-new slices process a bounded number of times; the flood converges as the aperture is covered). **4.3 Keep re-processing of genuinely-new slices.** A contribution that *does* add uncovered area still grows the view and re-enqueues, so late-discovered slices still reach exit portals (`Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` stays GREEN). **Exact code form (→ implementation plan, Task 1).** Whether 4.1 is implemented as (i) a polygon coverage test in `AddRegion` (candidate ⊆ union(existing) → no growth), (ii) an uncovered-remainder set-difference before the union, or (iii) matching retail's `ClipPortals` slice-watermark + `AddToCell` in-place growth, is finalized in the plan by reading the retail `ClipPortals`/`AddToCell`/`AdjustCellView` slice loop in full and choosing the smallest faithful form. The **principle** (redundant/covered → no growth; uncovered remainder propagates; cap removed; genuine re-processing kept) is fixed here. **Unchanged (explicit):** `ProjectToClip`/`ClipToRegion`, `EyeInsidePortalOpening`, the reciprocal `ApplyReciprocalClip`, the `OutsideView` exit contribution, rooting (`clipRoot = viewerRoot ?? _outdoorNode`), the camera, and the landscape-through-door seal. No new heuristic, hysteresis, or epsilon. --- ## 5. Testing (TDD) 1. **Eye-sweep membership stability (new, the RED→GREEN driver).** In `AcDream.App.Tests`, build the flood at a sequence of eye positions stepping monotonically across a grazing doorway (synthetic two-room + shared-portal topology reproducing the `0171↔0173` mutual aperture). **Assert each cell's membership across the sweep is a single contiguous run** — no `present→absent→present` flicker — and, if surfaced, per-cell pop count ≤ a small constant. RED under the churn, GREEN after the bound. 2. **Termination without the cap.** Diamond + cycle fixtures: assert the flood terminates with `MaxReprocessPerCell` removed, `OrderedVisibleCells` deduped, each reachable cell present once. 3. **No membership regression.** `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit`, `Build_IsDeterministic_*`, `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95 over-inclusion guard), and the cellar/window/look-in tests stay GREEN. The 4 physics rest-stability guards stay GREEN. 4. **Visual gate (user) — acceptance.** At the cottage doorway: walk through and turn the camera — interior rooms render steadily, no battling/popping; `[pv-input]` flood stable per eye pose; `[portal-churn]` `maxPop` ≤ a small constant (no near-16 churn). Then strip the `[portal-churn]`/`[flap]`/`[pv-trace]` apparatus. `dotnet build` + `dotnet test` green before the visual gate. --- ## 6. Scope / non-goals / risks - **In scope:** `PortalVisibilityBuilder` bounded-growth (4.1) + cap removal (4.2) in both `Build` and `BuildFromExterior`; the new tests. - **Under-inclusion risk + mitigation:** an over-aggressive "covered" test could drop a genuinely-visible cell (a hole). Mitigation: "covered" is conservative (drop a candidate's growth only when fully covered); the #95 over-inclusion guard, the eye-standing/look-in/cellar tests, and the new eye-sweep test (must not drop a cell mid-sweep) bound both directions. Surface any test tension during implementation; do not weaken a test to pass. - **§4 camera (deferred, separate divergence):** the eye floating edge-on (retail's eye is pulled in, collided 93 % at the doorway — `flap-cam-measure.log`) can make the churn fire more often, but is **not** required for this fix — the churn is a real flood bug at any eye position. Revisit as a follow-up only if a residual remains after R-A2b. - **No** rooting / clip-math-rewrite / seal / physics change. --- ## 7. Apparatus + references - **Captures (untracked, large):** `launch-churn-confirm.log` (this session's walk-through — `maxPop=16`, 44 % churn); `launch-camprobe.log` (`[pv-trace]` `0171↔0173` churn detail); `flap-churn.log` (the `maxPop=1` camera-turn-at-rest = the wrong reproduction that mis-shelved the spec). - **Analyzers (throwaway):** `analyze_flap_vis.py` (same-root vs root-swap split), `analyze_churn_confirm.py` (maxPop distribution + flap reproduction). - **Probes:** `ACDREAM_PROBE_FLAP=1` (`[flap]` / `[pv-trace]`), `ACDREAM_PROBE_PORTAL_CHURN=1` (`[portal-churn]` per-Build maxPop + reciprocal pre→post). Strip after the visual gate. - **Retail anchors:** `ConstructView` `:433750`, `ClipPortals` `:433572`, `AddViewToPortals` `:433446`, `AddToCell` `:433050`, `FixCellList` `:433407`, `AdjustCellView` `:433741`, `OtherPortalClip` `:433524`, `copy_view` `:344784`, `GetClip` `:432344`, `polyClipFinish` `:702749`. - **Revived (banners redirected here):** `2026-06-08-portal-flood-enqueue-once-port-design.md` (REVISION = bounded propagation), `2026-06-08-portal-flood-bounded-propagation.md` (Phase 1 done; Phase 2 = this). - **Memory to correct after ship:** `project_indoor_flap_rootcause` — the churn is confirmed at flap-time (`maxPop=16`); the "churn refuted (maxPop=1)" verdict was a non-flapping (camera-turn-at-rest) sample.