The indoor doorway flap is the portal flood's re-enqueue churn (0171<->0173 mutual re-contribution; drifted near-duplicate regions AddRegion won't dedup -> grew -> re-enqueue, capped at MaxReprocessPerCell=16 -> eye-sensitive flood depth -> grey flash). Confirmed live: launch-churn-confirm.log shows maxPop=16 on 44% of frames during a doorway walk-through. The 2026-06-08 'maxPop=1, churn refuted' verdict was a camera-turn-at-rest capture (wrong reproduction); its DO-NOT is overturned. Fix (Option A, user-approved): contributions already covered by the neighbour's accumulated view don't grow it (no re-enqueue); only the uncovered remainder propagates -- retail's 'redundant -> empty before copy_view' (copy_view confirmed to just append). Remove MaxReprocessPerCell; keep re-processing of genuinely-new slices. Scope: PortalVisibilityBuilder only. Revives 2026-06-08 spec+plan (banners redirected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
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") anddocs/superpowers/plans/2026-06-08-portal-flood-bounded-propagation.md. Both were marked⛔ SUPERSEDEDon the strength of a singlemaxPop=1capture 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 measuredmaxPop=16on a fifth of frames — the churn is real. Their banners are corrected to redirect here.
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 (Option A — approved): port retail's bounded propagation. A candidate contribution that is
already covered by the neighbour's accumulated view does not count as growth (no re-enqueue); only the
uncovered remainder propagates. This mirrors retail, where a redundant contribution clips to empty
before copy_view appends it, so the flood terminates structurally. Remove the MaxReprocessPerCell +
popCounts band-aid (termination is now by construction). Keep re-processing of genuinely-new slices.
Scope: PortalVisibilityBuilder only — no camera, rooting, clip-math, or seal change.
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 tocell_draw_list(= membership),ClipPortals(cell, 0), thenAddViewToPortals.PView::ClipPortals(:433572): processes the cell's view slices[update_count, view_count); per portalGetClip; 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, thenprocessed_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 toOutsideView.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)
- 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 the0171↔0173mutual aperture). Assert each cell's membership across the sweep is a single contiguous run — nopresent→absent→presentflicker — and, if surfaced, per-cell pop count ≤ a small constant. RED under the churn, GREEN after the bound. - Termination without the cap. Diamond + cycle fixtures: assert the flood terminates with
MaxReprocessPerCellremoved,OrderedVisibleCellsdeduped, each reachable cell present once. - 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. - 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:
PortalVisibilityBuilderbounded-growth (4.1) + cap removal (4.2) in bothBuildandBuildFromExterior; 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↔0173churn detail);flap-churn.log(themaxPop=1camera-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.