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>
This commit is contained in:
parent
8f879bd7d9
commit
3fd71a123c
3 changed files with 223 additions and 10 deletions
|
|
@ -1,6 +1,17 @@
|
|||
> **⛔ SUPERSEDED / REFUTED 2026-06-08 (evening). DO NOT IMPLEMENT.** Both this spec's directions
|
||||
> (enqueue-once AND the revised bounded-propagation) are dead: live measurement found ZERO portal
|
||||
> re-enqueue churn at the flap (`maxPop=1`). The flap is a STRUCTURAL divergence — retail renders
|
||||
> **✅ REVIVED 2026-06-09 — the REVISION banner (bounded propagation) is the live design; see
|
||||
> [`2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md`](2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md).**
|
||||
> The `⛔` banner below was wrong: the `maxPop=1` "refutation" was a **camera-turn-at-rest** capture (the
|
||||
> calm position, root `0172`), NOT a doorway crossing. A 2026-06-09 walk-through re-capture
|
||||
> (`launch-churn-confirm.log`) measured `maxPop=16` on 44 % of frames — the churn is confirmed at
|
||||
> flap-time. The "enqueue-once" half (§4/§5) stays dead (re-processing IS retail-faithful); the
|
||||
> **bounded-propagation** half (REVISION banner) is what ships, as R-A2b.
|
||||
>
|
||||
> ---
|
||||
>
|
||||
> **⛔ (HISTORICAL — corrected above) SUPERSEDED / REFUTED 2026-06-08 (evening).** Both this spec's directions
|
||||
> (enqueue-once AND the revised bounded-propagation) were called dead: live measurement found ZERO portal
|
||||
> re-enqueue churn at the flap (`maxPop=1`) — **but that sample was the wrong reproduction (see the revival
|
||||
> note above).** The flap is a STRUCTURAL divergence — retail renders
|
||||
> inside+outside through ONE `DrawInside(viewer_cell)` path with no inside/outside branch, and is
|
||||
> robust to its own ~36 µm eye jitter via many small per-building floods. Decision: full retail port
|
||||
> (Option A). Canonical:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
# 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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue