acdream/docs/superpowers/plans/2026-06-02-phase-w-membership-flicker-fix.md
Erik 50b168bc1e docs(render): Phase W chunk-1 plan — transition-owned membership flicker fix
Bite-sized TDD plan for design Stages 0-1 + W2b revert + visual gate: add the
[cell-swept] diagnostic, return the swept sp.CurCellId from ResolveWithTransition
(retail SetPositionInternal), revert the superseded W2b hysteresis, visual-gate the
doorway/cellar strobe, then lock it with a doorway replay regression. Render chunk
(Stages 3-5) gets its own spec+plan after this gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:04:45 +02:00

176 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase W (rev) — Chunk 1: Transition-owned membership (flicker fix) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Stop the cell-membership ping-pong (`0xA9B40170↔0xA9B40031` at the cottage door, plus vestibule↔room and cellar) by returning the transition's *swept* cell from `ResolveWithTransition` instead of statically re-deriving it — matching retail `SetPositionInternal`.
**Architecture:** Design `docs/superpowers/specs/2026-06-02-phase-w-transition-membership-and-pview-render-design.md` §13. Retail carries `sphere_path.curr_cell` through the collision sweep (`validate_transition` advances on an accepted move, reverts on a block) and commits it; it never re-derives from the resting position. acdream already latches the swept cell in `SpherePath.CurCellId` via `Transition.ValidateTransition` (`TransitionTypes.cs:3404-3434`) and `CheckOtherCells` (`:2061-2075`); the only defect is that `PhysicsEngine.ResolveWithTransition` discards it and re-derives via `ResolveCellId(sp.GlobalSphere[0].Origin, …)` (`PhysicsEngine.cs:909`/`:928`). This chunk fixes the consumer; it does **not** touch collision math, the `CELLARRAY`/`do_not_load_cells` prune (Stage 2, secondary), or render (Stages 35).
**Tech Stack:** C# .NET 10, xUnit. Branch `claude/thirsty-goldberg-51bb9b` (unpushed — do NOT push). Every commit ends with `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
**Acceptance:** the decisive gate is **visual**`[cell-transit]` stays stable at the doorway/cellar (no ping-pong) while the player stands/jitters at the threshold, and walking room↔cellar↔outside produces clean single transitions. No regression to collision or outdoor walking. Chunk 1 fixes the *strobe*; it does NOT seal the interior (the bluish/unsealed look is Stages 35).
---
## File Structure
- Modify `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — add the `ProbeSweptEnabled` flag (mirror `ProbeCellEnabled`).
- Modify `src/AcDream.Core/Physics/PhysicsEngine.cs` — add the `[cell-swept]` probe in `ResolveWithTransition` (Task 1); switch the two return sites to the swept cell (Task 2).
- Revert commit `2acd8f9` (W2b) — `PhysicsEngine.cs` prune/helper/const + `CellGraphMembershipTests.cs` hysteresis tests (Task 3).
- Tests: `tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs` (Task 1 flag); a doorway replay regression in `tests/AcDream.Core.Tests/Physics/` from a capture taken at the gate (Task 5).
---
## Task 1: Stage-0 diagnostic probe (`[cell-swept]`) — zero behavior change
**Files:** Modify `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, `src/AcDream.Core/Physics/PhysicsEngine.cs`; Test `tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs`.
- [ ] **Step 1: Read the existing pattern.** Read `PhysicsDiagnostics.cs` and find `ProbeCellEnabled` (env var `ACDREAM_PROBE_CELL`, a static bool read at startup + runtime-toggleable). Read `PhysicsEngine.cs:867-899` (the existing `ProbeResolveEnabled` `[resolve]` block) — you will mirror its shape. Confirm `SpherePath` exposes `CurCellId`, `CheckCellId`, `CurPos`, `CheckPos` (`TransitionTypes.cs:328-336`). Report if any differ.
- [ ] **Step 2: Write the failing test** in `tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs` (add to the existing class; mirror how the existing tests assert a probe flag default). Example:
```csharp
[Fact]
public void ProbeSweptEnabled_DefaultsToFalse()
{
// The swept-cell probe (ACDREAM_PROBE_SWEPT) must be OFF unless explicitly enabled,
// so production pays only one static-bool read per ResolveWithTransition call.
PhysicsDiagnostics.ProbeSweptEnabled = false;
Assert.False(PhysicsDiagnostics.ProbeSweptEnabled);
}
```
> If `PhysicsDiagnosticsTests` asserts flags differently (e.g. via an env-parse helper), match that style. Keep the assertion: the new flag exists and defaults false.
- [ ] **Step 3: Run, verify FAIL:** `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~PhysicsDiagnosticsTests"` — fails to compile (`ProbeSweptEnabled` undefined).
- [ ] **Step 4: Add the flag.** In `PhysicsDiagnostics.cs`, mirror `ProbeCellEnabled` exactly, for env var `ACDREAM_PROBE_SWEPT`:
```csharp
/// <summary>
/// Phase W Stage 0 (2026-06-02): one [cell-swept] line per ResolveWithTransition call —
/// the transition's swept cell (sp.CurCellId/CheckCellId) vs the position-derived cell the
/// (legacy) static ResolveCellId path used. Lets a single doorway walk prove the swept cell
/// is stable where the static one strobes (Codex "verify before delete"). Env ACDREAM_PROBE_SWEPT=1.
/// </summary>
public static bool ProbeSweptEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1";
```
- [ ] **Step 5: Run, verify PASS** (same filter).
- [ ] **Step 6: Add the probe line.** In `PhysicsEngine.ResolveWithTransition`, AFTER `ok` is known and `sp`/`ci` are in scope (just after the existing `ProbeResolveEnabled` block ~`:899`), add — note this logs both swept fields and the position; it does NOT call `ResolveCellId` (which has a `CellGraph.CurrCell` side-effect) and does NOT change the return:
```csharp
if (PhysicsDiagnostics.ProbeSweptEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[cell-swept] ent=0x{movingEntityId:X8} ok={ok} inCell=0x{cellId:X8} " +
$"curCell=0x{sp.CurCellId:X8} checkCell=0x{sp.CheckCellId:X8} " +
$"curPos=({sp.CurPos.X:F3},{sp.CurPos.Y:F3},{sp.CurPos.Z:F3}) " +
$"checkPos=({sp.CheckPos.X:F3},{sp.CheckPos.Y:F3},{sp.CheckPos.Z:F3})"));
}
```
- [ ] **Step 7: Build green + confirm zero behavior change.** `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug` (0 errors). `rg "ProbeSweptEnabled" src` shows only the declaration + this read. Commit:
```
git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs
git commit -m "feat(core): Phase W Stage 0 — [cell-swept] diagnostic (swept vs static cell, no behavior change)"
```
---
## Task 2: Stage-1 transition-owned membership return (BEHAVIOR-CHANGING)
**Files:** Modify `src/AcDream.Core/Physics/PhysicsEngine.cs`.
- [ ] **Step 1: Confirm the return sites + the W2a wiring.** Read `PhysicsEngine.ResolveWithTransition` `:901-932`. Confirm the OK path builds `new ResolveResult(sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), …)` (`~:907-912`) and the partial path uses `partialCellId` + `ResolveCellId(...)` (`~:925-931`). Confirm `SetCurrAndReturn(uint)` (`:265`) writes `DataCache.CellGraph.CurrCell = GetVisible(id)` then returns the id (this is the W2a render-read wiring we must KEEP). Report the exact lines.
- [ ] **Step 2: Switch both return sites to the swept cell** via `SetCurrAndReturn` (preserves the W2a `CurrCell` write that render reads). OK path:
```csharp
resolveResult = new ResolveResult(
sp.CheckPos,
// Phase W Stage 1: return the transition's SWEPT cell (retail SetPositionInternal
// reads sphere_path.curr_cell), not a static re-derive from the resting origin.
// ValidateTransition advances sp.CurCellId only on accepted moves and reverts on
// blocks, so a push-back/standing-still cannot flip it. SetCurrAndReturn keeps the
// W2a CellGraph.CurrCell write the render root consumes.
SetCurrAndReturn(sp.CurCellId),
onGround,
collisionNormalValid,
collisionNormal);
```
Partial path (replace the `ResolveCellId(...)` call; keep the `partialCellId` fallback for the degenerate `CurCellId == 0` case):
```csharp
resolveResult = new ResolveResult(
sp.CheckPos,
SetCurrAndReturn(sp.CurCellId != 0 ? sp.CurCellId : partialCellId),
partialOnGround,
collisionNormalValid,
collisionNormal);
```
Leave everything else (`partialCellId` computation, `onGround`, the probes) unchanged. Do NOT delete `ResolveCellId` — it stays for the seed/teleport callers (verify in Step 3).
- [ ] **Step 3: Confirm `ResolveCellId` still has its seed callers.** `rg "ResolveCellId\(" src` — there must remain at least the spawn/teleport/server-set seed path outside `ResolveWithTransition` (e.g. in `PlayerMovementController`/`GameWindow`). If `ResolveWithTransition` was the ONLY caller, leave `ResolveCellId` in place anyway (seed path may call it later) and note it as DONE_WITH_CONCERNS. Do not delete it.
- [ ] **Step 4: Build + full Core suite.** `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug` (0 errors). `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug` — compare failures to the documented static-leak/stale-capture baseline (PhysicsResolveCapture/PhysicsDiagnostics statics; CellarUp/DoorBug document-the-bug captures). Report any NEW failure in a cell/physics/transition class (that would be a regression). The #98 cottage-floor-cap regression test must still pass.
- [ ] **Step 5: Commit:**
```
git add src/AcDream.Core/Physics/PhysicsEngine.cs
git commit -m "feat(core): Phase W Stage 1 — return swept sp.CurCellId from ResolveWithTransition (retail SetPositionInternal)"
```
---
## Task 3: Revert W2b (superseded)
**Files:** Revert commit `2acd8f9`.
- [ ] **Step 1: Revert.** W2b (the `DoorwayHoldMargin` band + `SphereOverlapsEnvCell` + the prune block inside `ResolveCellId`, plus its 3 hysteresis tests) is superseded — the prune was the wrong mechanism in the wrong place (design §1 corollary). Revert it cleanly:
```
git revert --no-edit 2acd8f9
```
If the revert conflicts (because Task 2 edited nearby lines in `PhysicsEngine.cs`), resolve by **keeping Task 2's swept-return changes** and removing ONLY W2b's additions (`DoorwayHoldMargin` const, `SphereOverlapsEnvCell` helper, the `if (DataCache?.CellGraph.CurrCell is …EnvCell prevCell …) return SetCurrAndReturn(prevCell.Id);` block in the outdoor branch) and the W2b tests in `CellGraphMembershipTests.cs`. The W2 Task-1 test (`ResolveCellId_Resolved_WritesCurrCellTrackingTheResolvedId`) and `SetCurrAndReturn` STAY.
- [ ] **Step 2: Build + suite green** (same commands as Task 2 Step 4). Confirm `rg "DoorwayHoldMargin|SphereOverlapsEnvCell" src` returns nothing.
- [ ] **Step 3: Commit** (if `git revert` already committed, amend the message or leave it; if resolved manually, commit):
```
git commit -m "revert(core): Phase W — drop W2b doorway hysteresis (superseded by transition-owned membership)" --allow-empty
```
(Skip if `git revert --no-edit` already produced the commit.)
---
## Task 4: Visual gate (STOP — the acceptance test) + capture
- [ ] **Step 1: Build the App.** `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` — 0 errors before launching.
- [ ] **Step 2: Launch with the probes + a resolve capture** (per CLAUDE.md "Running the client"), adding `ACDREAM_PROBE_CELL=1 ACDREAM_PROBE_SWEPT=1 ACDREAM_CAPTURE_RESOLVE=<repo>/doorway-capture.jsonl`, piped via `Tee-Object` to `launch-w-stage1.log`. Run in the background.
- [ ] **Step 3: STOP for the user.** The user walks `+Acdream` to the cottage front-door threshold, jitters/straddles it, then walks room↔cellar↔outside. Acceptance: `[cell-transit]` stays stable (no `0170↔0031`/`0170↔0171` flip at a static spot); `[cell-swept]` shows `curCell` matching (it was the stable source all along). Read `launch-w-stage1.log` with PowerShell `Get-Content` / the ripgrep Grep tool (UTF-16 — NOT GNU grep). **Do not proceed until the user confirms the strobe is gone.** Remind the user the interior will still look unsealed/bluish (that's Stages 35).
---
## Task 5: Lock the fix with a doorway replay regression test
**Files:** Test in `tests/AcDream.Core.Tests/Physics/` (new `DoorwayMembershipReplayTests.cs`) + a fixture from the Task-4 capture.
- [ ] **Step 1: Confirm the replay harness API.** Read `tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs` + `src/AcDream.Core/Physics/PhysicsResolveCapture.cs` to see how a captured `ResolveCall*` JSONL record is loaded and replayed through `engine.ResolveWithTransition(...)`, and how the returned `ResolveResult.CellId` is read. Report the exact load + replay calls.
- [ ] **Step 2: Distill a fixture.** From `doorway-capture.jsonl` (Task 4), extract the contiguous run of records at the threshold (the ~8 cm jitter band, world XY ≈ (155.x, 16.x)). Save a trimmed fixture under `tests/AcDream.Core.Tests/Fixtures/` (mirror the issue98 fixture layout). Keep it small (the jitter run, ~2040 records).
- [ ] **Step 3: Write the regression test** that replays the fixture through `ResolveWithTransition` and asserts **no immediate ping-pong**: across the threshold-jitter run, the returned `CellId` must not flip `A→B→A` within a short window while the position stays within the jitter band (assert the distinct-cell-transition count over the run is ≤ 1, matching retail's accept-on-move). Use the exact harness calls from Step 1. (This is the automated lock; the visual gate is the primary acceptance.)
- [ ] **Step 4: Run, verify PASS.** `dotnet test … --filter "FullyQualifiedName~DoorwayMembershipReplayTests"`. (Sanity: confirm the SAME fixture replayed through the *old* static `ResolveCellId` path would have flipped — note it in the test doc comment; do not keep the old path.)
- [ ] **Step 5: Commit:**
```
git add tests/AcDream.Core.Tests/Physics/DoorwayMembershipReplayTests.cs tests/AcDream.Core.Tests/Fixtures/<doorway-fixture>
git commit -m "test(core): Phase W Stage 1 — doorway replay regression locks no-ping-pong membership"
```
---
## Task 6: Roadmap + handoff
- [ ] Update `docs/plans/2026-04-11-roadmap.md` Phase W table: W2 reframed (transition-owned membership shipped; W2b reverted as superseded). Note chunk 2 (render: Stages 35) is next, pending its own spec.
- [ ] Commit the roadmap update.
---
## Self-Review
- **Spec coverage:** design Stage 0 (Task 1), Stage 1 (Task 2), W2b revert (Task 3, design §1 corollary), visual gate (Task 4, design §6), automated lock (Task 5). Stage 2 (CELLARRAY parity) + Stages 35 (render) are explicitly deferred to chunk 2 — this plan is scoped to the flicker fix. ✓
- **Placeholders:** Tasks 13 carry concrete code. Tasks 1/2/5 have "confirm the live API first" steps because the probe pattern, the exact return-site lines, and the replay-harness API are verify-then-use against real code (the W2-plan pattern); concrete code/assertions are supplied for each. Task 5's fixture depends on the Task-4 capture by design (evidence-first). ✓
- **Type consistency:** `SpherePath.CurCellId`/`CheckCellId`/`CurPos`/`CheckPos`, `SetCurrAndReturn(uint)→uint` (W2a), `ResolveResult(Vector3, uint, bool, bool, Vector3)`, `PhysicsDiagnostics.ProbeSweptEnabled` (new). ✓
- **No collision-math change:** Task 2 changes only the returned cell id; the sweep, `FindEnvCollisions`, `CheckOtherCells`, and `ResolveResult.Position`/`onGround` are untouched. ✓