# 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`. **PINNED (Phase 1 Task 2, 2026-06-09 capture `flap-sidechk.log`): B1 — the `eyeInsideOpening` bypass.** Every back portal (`0173→0171` at `D=-1.51…-1.70`; `0172→0173` at `D=1.71`) shows `camInterior=False` (our `CameraOnInteriorSide` already agrees with retail — it WANTS to cull) and is traversed **only when `eyeIn=True`** (eye within 1.75 m of the shared doorway). At `D=-2.32` (farther, `eyeIn=False`) the same back portal is correctly culled. So the cycle is the `&& !eyeInsideOpening` bypass. Forward portals (`0171→0173`) show `camInterior=True` (unaffected; the clip-empty void rescue is preserved). **Fix = Branch B1 (Task 4): drop `&& !eyeInsideOpening` from the side-cull.** B2 (side-test convention) is NOT needed. --- ## 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.