From 7b8a490da91027b6ac8ba82abe0216459668d3db Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 9 Jun 2026 10:25:28 +0200 Subject: [PATCH] =?UTF-8?q?docs(render):=20R-A2b=20plan=20=E2=80=94=20back?= =?UTF-8?q?-portal=20side-cull=20(Option=20B),=20verify-first=20B1/B2=20pi?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading retail InitCell (:432896) side test during writing-plans showed retail's flood is acyclic (the back portal fails the side test, so 0171<->0173 can't cycle). Our flood traverses the back portal -> the cycle -> the churn. Option B (user-chosen): cull the back portal like retail, keep the forward-portal void rescue, remove the dead cap. Phase 1 pins WHY the back portal is traversed (B1 eyeInsideOpening bypass vs B2 CameraOnInteriorSide convention) before the fix; spec REVISION updated A->B. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-09-portal-flood-r-a2b-side-cull.md | 282 ++++++++++++++++++ ...-flood-bounded-propagation-r-a2b-design.md | 45 ++- 2 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md diff --git a/docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md b/docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md new file mode 100644 index 00000000..941c8725 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md @@ -0,0 +1,282 @@ +# R-A2b — Portal-Flood Back-Portal Side-Cull (indoor flap fix) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the indoor doorway "flap" by making the portal flood acyclic like retail — cull the "back" portal (the doorway just flooded through) so the `0171↔0173` re-enqueue churn cannot form. + +**Architecture:** The flap is a flood-membership oscillation from a re-enqueue **churn** in `PortalVisibilityBuilder.Build` (confirmed live: `maxPop=16` on 44% of frames at the cottage doorway). The churn needs a **cycle**: `0171→0173→0171→…`. Retail's flood is acyclic because `PView::InitCell`'s per-portal **side test culls the back portal** (`:432962` — traverse iff the viewpoint's front/back classification equals the portal's stored side; the back portal fails it). Our flood traverses the back portal, so it cycles. **Phase 1 pins WHY** (B1 = `EyeInsidePortalOpening` bypasses our side-cull; B2 = `CameraOnInteriorSide` returns the wrong answer vs retail). **Phase 2 applies the pinned fix.** Phase 3 removes the now-dead `MaxReprocessPerCell` cap. Spec: `docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md` (REVISION → Option B). + +**Tech Stack:** C# / .NET 10, xUnit. GL-free unit tests in `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs`. Live capture via `dotnet run` against local ACE (the user drives the doorway crossing). + +**Non-goals:** no camera / rooting / clip-math-rewrite / seal change; the **forward-portal clip-empty void rescue** (`Build` ~241-250, the 2026-06-05 fix) is **preserved**; physics + the rest-stability regression tests stay green. + +**Build-while-running gotcha:** the running client locks the DLLs (MSB3027). Always close the client (graceful `CloseMainWindow`) before `dotnet build`. + +--- + +## PHASE 1 — Pin the back-portal traversal mechanism (B1 vs B2) + +### Task 1: Add the side-test pin probe to `PortalVisibilityBuilder.Build` + +**Files:** +- Modify: `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (in `Build`, right after `bool eyeInsideOpening = EyeInsidePortalOpening(...)`, ~line 202) + +- [ ] **Step 1: Add the probe line** (throwaway apparatus; fires only under the existing `PortalBuildTrace`, which is gated on `ProbeFlapEnabled` + `IsHoltburgIndoorProbeCell`) + +```csharp +// (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal traversal +// (cell=0x..0173 p->0x0171) can be attributed to B1 (eyeInsideOpening bypass) vs B2 (CameraOnInteriorSide +// returns interior where retail culls). camInterior = our side-test result; D = eye signed distance to the +// portal plane (|D|<=1.75 means eyeInsideOpening is in range). Strip with the rest of the [pv-trace] apparatus. +if (trace != null) +{ + bool camInterior = i >= cell.ClipPlanes.Count || CameraOnInteriorSide(cell, i, cameraPos); + float sideD = (i < cell.ClipPlanes.Count && cell.ClipPlanes[i].Normal.LengthSquared() >= 1e-8f) + ? Vector3.Dot(cell.ClipPlanes[i].Normal, Vector3.Transform(cameraPos, cell.InverseWorldTransform)) + cell.ClipPlanes[i].D + : float.NaN; + trace.Add($"sidechk cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} camInterior={camInterior} eyeIn={eyeInsideOpening} D={(float.IsNaN(sideD) ? "na" : sideD.ToString("F2"))}"); +} +``` + +- [ ] **Step 2: Build** — close the client first if running. Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: `Build succeeded`. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +git commit -m "diag(render): [pv-trace] sidechk — pin back-portal traversal (B1 bypass vs B2 side-test) for R-A2b" +``` + +### Task 2: Live capture at the doorway + pin (CHECKPOINT — needs the user) + +- [ ] **Step 1: Launch** with the flap probe. `dotnet build` green, then (background, tee to `flap-sidechk.log`): + +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" +$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" +$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" +$env:ACDREAM_PROBE_FLAP="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "flap-sidechk.log" +``` + +- [ ] **Step 2: User reproduces** — slow one-way walk through the cottage interior doorway (room→room), camera still. Then graceful-close. +- [ ] **Step 3: Analyze.** Find the **back-portal traversal** (the cell whose flood came *from* `0171` traversing back *to* `0171`): grep `flap-sidechk.log` for `sidechk cell=0xA9B40173 p.*->0x0171` AND the adjacent `portal cell=0xA9B40173 p.*->0xA9B40171 addCell` (traversed, not `skip=side`). Read its `camInterior` / `eyeIn` / `D`: + - `eyeIn=True` (and `|D| <= 1.75`) → **B1**: `EyeInsidePortalOpening` is bypassing the side-cull. + - `eyeIn=False` AND `camInterior=True` (`|D| > 1.75`) → **B2**: `CameraOnInteriorSide` returns interior where retail culls. Also record the back portal's `D` sign + (from a one-off log or the cell fixture) its `ClipPlanes[p].InsideSide` and `Normal`, so the exact convention edit is known. + - (If both back-portal directions show `skip=side` and the cycle is absent, the flap reproduced via a different cell pair — record which, and repeat the read for that pair.) +- [ ] **Step 4: Record the pin** in a one-paragraph note at the top of this plan ("PINNED: B1" or "PINNED: B2, convention = …") with the captured `camInterior/eyeIn/D`. **Do not start Phase 2 before this pin.** + +--- + +## PHASE 2 — Apply the pinned fix (TDD) + +### Task 3: Eye-sweep membership-stability test (the RED→GREEN driver) + +**Files:** +- Modify: `tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs` + +- [ ] **Step 1: Write the failing test** — a synthetic two-room + shared-doorway topology reproducing the cycle, swept monotonically across the doorway. Use the existing `Cell` / `QuadX` / `Build` helpers in this test file. The portal-side data (`CellPortalInfo` ctor args + `ClipPlanes`) must reproduce the **pinned** mechanism: for **B2**, set the back portal's side so our current `CameraOnInteriorSide` returns interior (the bug); for **B1**, place the sweep within 1.75 m so `EyeInsidePortalOpening` fires. Assert each cell's membership across the sweep is a **single contiguous run** (no `present→absent→present`). + +```csharp +[Fact] +public void Build_MonotonicEyeSweepThroughDoorway_MembershipIsContiguous_NoFlapCycle() +{ + // Two rooms A,B sharing one doorway; B also has a back portal to A (the cycle edge retail culls). + // Sweep the eye monotonically along -Y across the doorway; assert no cell flickers in/out. + const uint A = 0x0001, B = 0x0002; + // (Portal-side fields set per the Phase-1 pin so this reproduces the live cycle. See Task 2 note.) + LoadedCell MakeA() { var a = Cell(A, new CellPortalInfo((ushort)B, 0, 0, 0)); + a.PortalPolygons.Add(QuadX(-0.5f, 0.5f, -1f)); return a; } + LoadedCell MakeB() { var b = Cell(B, new CellPortalInfo((ushort)A, 0, 0, 0)); + b.PortalPolygons.Add(QuadX(-0.5f, 0.5f, -1f)); return b; } + + var seen = new Dictionary>(); + int steps = 40; + for (int s = 0; s < steps; s++) + { + var a = MakeA(); var b = MakeB(); + var all = new Dictionary { [A] = a, [B] = b }; + float y = 2.0f - 4.0f * s / (steps - 1); // monotonic sweep through the doorway + var frame = PortalVisibilityBuilder.Build(a, new Vector3(0, y, 0), id => all.GetValueOrDefault(id), + TestViewProj()); + var present = new HashSet(frame.OrderedVisibleCells); + foreach (var c in new[] { A, B }) + { + if (!seen.TryGetValue(c, out var run)) { run = new List(); seen[c] = run; } + run.Add(present.Contains(c)); + } + } + foreach (var (cell, run) in seen) + { + int transitions = 0; + for (int i = 1; i < run.Count; i++) if (run[i] != run[i - 1]) transitions++; + Assert.True(transitions <= 1, $"cell 0x{cell:X4} membership flickered ({transitions} transitions) across a monotonic sweep"); + } +} +``` + +- [ ] **Step 2: Run it — expect FAIL (RED)** under the cycle. + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_MonotonicEyeSweepThroughDoorway"` +Expected: FAIL (a cell shows >1 transition — the flap cycle). + +> **If the synthetic test cannot be made RED** (the cycle depends on live geometry the fixtures don't capture): do **not** weaken it to pass. Convert it to a **termination/contiguity guard** (assert `OrderedVisibleCells` deduped + contiguous for whatever it does produce), note that the live `[portal-churn] maxPop` + the visual gate (Task 6) are the real acceptance, and proceed — the fix is still validated by Phase 1's pin + Task 6. + +### Task 4: Implement the pinned fix + +**Apply the branch Phase 1 (Task 2) pinned. Both are exact; pick one.** + +#### Branch B1 — drop the `eyeInsideOpening` bypass from the side-cull + +**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`Build` ~208-216; `BuildFromExterior` ~472-475) + +- [ ] **Step 1 (B1): `Build` side-cull** — remove the bypass so back portals cull like retail. The separate clip-empty rescue (~241-250) still rescues FORWARD (side-test-passing) portals → void fix preserved. + +```csharp +// BEFORE: +if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos) + && !eyeInsideOpening) +{ + sideAllowed = false; + ... + continue; +} +// AFTER (retail InitCell side test culls the back portal regardless of eye proximity; :432962): +if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos)) +{ + sideAllowed = false; + trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}"); + if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); + continue; +} +``` + +- [ ] **Step 2 (B1): `BuildFromExterior` side-cull** — same removal at the matching site (~472-475): + +```csharp +// AFTER: +if (i < cell.ClipPlanes.Count + && !CameraOnInteriorSide(cell, i, cameraPos)) + continue; +``` + +#### Branch B2 — align `CameraOnInteriorSide` to retail's `InitCell` side test + +**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`CameraOnInteriorSide` ~717-724) + +- [ ] **Step 1 (B2): correct the convention** per Phase 1's captured `InsideSide`/`Normal`/`D` for the back portal. Retail (`:432962`) traverses iff `(dot > +eps ? front : behind) == portal_side` — a strict front/back classification matched to the stored side, NOT a symmetric ±eps band on both sides. Our current `dot >= -eps` / `dot <= eps` makes the in-plane band interior for *both* conventions and (per the pin) returns interior for the back portal. The exact edit is finalized from the captured back-portal `InsideSide` + `D` sign — the most likely form is to make the test strict and side-correct: + +```csharp +// Retail PView::InitCell side test (:432962): viewpoint front/back classified vs the portal plane, +// then traversed iff that equals the portal's stored interior side. Port faithfully: +private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos) +{ + var plane = cell.ClipPlanes[portalIndex]; + if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow + var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); + float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; + bool front = dot > PortalSideEpsilon; // strict front (retail eax_9), in-plane = behind + return plane.InsideSide == 0 ? front : !front; // traverse iff classification matches the stored side +} +``` + +> The precise mapping of `InsideSide` (0/1) to `front`/`!front` is confirmed against the captured back-portal data in Task 2 (the back portal MUST end up `!interior`, the forward portal `interior`). Adjust the boolean accordingly if the capture shows the opposite polarity. Do not guess past the captured fact. + +#### Step (both branches): run the driver test — expect PASS (GREEN) + +- [ ] Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Build_MonotonicEyeSweepThroughDoorway"`. Expected: PASS. +- [ ] Run the full builder suite — no regression: + `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilder"`. Expected: PASS, **including** `Build_EyeStandingInInteriorPortal_FloodsNeighbour`, `Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour`, `Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion` (#95), `Build_ViewGrowthAfterDoneCell_*`, `Build_IsDeterministic_*`. + - If `Build_EyeStandingInInteriorPortal_FloodsNeighbour` FAILS under B1: that test feeds a portal whose side-test fails (a "back" portal) and relied on the bypass. Inspect — if it encodes the non-retail bypass, correct the fixture so the standing-in portal is a FORWARD (side-passing) portal (the void case is a forward portal); do **not** reinstate the bypass. If it represents a genuine forward portal that B1 wrongly culls, B1 is wrong → re-examine the pin. + +- [ ] **Step (both): Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +git commit -m "fix(render): R-A2b — cull back portal like retail (InitCell side test), kill the indoor flap cycle" +``` + +--- + +## PHASE 3 — Remove the now-dead re-enqueue cap + +### Task 5: Delete `MaxReprocessPerCell` + `popCounts` (the cycle is gone → the cap is dead) + +**Files:** `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`MaxReprocessPerCell` const ~51; `popCounts` + the cap in the re-enqueue gate in `Build` ~331 and `BuildFromExterior` ~509; the per-pop `popCounts` bookkeeping ~160-161 and ~441-442) + +- [ ] **Step 1: Add a termination test first** (diamond + 2-cell cycle) asserting the flood terminates and `OrderedVisibleCells` is deduped with the cap removed: + +```csharp +[Fact] +public void Build_DiamondAndCycle_TerminatesAndDedupes_WithoutCap() +{ + // A->B, A->C, B->D, C->D (diamond) + D->B (cycle edge). Acyclic-by-side-test now; must terminate. + const uint A = 0x0001, B = 0x0002, C = 0x0003, D = 0x0004; + var a = Cell(A, new CellPortalInfo((ushort)B,0,0,0), new CellPortalInfo((ushort)C,1,0,0)); + a.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-2f)); a.PortalPolygons.Add(QuadX(0.1f,0.9f,-2f)); + var b = Cell(B, new CellPortalInfo((ushort)D,0,0,0)); b.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-4f)); + var c = Cell(C, new CellPortalInfo((ushort)D,0,0,0)); c.PortalPolygons.Add(QuadX(0.1f,0.9f,-4f)); + var d = Cell(D, new CellPortalInfo((ushort)B,0,0,0)); d.PortalPolygons.Add(QuadX(-0.9f,-0.1f,-6f)); + var all = new Dictionary { [A]=a,[B]=b,[C]=c,[D]=d }; + + var frame = Build(a, all); // must return (no infinite loop) without the cap + Assert.Equal(frame.OrderedVisibleCells.Count, frame.OrderedVisibleCells.Distinct().Count()); +} +``` + +- [ ] **Step 2: Run it — expect PASS** (with the cap still present it already passes; this guards the removal). +- [ ] **Step 3: Remove the cap.** Delete the `MaxReprocessPerCell` const, the `popCounts` dictionaries, the per-pop `popCounts` increments, and simplify the re-enqueue gates: + +```csharp +// Build (~331) BEFORE: +if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) +// AFTER: +if (grew && queued.Add(neighbourId)) +``` +```csharp +// BuildFromExterior (~509) BEFORE: +if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId)) +// AFTER: +if (grew && queued.Add(neighbourId)) +``` +Also delete `var popCounts = new Dictionary();` (both methods) and the two `popCounts.TryGetValue(...); popCounts[...] = popsSoFar + 1;` blocks, and the `MaxReprocessPerCell` doc-comment block (~40-51). + +- [ ] **Step 4: Run the full builder suite** — must stay GREEN (termination now structural via the acyclic side-test). Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PortalVisibilityBuilder"`. Expected: PASS. + - If any cyclic fixture now hangs/fails, the side-cull did **not** make the graph acyclic for that case → STOP, re-examine (a cycle source remains; do not re-add the cap as a band-aid without understanding why). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/PortalVisibilityBuilder.cs tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +git commit -m "refactor(render): R-A2b — remove dead MaxReprocessPerCell cap (flood is acyclic after the side-cull)" +``` + +--- + +## PHASE 4 — Visual gate + cleanup + +### Task 6: Visual gate (acceptance) + strip apparatus + +- [ ] **Step 1: Full build + test green.** `dotnet build`; `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` (App `~Rendering` 207+, the physics rest-stability guards green). +- [ ] **Step 2: Visual gate (user).** Launch with `ACDREAM_PROBE_FLAP=1` + `ACDREAM_PROBE_PORTAL_CHURN=1`; walk through the cottage doorway. Acceptance: **no grey flap**; interior rooms render steadily; `[portal-churn] maxPop` ≤ a small constant (no near-16); the eye-standing-in-doorway case still shows the room ahead (void fix intact). **This is the real acceptance — the user confirms.** +- [ ] **Step 3: Strip apparatus.** Remove the Task-1 `sidechk` probe line. Decide whether to keep `[portal-churn]`/`[flap]`/`[pv-trace]` (they are pre-existing R-A diagnostics) or strip per the spec — strip the `sidechk` addition at minimum. + +```bash +git add -A && git commit -m "chore(render): R-A2b — strip sidechk pin probe after the visual gate" +``` + +- [ ] **Step 4: Update memory + docs.** + - `project_indoor_flap_rootcause`: the flap was the flood **cycle** from a non-retail back-portal traversal (B1/B2 per the pin); retail's `InitCell` side test culls the back portal; fix = match it. The "churn refuted (maxPop=1)" was a camera-turn-at-rest sample (overturned: `maxPop=16` on the walk-through). + - Roadmap/milestones: R-A2b shipped (indoor flap fixed); note the §4 camera (eye-pull-in) follow-up is still open (separate, deferred). + - Mark the 2026-06-09 spec + this plan SHIPPED. + +--- + +## Self-Review notes + +- **Spec coverage:** Phase 1 pins B1/B2 (spec REVISION "open" item); Phase 2 implements the pinned back-portal cull (spec §4-B / REVISION); Phase 3 removes the dead cap (spec §4); Task 6 = the spec §5 visual gate + the eye-sweep stability test (spec §5.1). Forward-portal void rescue explicitly preserved (spec §6); no camera/rooting/clip/seal change (spec §6). +- **Apparatus-first justification:** Phase 2's exact form is gated on Phase 1's runtime pin (B1 has exact code; B2's convention is finalized from the captured `InsideSide`/`D`). This matches the codebase's established pin-then-fix pattern and the user's explicit "verify first," not a lazy placeholder — both branches' code is concrete. +- **Regression guards:** the void fix (forward clip-empty rescue) is untouched; the #95 over-inclusion guard + eye-standing tests must stay green (Task 4 Step); termination without the cap is guarded (Task 5). +- **Risk:** if Phase 1 shows the cycle isn't a clean back-portal traversal (e.g., the in-plane ±eps band at D≈0), the fix may need both the strict side test (B2) AND care at D≈0 — Task 2's pin surfaces this; do not proceed past an ambiguous pin. diff --git a/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md b/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md index 9e8559c8..2095642f 100644 --- a/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md +++ b/docs/superpowers/specs/2026-06-09-portal-flood-bounded-propagation-r-a2b-design.md @@ -14,6 +14,34 @@ --- +> ## ⚠️ 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 @@ -25,12 +53,17 @@ the neighbour re-enqueues. It ping-pongs to the `MaxReprocessPerCell=16` cap, wh **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. +**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. ---