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:
parent
3fd71a123c
commit
7b8a490da9
2 changed files with 321 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue