docs(render): R-A2b plan — back-portal side-cull (Option B), verify-first B1/B2 pin

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-09 10:25:28 +02:00
parent 3fd71a123c
commit 7b8a490da9
2 changed files with 321 additions and 6 deletions

View file

@ -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<uint, List<bool>>();
int steps = 40;
for (int s = 0; s < steps; s++)
{
var a = MakeA(); var b = MakeB();
var all = new Dictionary<uint, LoadedCell> { [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<uint>(frame.OrderedVisibleCells);
foreach (var c in new[] { A, B })
{
if (!seen.TryGetValue(c, out var run)) { run = new List<bool>(); 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<uint, LoadedCell> { [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<uint,int>();` (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.