diff --git a/docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md b/docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md
new file mode 100644
index 0000000..810b60a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md
@@ -0,0 +1,481 @@
+# A6.P3 issue #98 — cellar-up fix plan (diagnostic-first)
+
+> **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:** Close issue #98 (sphere stuck on cottage cellar ramp) by identifying the EXACT failure point through a focused diagnostic probe, then writing a minimal evidence-driven fix. The user can walk up and out of a Holtburg cottage cellar in the live client.
+
+**Architecture:** Three phases. Phase 0 re-reads the existing capture for everything the divergence comparison missed — no code. Phase 1 adds ONE focused probe at the only site where existing instrumentation is silent (inside `AdjustOffset`). Phase 2 runs the probe and decides which of three branches the fix must take. Phase 3 writes the fix against the replay harness as TDD oracle.
+
+**Tech Stack:** C# .NET 10. The deterministic replay loop ([Issue98CellarUpReplayTests.cs](../../tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs), <200ms) is the inner test loop. The cdb capture script ([tools/cdb/issue98-runner.ps1](../../tools/cdb/issue98-runner.ps1)) is the outer ground-truth loop.
+
+**Risk:** Four prior sessions guessed-and-shipped (10+ variants). The pattern: convinced of diagnosis → ship fix → user reports "still can't pass." This plan refuses to ship code until the diagnostic data NAMES the failure site.
+
+---
+
+## What the existing log already proves (Phase 0 already partially done)
+
+The slice 7 handoff and the divergence comparison both claimed *"our sphere oscillates at world Z ≈ 92.01 with no altitude gain."* That framing is **wrong**. Scanning the 2,589 `[step-walk]` lines in [a6-issue98-negpoly-20260523-135032.out.log](../../a6-issue98-negpoly-20260523-135032.out.log) shows:
+
+| What the log shows | Implication |
+|---|---|
+| Sphere `cur` Z climbs **90.00 → 92.79** across the capture (2.79 m gain). | The climb works for most of the ramp. Z gain per `[step-walk] site=after-adjust` is +0.197 to +0.227 when offset points INTO the ramp normal. |
+| Climb **caps at world Z ≈ 92.79**, then descends back. | Something at the top of the ramp prevents the last ~1.21 m of climb to cottage floor (world Z=94). |
+| At the peak (line 17458–17485 in the log): `before-insert` has `check=(141.58, 7.18, 92.79)`, `walkPoly=True`. `after-insert` has same position but **`walkPoly=False`** + `winterp=-0.0000`. | `DoStepDown` at the peak burned `WalkInterp` from 1.0 → 0 in one tick and the sphere ended up off the walkable polygon. |
+| `cell=0xA9B40147->0xA9B40147` for every line. | Our sphere never transitions to a cottage cell (`0xA9B40143` / `0xA9B40146`) during the climb. Retail's BPA capture shows queries against cottage cells starting at BPA hit#430. |
+| ContactPlane normal is `(0, +0.719, +0.695)` in our log. The divergence doc says retail's ramp normal is `(0, -0.719, +0.695)` (sign-flipped) — but retail's BPE NEVER writes the ramp as ContactPlane at all (only flat cellar floor or flat cottage floor). | Retail does not use the ramp as a ContactPlane. Our code does. This is a SHAPE-of-the-fix question for later; do not act on it before the diagnostic confirms it matters. |
+
+**Revised hypothesis (informed by the data, not the divergence doc):**
+
+The climb works while the sphere is mid-ramp. It **fails at the top of the ramp** where the polygon ends. Three mutually-exclusive failure modes are plausible:
+
+1. **Geometric cap.** The ramp polygon physically ends at world Z ≈ 92.79; there is no further walkable surface within the step-up reach. Retail's sphere reaches the cottage floor by a transition we never trigger.
+2. **Cell-set divergence.** At world Z ≈ 92.79 the sphere overlaps cottage cell volumes, but our `CheckOtherCells` either doesn't query the cottage cell, or queries it with the wrong sphere reference (foot vs lifted). Retail's BPA hit#430 / hit#434 show queries against TWO different leaves at SIGNIFICANTLY different sphere positions (cell A: local 0.48, cell B: local 0.63) — that's the retail two-step-up pattern at work, which we may not be doing.
+3. **WalkInterp depletion before forward motion applies.** The peak `[step-walk]` line at 17485 shows `winterp=-0.0000` after `DoStepDown`. If `DoStepDown` consumed all WalkInterp on the lift (the slice 7 handoff's reading), the next tick starts WalkInterp at 1.0 again — so this isn't the inter-tick blocker the handoff thought. But INTRA-tick, the next call's `AdjustOffset` runs with no remaining WalkInterp, which could legitimately gate further motion.
+
+**The point of Phase 1 is to disambiguate between (1), (2), and (3).**
+
+---
+
+## Phase 1 — focused diagnostic (no code logic changes)
+
+Add ONE probe site inside `AdjustOffset` and rerun. The existing `[step-walk]` probe instruments the call from the outside (req → adj across the call) but never reveals which **branch** AdjustOffset took. The new probe reveals the branch; that's the missing signal.
+
+### Task 1.1: Add `[step-walk-adjust]` probe call inside AdjustOffset
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (add one new helper)
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:2635-2739` (add 4 probe calls inside AdjustOffset's branches)
+
+- [ ] **Step 1: Add the diagnostic emitter to PhysicsDiagnostics.cs**
+
+Add this method right after `LogStepWalk` (currently ending around line 700). It is intentionally separate from `LogStepWalk` so the line format stays short and greppable.
+
+```csharp
+///
+/// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE AdjustOffset
+/// revealing which branch was taken and the Z gain per call. Pair with
+/// [step-walk] site=after-adjust at the call site to triangulate
+/// where the projection ends up. Caller MUST guard with
+/// if (!ProbeStepWalkEnabled) return; before calling.
+///
+public static void LogStepWalkAdjust(
+ string branch,
+ Vector3 input,
+ Vector3 output,
+ Plane? contactPlane,
+ bool slidingValid,
+ Vector3 slidingNormal,
+ float collisionAngle,
+ float walkInterp)
+{
+ var culture = System.Globalization.CultureInfo.InvariantCulture;
+
+ string cpDesc = contactPlane is { } cp
+ ? string.Format(culture,
+ "n=({0:F4},{1:F4},{2:F4}) d={3:F4}",
+ cp.Normal.X, cp.Normal.Y, cp.Normal.Z, cp.D)
+ : "n/a";
+
+ string slideDesc = slidingValid
+ ? string.Format(culture,
+ "({0:F4},{1:F4},{2:F4})",
+ slidingNormal.X, slidingNormal.Y, slidingNormal.Z)
+ : "n/a";
+
+ Console.WriteLine(string.Format(culture,
+ "[step-walk-adjust] branch={0} input=({1:F4},{2:F4},{3:F4}) " +
+ "output=({4:F4},{5:F4},{6:F4}) zGain={7:F4} " +
+ "cp={8} slide={9} colAngle={10:F4} winterp={11:F4}",
+ branch,
+ input.X, input.Y, input.Z,
+ output.X, output.Y, output.Z,
+ output.Z - input.Z,
+ cpDesc, slideDesc, collisionAngle, walkInterp));
+}
+```
+
+- [ ] **Step 2: Wire the probe at the four AdjustOffset branches**
+
+Open [TransitionTypes.cs](../../src/AcDream.Core/Physics/TransitionTypes.cs) at line 2635 (`private Vector3 AdjustOffset(Vector3 offset)`). The body currently has FOUR exit/branch points:
+
+1. **no-cp** path (line 2654–2659): `if (!ci.ContactPlaneValid) return result;`
+2. **slide** path (lines 2665–2676): `if (checkSlide) { ... result = ... }`
+3. **into-plane** path (lines 2677–2681): `else if (collisionAngle <= 0f) { result -= ... }`
+4. **away-plane** path (lines 2682–2687): `else { result -= ... }` — same arithmetic as `into-plane`, kept distinct for the probe.
+
+At the FINAL return (line 2738 `return result;`), emit ONE probe line that captures the branch taken and the final result.
+
+Replace lines 2635–2739 with a body that tracks the branch token (initialize to `"unknown"`) and assigns it at each branch point. Concretely:
+
+```csharp
+private Vector3 AdjustOffset(Vector3 offset)
+{
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+
+ Vector3 result = offset;
+ bool checkSlide = false;
+ string branch = "init"; // ← new
+
+ // Check if we should apply sliding.
+ float slidingAngle = Vector3.Dot(result, ci.SlidingNormal);
+ if (ci.SlidingNormalValid)
+ {
+ if (slidingAngle < 0f)
+ checkSlide = true;
+ else
+ ci.SlidingNormalValid = false;
+ }
+
+ // No contact plane — simple slide projection.
+ if (!ci.ContactPlaneValid)
+ {
+ if (checkSlide)
+ {
+ result -= ci.SlidingNormal * slidingAngle;
+ branch = "no-cp-slide"; // ← new
+ }
+ else
+ {
+ branch = "no-cp"; // ← new
+ }
+
+ if (PhysicsDiagnostics.ProbeStepWalkEnabled) // ← new
+ PhysicsDiagnostics.LogStepWalkAdjust(
+ branch, offset, result,
+ contactPlane: null,
+ slidingValid: ci.SlidingNormalValid,
+ slidingNormal: ci.SlidingNormal,
+ collisionAngle: 0f,
+ walkInterp: sp.WalkInterp);
+
+ return result;
+ }
+
+ // Have a contact plane — project movement onto the contact surface.
+ float collisionAngle = Vector3.Dot(result, ci.ContactPlane.Normal);
+ Vector3 slideOffset = Vector3.Cross(ci.ContactPlane.Normal, ci.SlidingNormal);
+
+ if (checkSlide)
+ {
+ // Project movement along the crease between contact and slide planes.
+ float slideLen = slideOffset.Length();
+ if (slideLen < PhysicsGlobals.EPSILON)
+ {
+ result = Vector3.Zero;
+ branch = "slide-degenerate"; // ← new
+ }
+ else
+ {
+ slideOffset /= slideLen;
+ result = Vector3.Dot(slideOffset, result) * slideOffset;
+ branch = "slide-crease"; // ← new
+ }
+ }
+ else if (collisionAngle <= 0f)
+ {
+ // Moving into the contact plane: remove component into the plane.
+ result -= ci.ContactPlane.Normal * collisionAngle;
+ branch = "into-plane"; // ← new
+ }
+ else
+ {
+ // Moving away from contact plane: snap to plane surface.
+ result -= ci.ContactPlane.Normal * collisionAngle;
+ branch = "away-plane"; // ← new
+ }
+
+ // ── (Existing safety check unchanged — keep lines 2689–2736 verbatim) ──
+ if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater)
+ {
+ Vector3 globCenter = sp.GlobalSphere[0].Origin;
+ float radius = sp.GlobalSphere[0].Radius;
+
+ float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal)
+ + ci.ContactPlane.D;
+
+ float naturalRestingDist = radius * ci.ContactPlane.Normal.Z;
+
+ if (dist < naturalRestingDist - PhysicsGlobals.EPSILON)
+ {
+ float zDist = (naturalRestingDist - dist) / ci.ContactPlane.Normal.Z;
+ if (radius > MathF.Abs(zDist))
+ {
+ sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));
+ branch += "+safety-push"; // ← new — branch annotation
+ }
+ }
+ }
+
+ if (PhysicsDiagnostics.ProbeStepWalkEnabled) // ← new
+ PhysicsDiagnostics.LogStepWalkAdjust(
+ branch, offset, result,
+ contactPlane: ci.ContactPlane,
+ slidingValid: ci.SlidingNormalValid,
+ slidingNormal: ci.SlidingNormal,
+ collisionAngle: collisionAngle,
+ walkInterp: sp.WalkInterp);
+
+ return result;
+}
+```
+
+**What this adds:** ONE log line per AdjustOffset call (probe-gated) naming the branch and showing Z gain. Nothing else changes — the math is identical.
+
+- [ ] **Step 3: Run dotnet build to confirm the edit compiles**
+
+```powershell
+dotnet build src\AcDream.Core\AcDream.Core.csproj -c Debug
+```
+
+Expected: green build. The diff is additive (one new diagnostic helper + four single-line probe gates inside an existing method).
+
+- [ ] **Step 4: Run dotnet test to confirm the apparatus tests stay green**
+
+```powershell
+dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Issue98CellarUpReplayTests"
+```
+
+Expected: all 7 Issue98CellarUpReplayTests pass (they document the bug; the failing-frame assertions still pin the current behavior). No new failures elsewhere.
+
+- [ ] **Step 5: Commit the probe-only change**
+
+```powershell
+git add src\AcDream.Core\Physics\PhysicsDiagnostics.cs src\AcDream.Core\Physics\TransitionTypes.cs
+git commit -m "diag(phys): A6.P3 #98 — [step-walk-adjust] probe inside AdjustOffset
+
+Adds one log line per AdjustOffset call (gated by ACDREAM_PROBE_STEP_WALK)
+naming the branch taken (no-cp / no-cp-slide / slide-degenerate /
+slide-crease / into-plane / away-plane, optionally +safety-push) plus
+zGain = output.Z - input.Z.
+
+No math changes — pure observability so the next capture can disambiguate
+the three failure-mode hypotheses for the cellar-ramp climb cap at
+world Z ≈ 92.79."
+```
+
+---
+
+### Task 1.2: Capture with the new probe
+
+- [ ] **Step 1: Confirm ACE is up and the test character is in the cottage cellar**
+
+The character is `+Acdream` (server guid `0x5000000A`). Stand on the cellar ramp, facing the top.
+
+- [ ] **Step 2: Launch the client with the step-walk probe enabled**
+
+```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_STEP_WALK = "1"
+$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "a6-issue98-stepwalkadjust-$timestamp.out.log"
+```
+
+- [ ] **Step 3: User walks SLOWLY up the cellar ramp until stuck — no 180° turn-around**
+
+The previous capture (negpoly log) polluted the trajectory because the user turned around and walked back. This capture must be a clean monotone climb.
+
+- [ ] **Step 4: User closes the client (graceful — Alt-F4 / window close, not Ctrl-C)**
+
+Per the logout-before-reconnect rules in CLAUDE.md — hard kill costs 3 minutes of ACE session recovery.
+
+- [ ] **Step 5: Snapshot the capture to docs/research**
+
+```powershell
+$captureDir = "docs\research\2026-05-23-a6-captures\stepwalkadjust"
+New-Item -ItemType Directory -Force $captureDir | Out-Null
+Copy-Item "a6-issue98-stepwalkadjust-$timestamp.out.log" "$captureDir\acdream.log"
+```
+
+---
+
+### Task 1.3: Analyze the new probe data
+
+- [ ] **Step 1: Extract the [step-walk-adjust] lines from the climb portion only**
+
+```powershell
+Select-String "step-walk-adjust" "docs\research\2026-05-23-a6-captures\stepwalkadjust\acdream.log" |
+ Select-Object -ExpandProperty Line |
+ Set-Content "docs\research\2026-05-23-a6-captures\stepwalkadjust\adjust-only.log"
+```
+
+- [ ] **Step 2: Cross-correlate [step-walk-adjust] with [step-walk] site=after-adjust by line position**
+
+For each ramp-climb tick, you should see the pattern:
+```
+[step-walk] site=after-adjust cur=(...) req=(rX,rY,0) adj=(aX,aY,aZ) ...
+[step-walk-adjust] branch=... input=(rX,rY,0) output=(aX,aY,aZ) zGain=aZ cp=(...) ...
+```
+
+- [ ] **Step 3: Classify the climb by branch token**
+
+Walk forward through the lines from the start of the climb to the peak (world Z ≈ 92.79). Build a histogram:
+
+```
+branch=into-plane — how many calls? Avg zGain?
+branch=away-plane — how many? Avg zGain?
+branch=slide-crease — how many? Avg zGain?
+branch=no-cp — how many? (means ContactPlaneValid cleared mid-climb)
++safety-push annotation — how often? At what zGain?
+```
+
+- [ ] **Step 4: Decide which Phase 2 branch the fix takes**
+
+Decision tree (read the histogram + the climb-cap moment together):
+
+| Observation | Implies fix target | Phase 2 branch |
+|---|---|---|
+| `into-plane` dominates the climb, +zGain ~0.2/call, then at peak the branch flips to `no-cp` or `away-plane` with zero zGain | **Target A: ContactPlane is being cleared / replaced at the ramp top.** Fix: investigate why the ramp's CP is dropping when the climb is incomplete. | **Branch A** |
+| `into-plane` continues at peak but zGain becomes near-zero (offset.Y trends to zero) | **Target B: forward motion is being consumed elsewhere.** Either by step-up burning WalkInterp before AdjustOffset gets the offset, or by the offset being slid horizontally by a wall hit. Look at `[step-walk] site=before-insert` for a Y-collapsing pattern. | **Branch B** |
+| `into-plane` at peak with correct zGain but CurPos doesn't advance | **Target C: the offset is computed correctly but never committed.** Look at `TransitionalInsert` / `Insert` for a path that returns before commit. | **Branch C** |
+| Any branch with frequent `slide-crease` mid-climb | **Side-finding: a SlidingNormal is being set against the ramp.** Should not happen on a clean slope — points at a wall poly being mis-classified. | **Branch D (side-investigation, not the fix)** |
+
+- [ ] **Step 5: Write a 1-page findings note**
+
+Save to `docs/research/2026-05-23-a6-stepwalkadjust-findings.md`. Format:
+
+```markdown
+# A6.P3 #98 — [step-walk-adjust] capture analysis (YYYY-MM-DD)
+
+## Climb branch histogram (90.00 → peak)
+- into-plane: X calls, avg zGain=Y
+- away-plane: ...
+- ...
+
+## At the climb cap (world Z ≈ ZZZ):
+- Branch flipped from X to Y at log line NNN
+- ContactPlane normal at cap: (...)
+- WalkInterp at cap: ...
+
+## Conclusion: Fix target is Branch A / B / C / D.
+
+## Reason (one paragraph)
+...
+```
+
+- [ ] **Step 6: Commit the findings note**
+
+```powershell
+git add docs\research\2026-05-23-a6-stepwalkadjust-findings.md docs\research\2026-05-23-a6-captures\stepwalkadjust\
+git commit -m "research(phys): A6.P3 #98 — [step-walk-adjust] capture + findings
+
+Identifies fix target Branch [A/B/C/D] for the cellar-up climb cap."
+```
+
+---
+
+## Phase 2 — branch to fix target (data-driven)
+
+**Do NOT execute Phase 2 until Phase 1's findings note exists and the user has reviewed it.** This is the explicit lesson from the 4-session failure pattern: shipping speculative fixes wastes a session each time.
+
+Per CLAUDE.md (no-workarounds rule) the fix must address the root cause. Each branch below names the LIKELY fix shape. The actual code lands as a separate plan once Phase 1 confirms the branch.
+
+### Branch A — ContactPlane clearing / replacement at ramp top
+
+If the climb works while CP=ramp and FAILS when CP changes mid-climb:
+
+**Investigate first (no code):**
+- Where in `Transition.FindEnvCollisions` / `Transition.SetContactPlane` is the ramp CP dropped? Likely candidates: `[TransitionTypes.cs:1814](../../src/AcDream.Core/Physics/TransitionTypes.cs:1814)` (FindEnvCollisions write), `[TransitionTypes.cs:1837](../../src/AcDream.Core/Physics/TransitionTypes.cs:1837)` (post-stepdown write).
+- Compare against retail's BPE pattern: retail's CP toggles cellar-floor → cottage-floor with **no intermediate** (per the divergence comparison table). It never sets CP to the ramp. Our code DOES. The fix MAY be making our ramp-CP behavior match retail (never set CP=ramp; set CP=cellar-floor while climbing, then transition CP=cottage-floor when the sphere is above cottage floor) — but this is a SHAPE-of-the-fix question; verify it's actually needed before changing the contract.
+
+**Probable fix file:** [src/AcDream.Core/Physics/TransitionTypes.cs](../../src/AcDream.Core/Physics/TransitionTypes.cs) around `FindEnvCollisions` (line 1700–1900).
+
+**Acceptance:** Phase 1's findings note's branch histogram shows the failure point. The fix must produce a log where `cur` Z monotonically increases from 90.00 to ≥ 94.00 across the climb.
+
+### Branch B — forward motion consumed before AdjustOffset
+
+If `into-plane` continues at peak but zGain → 0 because input offset Y → 0:
+
+**Investigate first:**
+- Look at `[step-walk] site=before-insert` in the existing log around line 17458 at the peak. Compare the `req` value to the next tick's `req`. If `req.Y` decays to zero across consecutive ticks, the motion source itself is being cut. Trace back to where the per-tick offset is generated — `[PlayerMovementController](../../src/AcDream.Core/Physics/PlayerMovementController.cs)` and the input → velocity → offset chain.
+- Compare against retail's BPF (adjust_sphere_to_plane) cadence — 431 hits over 35K BPs suggests retail re-projects relatively frequently but not in a way that zeros out motion. Our code might be over-projecting.
+
+**Probable fix file:** [src/AcDream.Core/Physics/PlayerMovementController.cs](../../src/AcDream.Core/Physics/PlayerMovementController.cs) or the velocity-source upstream of the physics tick.
+
+**Acceptance:** Same as Branch A.
+
+### Branch C — offset computed but never committed (CurPos doesn't advance)
+
+If `[step-walk-adjust] zGain` is correct per call but `[step-walk] cur` doesn't accumulate across calls:
+
+**Investigate first:**
+- `TransitionalInsert` in [TransitionTypes.cs](../../src/AcDream.Core/Physics/TransitionTypes.cs) — find the path that returns without committing `sp.CurPos += sp.GlobalOffset`. Likely guard: a collision state that retreats.
+- The `[step-walk] delta=(0,0,0)` pattern across consecutive lines is the smoking gun if it persists at the peak.
+
+**Probable fix file:** `TransitionTypes.cs`, the per-step commit at the bottom of the step loop (around line 689–760).
+
+**Acceptance:** Same as Branch A.
+
+### Branch D — sliding-normal mis-classification (side-investigation only)
+
+If `slide-crease` appears mid-climb, the ramp polygon is being treated as a wall by some upstream call. This is unlikely to be the fix for #98 — slide is the right answer for walls, the question is which polygon triggered it.
+
+**Investigate:** which polygon's normal got installed as `SlidingNormal`? Add a one-line probe at `ci.SlidingNormal = ...` write sites and capture again.
+
+---
+
+## Phase 3 — write the fix (TDD against replay tests)
+
+Once Phase 1 names the branch and the findings note is committed, write a fresh plan for the fix:
+
+`docs/superpowers/plans/2026-MM-DD-a6-p3-issue98-cellar-up-fix-impl.md`
+
+Use [superpowers:test-driven-development](../../.claude/superpowers/skills/test-driven-development/SKILL.md) discipline:
+
+1. **Flip the replay test assertions FIRST** so they document the FIXED behavior. Two assertions to invert:
+ - `FailingFrame_CottageNeighborA_NearestWalkableIsOutsideSphereAndEdges` — at minimum one of `OverlapsSphere` / `InsideEdges` must become `true`.
+ - `FailingFrame_NoCottageNeighbourYieldsAcceptedWalkable` — at least one neighbour cell must accept.
+2. Run `dotnet test`; the inverted assertions fail (red).
+3. Implement the minimal fix in the file Phase 2 identified.
+4. Run `dotnet test`; the inverted assertions pass (green).
+5. Run the live client; the user verifies they can walk up out of the cottage cellar.
+
+---
+
+## Acceptance for Phase 1 (the only Phase landing in this plan)
+
+- [ ] `dotnet build` green
+- [ ] `dotnet test --filter "FullyQualifiedName~Issue98CellarUpReplayTests"` — 7 passing (no regressions)
+- [ ] `dotnet test` overall — baseline 1167 + 8 maintained (no new failures)
+- [ ] Diagnostic commit landed (one commit, additive only — no math/control-flow changes)
+- [ ] Capture commit landed (acdream.log + findings note)
+- [ ] Findings note names ONE branch (A / B / C / D) as the Phase 2 fix target
+
+**Acceptance for Phase 3 (the eventual fix, captured here for the next session):**
+
+- [ ] `Issue98CellarUpReplayTests.FailingFrame_NoCottageNeighbourYieldsAcceptedWalkable` — assertion inverted to require acceptance; test passes.
+- [ ] `Issue98CellarUpReplayTests.FailingFrame_CottageNeighborA_NearestWalkableIsOutsideSphereAndEdges` — assertion inverted to require sphere-overlap; test passes.
+- [ ] `dotnet build` green.
+- [ ] `dotnet test` baseline 1167 + 8 (or better) maintained.
+- [ ] User walks up out of the Holtburg cottage cellar in the live client — **visual verification required**.
+
+---
+
+## What this plan does NOT do (per CLAUDE.md no-workarounds rule)
+
+- Does not reattempt placement-insert bypasses in `BSPQuery.FindCollisions`, `Transition.FindEnvCollisions`, or `Transition.DoStepDown`. Six variants already tried; none worked.
+- Does not reattempt cell-resolver tiebreaker changes in `PhysicsEngine.ResolveCellId`. Slice 3 already shipped a stickiness fix; the bug persists.
+- Does not reattempt negative-side polygon handling. Reverted in `35b37df`; the neg-poly branch fired zero times in the failing log.
+- Does not reattempt the bldg-check / IsLandblockBuilding flag propagation. Reverted in `35b37df`.
+- Does not add suppression flags, grace periods, retry loops, or `if (problematicState) return` symptom guards.
+
+If at any point during Phase 1 implementation you feel certain about the fix without having seen the new probe's branch histogram, **STOP**. The 4-session pattern was: convinced of diagnosis → ship fix → user reports "still can't pass." Verifying via the probe is what breaks the pattern.
+
+---
+
+## Self-review (writing-plans skill discipline)
+
+**Spec coverage.** The user's prompt asks for: state altitudes (done in handoff), diagnostic-first verification (Task 1.1–1.3), branch-to-fix decision (Phase 2), TDD against replay tests (Phase 3), forbidden shortcut list (final section). ✓
+
+**Placeholder scan.** No "TBD" / "add error handling" / "similar to Task N" left in the plan. Exact file paths and line numbers throughout. ✓
+
+**Type consistency.** `LogStepWalkAdjust` matches `LogStepWalk`'s signature pattern. Branch tokens are stringly-typed but enumerated explicitly. `[step-walk-adjust]` prefix is distinct from `[step-walk]`. ✓
+
+**Realism.** Phase 1 ships one diagnostic commit + one capture commit. ~30 minutes if everything goes smoothly. Phase 2 is research (no code). Phase 3 is the actual fix — separate plan. The whole arc fits in 1–2 working days, broken at the user-review checkpoint after Phase 1's findings note.
+
diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index 4a9947b..a941efe 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -674,6 +674,49 @@ public static class PhysicsDiagnostics
string.IsNullOrEmpty(detail) ? string.Empty : " " + detail));
}
+ ///
+ /// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE
+ /// revealing which branch was
+ /// taken and the per-call Z gain. Pair with [step-walk]
+ /// site=after-adjust at the call site to triangulate where the
+ /// projection ends up. Caller MUST guard with
+ /// if (!ProbeStepWalkEnabled) return; before calling.
+ ///
+ public static void LogStepWalkAdjust(
+ string branch,
+ Vector3 input,
+ Vector3 output,
+ Plane? contactPlane,
+ bool slidingValid,
+ Vector3 slidingNormal,
+ float collisionAngle,
+ float walkInterp)
+ {
+ var culture = System.Globalization.CultureInfo.InvariantCulture;
+
+ string cpDesc = contactPlane is { } cp
+ ? string.Format(culture,
+ "n=({0:F4},{1:F4},{2:F4}) d={3:F4}",
+ cp.Normal.X, cp.Normal.Y, cp.Normal.Z, cp.D)
+ : "n/a";
+
+ string slideDesc = slidingValid
+ ? string.Format(culture,
+ "({0:F4},{1:F4},{2:F4})",
+ slidingNormal.X, slidingNormal.Y, slidingNormal.Z)
+ : "n/a";
+
+ Console.WriteLine(string.Format(culture,
+ "[step-walk-adjust] branch={0} input=({1:F4},{2:F4},{3:F4}) " +
+ "output=({4:F4},{5:F4},{6:F4}) zGain={7:F4} " +
+ "cp={8} slide={9} colAngle={10:F4} winterp={11:F4}",
+ branch,
+ input.X, input.Y, input.Z,
+ output.X, output.Y, output.Z,
+ output.Z - input.Z,
+ cpDesc, slideDesc, collisionAngle, walkInterp));
+ }
+
private static string FormatVector(bool valid, Vector3 value)
{
if (!valid)
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index deccec7..7d5b2b2 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2639,6 +2639,9 @@ public sealed class Transition
Vector3 result = offset;
bool checkSlide = false;
+ // A6.P3 #98 (2026-05-23): branch token for the [step-walk-adjust]
+ // probe. Tracks which exit path the projection takes.
+ string branch = "init";
// Check if we should apply sliding.
float slidingAngle = Vector3.Dot(result, ci.SlidingNormal);
@@ -2654,7 +2657,24 @@ public sealed class Transition
if (!ci.ContactPlaneValid)
{
if (checkSlide)
+ {
result -= ci.SlidingNormal * slidingAngle;
+ branch = "no-cp-slide";
+ }
+ else
+ {
+ branch = "no-cp";
+ }
+
+ if (PhysicsDiagnostics.ProbeStepWalkEnabled)
+ PhysicsDiagnostics.LogStepWalkAdjust(
+ branch, offset, result,
+ contactPlane: null,
+ slidingValid: ci.SlidingNormalValid,
+ slidingNormal: ci.SlidingNormal,
+ collisionAngle: 0f,
+ walkInterp: sp.WalkInterp);
+
return result;
}
@@ -2667,23 +2687,29 @@ public sealed class Transition
// Project movement along the crease between contact and slide planes.
float slideLen = slideOffset.Length();
if (slideLen < PhysicsGlobals.EPSILON)
+ {
result = Vector3.Zero;
+ branch = "slide-degenerate";
+ }
else
{
slideOffset /= slideLen;
result = Vector3.Dot(slideOffset, result) * slideOffset;
+ branch = "slide-crease";
}
}
else if (collisionAngle <= 0f)
{
// Moving into the contact plane: remove component into the plane.
result -= ci.ContactPlane.Normal * collisionAngle;
+ branch = "into-plane";
}
else
{
// Moving away from contact plane: snap to plane surface.
// SnapToPlane: remove any component that would violate the plane.
result -= ci.ContactPlane.Normal * collisionAngle;
+ branch = "away-plane";
}
// Safety check: ensure the sphere stays above the contact plane.
@@ -2731,10 +2757,22 @@ public sealed class Transition
if (radius > MathF.Abs(zDist))
{
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));
+ // A6.P3 #98 (2026-05-23): annotate the branch so the
+ // probe shows the safety push fired.
+ branch += "+safety-push";
}
}
}
+ if (PhysicsDiagnostics.ProbeStepWalkEnabled)
+ PhysicsDiagnostics.LogStepWalkAdjust(
+ branch, offset, result,
+ contactPlane: ci.ContactPlane,
+ slidingValid: ci.SlidingNormalValid,
+ slidingNormal: ci.SlidingNormal,
+ collisionAngle: collisionAngle,
+ walkInterp: sp.WalkInterp);
+
return result;
}