acdream/docs/superpowers/plans/2026-06-09-portal-flood-r-a2b-side-cull.md
Erik 7b8a490da9 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>
2026-06-09 10:25:28 +02:00

20 KiB

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)

// (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

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):
$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).

[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.
// 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):
// 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:
// 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

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:
[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:
// Build (~331) BEFORE:
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
// AFTER:
if (grew && queued.Add(neighbourId))
// 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

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.
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.