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 or control-flow changes — pure observability so the next capture
can disambiguate the three failure-mode hypotheses for the cellar-ramp
climb cap. Re-reading the existing capture (a6-issue98-negpoly-...log)
showed the sphere DOES climb 90.00 -> 92.79 (2.79 m gain), then caps,
contradicting the divergence comparison's "no altitude gain" framing.
The real question is what stops the climb at world Z ~= 92.79 with the
cottage floor still 1.21 m higher. Existing [step-walk] probes wrap
AdjustOffset; this new probe reveals which branch the projection takes.

Fix plan with the four-branch decision tree at
docs/superpowers/plans/2026-05-23-a6-p3-issue98-cellar-up-fix.md.

Test baseline maintained: 1167 + 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-23 16:16:42 +02:00
parent 67005e21f1
commit 8a232a3e6e
3 changed files with 562 additions and 0 deletions

View file

@ -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 1745817485 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
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE AdjustOffset
/// revealing which branch was taken and the Z gain per call. Pair with
/// <c>[step-walk] site=after-adjust</c> at the call site to triangulate
/// where the projection ends up. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
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 26542659): `if (!ci.ContactPlaneValid) return result;`
2. **slide** path (lines 26652676): `if (checkSlide) { ... result = ... }`
3. **into-plane** path (lines 26772681): `else if (collisionAngle <= 0f) { result -= ... }`
4. **away-plane** path (lines 26822687): `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 26352739 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 26892736 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 17001900).
**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 689760).
**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.11.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 12 working days, broken at the user-review checkpoint after Phase 1's findings note.

View file

@ -674,6 +674,49 @@ public static class PhysicsDiagnostics
string.IsNullOrEmpty(detail) ? string.Empty : " " + detail));
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — focused probe INSIDE
/// <see cref="Transition.AdjustOffset"/> revealing which branch was
/// taken and the per-call Z gain. Pair with <c>[step-walk]
/// site=after-adjust</c> at the call site to triangulate where the
/// projection ends up. Caller MUST guard with
/// <c>if (!ProbeStepWalkEnabled) return;</c> before calling.
/// </summary>
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)

View file

@ -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;
}