acdream/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md
Erik 3fd71a123c docs(render): R-A2b spec — revive bounded-propagation, churn confirmed at flap-time
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>
2026-06-09 09:48:53 +02:00

195 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
---
## 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 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 prepost). 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.