fix(p2): cellar-lip wedge — check_other_cells must use the LIVE sphere position

Root cause of the "blocked at the last cellar step" wedge (the primary,
ramp-climb family — 20/29 captured records). The prior session's pinned
"find_walkable is never called during the step-down" was a probe artifact:
a fresh [fc-dispatch]/[step-sphere-down] trace proves Path-3 StepSphereDown
IS reached for both the carried cell and the iterated other-cell.

The real divergence is in Transition.CheckOtherCells. Retail's
check_other_cells (acclient_2013_pseudo_c.txt:272735 → (*cell+0x88)(this))
re-collides the OTHER cells against the LIVE sphere_path.global_sphere — the
position AFTER the primary insert_into_cell ran. The primary collide can MOVE
the sphere: a Path-5 full-hit dispatches step_sphere_up, and a successful
step-up climbs the foot onto the cottage floor yet still returns OK. acdream
instead reused a footCenter snapshot captured BEFORE the primary collide, so
once the lip-riser step-up climbed the foot onto the floor, check_other_cells
still queried 0171 at the pre-climb (sunk ~0.25 m below the floor) position →
the foot spuriously near-missed the very floor it had climbed onto →
neg_step_up → a doomed second step_up vs the floor normal (0,0,1) whose
step_up_slide unwound the climb → validate_transition reverted → 0% advance.

Fix: re-read footCenter = sp.GlobalSphere[0].Origin at the top of
RunCheckOtherCellsAndAdvance (one line). Pre-fix 0/29 wedge records advanced;
post-fix 20/29 climb onto Z≈94.

No regression: full Core suite 1321 pass / 4 fail (the documented baseline:
Apparatus_Grounded_50cmOffCenter, 2× DoorBugTrajectoryReplay LiveCompare_*,
BSPStepUpTests.D4) / 1 skip. The 2 door LiveCompare divergences are
byte-identical with/without the fix (the door's step_up FAILS → sphere
restored → position unchanged → footCenter == live).

Tests: CellarLipWedgeTests.Fix_StaleFootCenter_RampRecordClimbsCottageFloor +
Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance (new, GREEN).
DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY documents the
remaining 9/29 (0,-1,0)-sliding-normal +Y-kill family (slide territory,
deferred to the visual gate).

Apparatus retained (gated on ACDREAM_PROBE_INDOOR_BSP): [fc-dispatch] in
BSPQuery.FindCollisions + [step-sphere-down] in BSPQuery.StepSphereDown +
CellarLipWedgeTests.Diagnostic_TraceRecordByIndex — strip once the residual
is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-05 09:15:19 +02:00
parent bc1be26907
commit cc4590f9e5
4 changed files with 208 additions and 18 deletions

View file

@ -340,26 +340,123 @@ public class CellarLipWedgeTests
}
/// <summary>
/// FAITHFUL documents-the-bug (passes while the wedge exists; FAILS when the
/// fix lands → flip to assert the climb). Replays a captured FLOOR-contact-
/// plane wedge through the lip-cell engine; the player is STUCK (0% advance).
/// TEMP diagnostic (2026-06-05): trace ONE record by index with full probes,
/// to a per-index %TEMP%/lip-trace-{idx}.log. Used to compare a ramp record
/// (no sliding normal) against a floor record. STRIP after fix.
/// </summary>
[Theory]
[InlineData(6)] // STILL 0% post-footCenter-fix: flat floor, sliding normal (0,-1,0)
[InlineData(13)] // STILL 0% post-footCenter-fix: ramp, NO sliding normal, motion -X,+Y
[InlineData(0)] // STILL 0% post-footCenter-fix: ramp, sliding normal (0,-1,0)
[InlineData(21)] // STILL 0% post-footCenter-fix: ramp, NO slide, motion -X,-Y (away?)
public void Diagnostic_TraceRecordByIndex(int idx)
{
var rec = LoadWedgeRecords()[idx];
var saved = Console.Out;
var sw = new StringWriter();
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1");
Console.SetOut(sw);
try
{
var (res, req, adv) = ReplayRecord(rec);
Console.SetOut(saved);
var bb = rec.BodyBefore!;
File.WriteAllText(Path.Combine(Path.GetTempPath(), $"lip-trace-{idx}.log"),
$"record #{idx} cur=({rec.Input.CurrentPos.X:F4},{rec.Input.CurrentPos.Y:F4},{rec.Input.CurrentPos.Z:F4}) " +
$"tgt=({rec.Input.TargetPos.X:F4},{rec.Input.TargetPos.Y:F4},{rec.Input.TargetPos.Z:F4}) " +
$"cp=({bb.ContactPlane.Normal.X:F2},{bb.ContactPlane.Normal.Y:F2},{bb.ContactPlane.Normal.Z:F2}) " +
$"slide=({bb.SlidingNormal.X:F2},{bb.SlidingNormal.Y:F2},{bb.SlidingNormal.Z:F2}) ts=0x{bb.TransientState:X2} " +
$"req={req:F3} adv={adv:F3} res=({res.X:F4},{res.Y:F4},{res.Z:F4})\n\n" + sw.ToString());
Assert.True(true);
}
finally
{
Console.SetOut(saved);
Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null);
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
/// <summary>
/// FIX VALIDATION (2026-06-05) — the stale-footCenter fix in
/// <c>RunCheckOtherCellsAndAdvance</c>. Retail's <c>check_other_cells</c>
/// (acclient_2013_pseudo_c.txt:272735) re-collides the OTHER cells against the
/// LIVE <c>sphere_path.global_sphere</c> — i.e. AFTER the primary cell's
/// <c>insert_into_cell</c> may have moved the sphere via a successful
/// <c>step_sphere_up</c>. acdream captured the foot-sphere center BEFORE the
/// primary collide and reused that stale snapshot, so once the lip-riser
/// step_up climbed the foot onto the cottage floor, <c>check_other_cells</c>
/// still queried 0171 at the pre-climb (sunk, penetrating) position → the foot
/// spuriously near-missed the very floor it had climbed onto → a doomed second
/// step_up against the floor normal whose slide unwound the climb →
/// validate_transition reverted → 0% advance.
///
/// <para>
/// <b>Root cause (traced via <c>Diagnostic_ReplayFloorCpRecord_StepUpProbes</c>):</b>
/// the player is at the doorway EDGE of the cottage floor (0171, poly 0x0023,
/// Z=94). The step-up's step-down finds that floor and the 0.48 sphere
/// OVERLAPS it (0.085 m below), but acdream's walkable check REJECTS it
/// because the sphere center projects outside the floor poly's edge
/// (<c>insideEdges=False</c>, <c>gap=0.395</c>) → no contact plane → step-up
/// fails → StepUpSlide=Collided. Retail accepts the floor at its edge and
/// crosses (0175 never blocks). Fix is in the walkable-edge acceptance
/// (WalkableHitsSphere / PolygonHitsSpherePrecise / CheckWalkable edge math)
/// — compare retail CPolygon::walkable_hits_sphere + check_walkable. DOOR
/// REGRESSION RISK: walkable changes are global; visual-gate + door tests.
/// Pre-fix: 0/29 captured wedge records advanced. Post-fix: the ramp-climb
/// family (≈20/29) advances onto the cottage floor (Z≈94). This asserts a
/// representative ramp record (#9, cp Z=0.78, no sliding normal) now climbs.
/// </para>
/// </summary>
[Fact]
public void DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge()
public void Fix_StaleFootCenter_RampRecordClimbsCottageFloor()
{
var rec = LoadWedgeRecords()[9];
var (res, requested, advance) = ReplayRecord(rec);
Assert.True(advance > 0.25f * requested && res.Z >= CottageFloorZ - 0.05f,
$"Expected ramp record #9 to climb onto the cottage floor after the " +
$"stale-footCenter fix. advance={advance:F3} (req={requested:F3}), " +
$"res=({res.X:F3},{res.Y:F3},{res.Z:F3}); want advance>0.25·req and Z≥{CottageFloorZ - 0.05f:F2}.");
}
/// <summary>
/// FIX REGRESSION GUARD (2026-06-05): the majority of captured wedge records
/// advance after the stale-footCenter fix. Pre-fix 0/29 → post-fix ≈20/29.
/// A drop here means <c>check_other_cells</c> is again querying other cells at
/// a stale pre-step_up position (the cellar-lip wedge regressed).
/// </summary>
[Fact]
public void Fix_StaleFootCenter_MajorityOfWedgeRecordsAdvance()
{
var recs = LoadWedgeRecords();
int advanced = 0, total = 0;
foreach (var rec in recs)
{
if (rec.BodyBefore is null) continue;
total++;
var (res, req, adv) = ReplayRecord(rec);
if (adv > 0.25f * req) advanced++;
}
Assert.True(advanced >= 18,
$"Expected ≥18 of {total} captured wedge records to advance >0.25·req " +
$"after the stale-footCenter fix; got {advanced}.");
}
/// <summary>
/// DOCUMENTS-THE-BUG (passes while a RESIDUAL wedge exists; flip when fixed).
/// Record #6 is a FLOOR-contact-plane record that ALSO carries a stale
/// <c>(0,-1,0)</c> sliding normal (the cottage south wall). The stale-footCenter
/// fix does NOT clear it: <c>AdjustOffset</c>'s slide-crease projects the
/// into-cottage +Y motion onto the floor×wall crease (the world X axis) and
/// ZEROES it before the sphere moves, so only the X residual survives → it
/// full-hits the slab's X wall → a step_up that fails on the flat floor (no CP)
/// → Collided → revert → 0% advance.
///
/// <para>
/// This is the SEPARATE "(0,-1,0) sliding-normal +Y-kill" family (7/29 records).
/// It is slide-recovery territory — explicitly OUT OF SCOPE for this pass per
/// the kickoff ("do not re-investigate ... slide") — and is suspected to be a
/// buggy-trajectory artifact (the stale slide accumulated only because the
/// player was already oscillating; once the ramp-climb advances cleanly the
/// player should not enter the south-wall-slide-into-doorway state). The visual
/// gate decides whether it needs a follow-up. If the residual is fixed, flip
/// this to require advance &gt; 0.25·requested.
/// </para>
/// </summary>
[Fact]
public void DocumentsResidualWedge_LiveFloorCp_SlidingNormalKillsPlusY()
{
var recs = LoadWedgeRecords();
var rec = recs.First(r => r.BodyBefore is not null
@ -367,10 +464,10 @@ public class CellarLipWedgeTests
var (res, requested, advance) = ReplayRecord(rec);
var c = rec.Input.CurrentPos; var t = rec.Input.TargetPos;
Assert.True(advance < 0.1f * requested,
$"DOCUMENTS-THE-BUG: expected the player STUCK at the cottage-floor edge. " +
$"DOCUMENTS-RESIDUAL: expected the player STUCK (sliding-normal +Y-kill). " +
$"Instead it advanced: cur=({c.X:F3},{c.Y:F3},{c.Z:F3}) tgt=({t.X:F3},{t.Y:F3},{t.Z:F3}) " +
$"res=({res.X:F3},{res.Y:F3},{res.Z:F3}) requested={requested:F3} advance={advance:F3}. " +
$"If the walkable-edge fix landed, FLIP this to require advance>0.25·requested.");
$"If the slide +Y-kill residual is fixed, FLIP this to require advance>0.25·requested.");
}
private static PhysicsEngine BuildEngineWithLipFixtures()