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>
176 lines
15 KiB
Markdown
176 lines
15 KiB
Markdown
# 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` §1–3. 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 3–5).
|
||
|
||
**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 3–5).
|
||
|
||
---
|
||
|
||
## 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 3–5).
|
||
|
||
---
|
||
|
||
## 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, ~20–40 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 3–5) 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 3–5 (render) are explicitly deferred to chunk 2 — this plan is scoped to the flicker fix. ✓
|
||
- **Placeholders:** Tasks 1–3 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. ✓
|