R-A2b (485e44d) killed the 0171<->0173 churn (maxPop 16->1, measured). Visible flap residual is sec 4 (edge-on openings render-side + corner camera-seal). Camera-damping tried+failed+reverted. The white-walls scare was a RED HERRING: heavy per-frame probes (ACDREAM_PROBE_FLAP) starve the thread-unsafe dat-reader so texture-decode loses the race -> white; a clean launch (no probes) fixes it. The dat-reader thread-safety bug is the real underlying issue (filed). Repo clean at HEAD.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
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(inBuild, right afterbool eyeInsideOpening = EyeInsidePortalOpening(...), ~line 202) -
Step 1: Add the probe line (throwaway apparatus; fires only under the existing
PortalBuildTrace, which is gated onProbeFlapEnabled+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 buildgreen, then (background, tee toflap-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
0171traversing back to0171): grepflap-sidechk.logforsidechk cell=0xA9B40173 p.*->0x0171AND the adjacentportal cell=0xA9B40173 p.*->0xA9B40171 addCell(traversed, notskip=side). Read itscamInterior/eyeIn/D:eyeIn=True(and|D| <= 1.75) → B1:EyeInsidePortalOpeningis bypassing the side-cull.eyeIn=FalseANDcamInterior=True(|D| > 1.75) → B2:CameraOnInteriorSidereturns interior where retail culls. Also record the back portal'sDsign + (from a one-off log or the cell fixture) itsClipPlanes[p].InsideSideandNormal, so the exact convention edit is known.- (If both back-portal directions show
skip=sideand 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/Buildhelpers in this test file. The portal-side data (CellPortalInfoctor args +ClipPlanes) must reproduce the pinned mechanism: for B2, set the back portal's side so our currentCameraOnInteriorSidereturns interior (the bug); for B1, place the sweep within 1.75 m soEyeInsidePortalOpeningfires. Assert each cell's membership across the sweep is a single contiguous run (nopresent→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
OrderedVisibleCellsdeduped + 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):
Buildside-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):
BuildFromExteriorside-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/Dfor 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 currentdot >= -eps/dot <= epsmakes 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-portalInsideSide+Dsign — 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) tofront/!frontis confirmed against the captured back-portal data in Task 2 (the back portal MUST end up!interior, the forward portalinterior). 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, includingBuild_EyeStandingInInteriorPortal_FloodsNeighbour,Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour,Build_DegeneratePortalToTheSide_NotFlooded_NoOverInclusion(#95),Build_ViewGrowthAfterDoneCell_*,Build_IsDeterministic_*.- If
Build_EyeStandingInInteriorPortal_FloodsNeighbourFAILS 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.
- If
-
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
OrderedVisibleCellsis 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
MaxReprocessPerCellconst, thepopCountsdictionaries, the per-poppopCountsincrements, 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.csprojanddotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj(App~Rendering207+, 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
sidechkprobe line. Decide whether to keep[portal-churn]/[flap]/[pv-trace](they are pre-existing R-A diagnostics) or strip per the spec — strip thesidechkaddition 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'sInitCellside test culls the back portal; fix = match it. The "churn refuted (maxPop=1)" was a camera-turn-at-rest sample (overturned:maxPop=16on 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.