acdream/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs
Erik bc1be26907 test(p2): faithful cellar-lip wedge reproduction + investigation apparatus (no fix yet)
P2 / M1.5 "blocked at the last step" cellar-lip wedge. This session built a faithful
deterministic reproduction and peeled the cause through six evidence-disproven framings
to one bounded question. NO fix landed — the last layers were each disproven by evidence,
and guessing at the load-bearing collision code is the saga's failure mode.

Apparatus:
- CellarLipWedgeTests.cs + Fixtures/cellar-lip/ (3 real cell dumps + wedge-records.jsonl =
  29 captured ACDREAM_CAPTURE_RESOLVE wedge calls). Replays the exact calls + body-before
  through the lip-cell engine: all 29 reproduce at 0% advance in <200 ms. Tests are
  documents-the-bug / diagnostics (GREEN while the wedge exists).
- TEMP probes ([path5-wall]/[fw-enter]/[find-walkable] in BSPQuery; [neg-poly]/[stepsphereup]/
  [stepdown-decide]/CheckOtherCells cn/sn/negHit in TransitionTypes), gated on
  ACDREAM_PROBE_INDOOR_BSP, marked STRIP. TransitionTypes neg-poly shortcut has a reverted-fix
  comment (slide attempt didn't clear the wedge).
- tools/cdb/retail-*-trace.cdb (retail cdb traces).

Findings (handoff: docs/research/2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, see the
"NEXT-SESSION KICKOFF" at top):
- Flat-floor contact plane is retail-faithful (v1 trace, full-file correlation). NOT the bug.
- PosHitsSphere cull sign is retail-faithful (cdb -z verified; the Binary Ninja `test ah,N; jp`
  parity-jump reads inverted — caught + reverted a wrong fix from that mis-read).
- Sphere radius correct (0.48 player / 0.30 camera probe).
- Retail connector cell 0xA9B40175 never blocks (CEnvCell::find_collisions trace: 0 Collided/Slid).
- PINNED: during the step-up's step-down, BSPQuery.FindWalkableInternal is never called for cell
  0171, so the cottage floor (poly 0x0023, Z=94) is never tested as walkable -> no contact plane
  -> step-up fails -> StepUpSlide=Collided -> wedge.

Next: trace FindEnvCollisions -> FindCollisions path dispatch for 0171 during StepDown=true (why
StepSphereDown/find_walkable is skipped), port retail, validate via CellarLipWedgeTests, regress
DoorBugTrajectoryReplayTests + visual gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:36 +02:00

446 lines
21 KiB
C#
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.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// P2 cellar-LIP wedge (2026-06-04) — deterministic reproduction of the
/// "blocked at the last step" wedge at the Holtburg cottage cellar lip,
/// distinct from the earlier issue-#98 cellar (cells 0xA9B4014X).
///
/// <para>
/// Built from live captures this session:
/// <list type="bullet">
/// <item><b>Retail-connector trace</b> (CEnvCell::find_collisions) proved
/// retail's connector cell <c>0xA9B40175</c> NEVER blocks (2692 OK +
/// 94 Adjusted + 0 Collided + 0 Slid over ~85K samples).</item>
/// <item><b>acdream live capture</b> at the wedge: the player (r=0.48 body
/// sphere) is mid-climb at world Z=93.936, carried in the 0.364 m-tall
/// threshold slab <c>0xA9B40175</c>. Its 0.48 sphere grazes the slab's
/// X wall (local X=9; sphere at X=8.523 reaches 9.003 — a 3 mm graze)
/// → <c>StepSphereUp</c> → <c>DoStepUp</c> fails (no CP on the flat
/// cottage floor) → <c>StepUpSlide</c> returns <c>Collided</c> → the
/// per-cell collide returns Collided → wedge.</item>
/// </list>
/// </para>
///
/// <para>
/// Fixtures are real cell dumps (<c>ACDREAM_DUMP_CELLS</c>) of the three lip
/// cells: <c>0xA9B40171</c> (cottage floor), <c>0xA9B40174</c>, <c>0xA9B40175</c>
/// (threshold connector). The <c>BSP=null</c> hydration gap is bridged with a
/// synthetic single-leaf BSP, same as <see cref="CellarUpTrajectoryReplayTests"/>.
/// </para>
///
/// <para>
/// <b>RED status:</b> the climb-advance assertion is expected to FAIL while the
/// wedge exists (the player freezes at the threshold ~Z=93.94 instead of
/// reaching the cottage floor ~Z=94.48). When the fix lands it flips to GREEN.
/// </para>
/// </summary>
public class CellarLipWedgeTests
{
private const uint CottageFloorId = 0xA9B40171u; // cottage room floor
private const uint Connector74Id = 0xA9B40174u;
private const uint ThresholdId = 0xA9B40175u; // 0.364 m threshold slab
// Player physics from PlayerMovementController.cs (human, from Setup).
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.40f;
private const float StepDownHeight = 0.04f;
// The live-captured wedge state. The probe's [indoor-bsp] wpos is the
// FOOT-SPHERE CENTER (153.406, 9.754, 93.936); the engine body Position is
// the foot BOTTOM = center radius = Z 93.456. Player carried in the
// 0.364 m threshold slab 0xA9B40175, climbing the cellar stairs; observed
// motion at the lip is world Y (into the cottage).
private static readonly Vector3 WedgeSphereCenter = new(153.406f, 9.754f, 93.936f);
private static readonly Vector3 WedgeBodyPos =
new(153.406f, 9.754f, 93.936f - SphereRadius); // foot bottom Z=93.456
private static readonly Vector3 PerTickOffset = new(0f, -0.10f, 0f);
private const float CottageFloorZ = 94.00f;
private const float RestOnCottageZ = CottageFloorZ + SphereRadius; // ≈94.48
/// <summary>
/// Diagnostic: drive the player off the threshold toward the cottage and
/// dump the trajectory + indoor-BSP/step probes. Always passes; the
/// captured stdout shows exactly what the engine does each tick.
/// </summary>
[Fact]
public void Diagnostic_DriveOffThreshold_DumpTrajectory()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
var saved = Console.Out;
var sw = new StringWriter();
Console.SetOut(sw);
try
{
var engine = BuildEngineWithLipFixtures();
var body = BuildWedgeBody();
var traj = SimulateTicks(engine, body, ThresholdId, 4);
Console.SetOut(saved);
var probeLines = sw.ToString();
File.WriteAllText(
Path.Combine(Path.GetTempPath(), "lip-wedge-diag.log"),
"TRAJECTORY:\n" + string.Join("\n", traj.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}")) +
"\n\nPROBES:\n" + probeLines);
Assert.True(true);
}
finally
{
Console.SetOut(saved);
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
}
}
/// <summary>
/// DOCUMENTS-THE-BUG (passes while the wedge exists; FAILS when the fix
/// lands). Seeded at the live wedge position (foot-sphere center Z=93.936,
/// carried in the 0.364 m threshold slab 0xA9B40175) and driven forward,
/// the player FREEZES — blocked by the slab's X side wall (poly normal
/// world (1,0,0)) — instead of advancing onto the cottage floor. Retail's
/// 0175 never blocks (live CEnvCell::find_collisions trace: 0 Collided/Slid).
///
/// <para>
/// <b>Faithfulness caveat:</b> the per-tick drive direction (world Y) is an
/// approximation — the exact <c>targetPos</c> would come from an
/// <c>ACDREAM_CAPTURE_RESOLVE</c> JSONL of the live wedge. The Y drive is
/// PARALLEL to the X wall, so it reproduces the block but may not be the
/// real climb path. A candidate fix (replacing the A6.P4 neg-poly
/// "return Collided" shortcut with retail's slide_sphere) did NOT clear this:
/// the slide returns Slid with offset=0 (displacement already along the
/// crease), the loop re-checks with gDelta≈0 → SlideSphere's
/// offset.LengthSquared&lt;ε → Collided branch → revert. The real fix needs a
/// faithful repro + the slide/loop-commit investigation (see the handoff
/// doc 2026-06-04-p2-cellar-lip-flatfloor-cp-handoff.md, Correction 2).
/// </para>
///
/// When the wedge is fixed the player advances and this assertion FAILS —
/// that is the signal to flip it to assert the climb.
/// </summary>
[Fact]
public void DocumentsWedge_PlayerFrozenAtThreshold_BlockedByMinusXWall()
{
var engine = BuildEngineWithLipFixtures();
var body = BuildWedgeBody();
var traj = SimulateTicks(engine, body, ThresholdId, 30);
var final = traj[^1];
float yAdvance = WedgeBodyPos.Y - final.Position.Y; // +ve = moved into cottage
float zRise = final.Position.Z - WedgeBodyPos.Z; // +ve = climbed
Assert.True(
yAdvance < 0.1f && zRise < 0.1f,
$"DOCUMENTS-THE-BUG: expected the player to be FROZEN at the threshold " +
$"(the X-wall wedge). Instead it advanced to " +
$"({final.Position.X:F3},{final.Position.Y:F3},{final.Position.Z:F3}) " +
$"after 30 ticks (yAdvance={yAdvance:F3}, zRise={zRise:F3}). If the wedge " +
$"fix landed, FLIP this assertion to require the climb " +
$"(Z≥{CottageFloorZ - 0.05f:F2}, yAdvance>0.5).");
}
// ───────────────────────────── helpers ─────────────────────────────
private static PhysicsBody BuildWedgeBody() => new()
{
Position = WedgeBodyPos, // foot bottom Z=93.456
Orientation = Quaternion.Identity,
// Best-effort grounded seed: a flat floor at foot level so the player
// starts "on the ground" mid-climb (the exact body-before state would
// come from an ACDREAM_CAPTURE_RESOLVE JSONL; this approximation puts
// the foot-sphere center at Z=93.936 — the live wedge — so the
// geometric X-wall full-hit fires).
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z),
ContactPlaneCellId = ThresholdId,
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -WedgeBodyPos.Z),
WalkableVertices = new[]
{
new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X - 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y + 1f, WedgeBodyPos.Z),
new Vector3(WedgeBodyPos.X + 1f, WedgeBodyPos.Y - 1f, WedgeBodyPos.Z),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
private static List<TrajPoint> SimulateTicks(
PhysicsEngine engine, PhysicsBody body, uint initialCellId, int tickCount)
{
uint cellId = initialCellId;
bool isOnGround = true;
var traj = new List<TrajPoint> { new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid) };
for (int tick = 1; tick <= tickCount; tick++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: target,
cellId: cellId,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
traj.Add(new(tick, body.Position, cellId, isOnGround, body.ContactPlaneValid));
}
return traj;
}
private sealed record TrajPoint(int Tick, Vector3 Position, uint CellId, bool IsOnGround, bool CpValid);
// ─────────────────────── faithful live-wedge replay ───────────────────────
// Replays a captured wedge ResolveWithTransition call (exact currentPos /
// targetPos / body-before from ACDREAM_CAPTURE_RESOLVE at the live cellar
// lip) through the lip-cell engine. The live climb direction is X,+Y (the
// synthetic Y guess was backwards). 29 representative wedge records are in
// Fixtures/cellar-lip/wedge-records.jsonl.
private static readonly System.Text.Json.JsonSerializerOptions WedgeJsonOptions =
new() { IncludeFields = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
private static List<ResolveCaptureRecord> LoadWedgeRecords()
{
var path = Path.Combine(FixtureDir, "wedge-records.jsonl");
Assert.True(File.Exists(path), $"Wedge fixture missing: {path}");
var list = new List<ResolveCaptureRecord>();
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line)) continue;
list.Add(System.Text.Json.JsonSerializer.Deserialize<ResolveCaptureRecord>(line, WedgeJsonOptions)!);
}
return list;
}
private static PhysicsBody SeedBody(PhysicsBodySnapshot s) => new()
{
Position = s.Position,
Orientation = s.Orientation,
Velocity = s.Velocity,
Acceleration = s.Acceleration,
Omega = s.Omega,
GroundNormal = s.GroundNormal,
SlidingNormal = s.SlidingNormal,
ContactPlaneValid = s.ContactPlaneValid,
ContactPlane = s.ContactPlane,
ContactPlaneCellId = s.ContactPlaneCellId,
ContactPlaneIsWater = s.ContactPlaneIsWater,
WalkablePolygonValid = s.WalkablePolygonValid,
WalkablePlane = s.WalkablePlane,
WalkableVertices = s.WalkableVertices,
WalkableUp = s.WalkableUp,
Elasticity = s.Elasticity,
Friction = s.Friction,
State = (PhysicsStateFlags)s.State,
TransientState = (TransientStateFlags)s.TransientState,
LastUpdateTime = s.LastUpdateTime,
};
private static (Vector3 res, float requested, float advance) ReplayRecord(ResolveCaptureRecord rec)
{
var engine = BuildEngineWithLipFixtures();
var body = SeedBody(rec.BodyBefore!);
var result = engine.ResolveWithTransition(
currentPos: rec.Input.CurrentPos,
targetPos: rec.Input.TargetPos,
cellId: rec.Input.CellId,
sphereRadius: rec.Input.SphereRadius,
sphereHeight: rec.Input.SphereHeight,
stepUpHeight: rec.Input.StepUpHeight,
stepDownHeight: rec.Input.StepDownHeight,
isOnGround: rec.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)rec.Input.MoverFlags,
movingEntityId: rec.Input.MovingEntityId);
float requested = Vector3.Distance(rec.Input.CurrentPos, rec.Input.TargetPos);
float advance = Vector3.Distance(rec.Input.CurrentPos, result.Position);
return (result.Position, requested, advance);
}
/// <summary>
/// Diagnostic: replay every captured wedge record and report advance% — to
/// confirm the lip-cell engine reproduces the live stuck (0% advance) before
/// asserting a fix. Always passes; results in the message + %TEMP% file.
/// </summary>
[Fact]
public void Diagnostic_ReplayLiveWedgeRecords_Advance()
{
var recs = LoadWedgeRecords();
var lines = new List<string>();
foreach (var (rec, i) in recs.Select((r, i) => (r, i)))
{
if (rec.BodyBefore is null) continue;
var (res, req, adv) = ReplayRecord(rec);
var cpN = rec.BodyBefore.ContactPlane.Normal;
lines.Add($"#{i} cp=({cpN.X:F2},{cpN.Y:F2},{cpN.Z:F2}) req={req:F3} adv={adv:F3} ({(req>0?100*adv/req:0):F0}%) " +
$"cur=({rec.Input.CurrentPos.X:F2},{rec.Input.CurrentPos.Y:F2},{rec.Input.CurrentPos.Z:F2}) " +
$"tgt=({rec.Input.TargetPos.X:F2},{rec.Input.TargetPos.Y:F2},{rec.Input.TargetPos.Z:F2}) " +
$"res=({res.X:F2},{res.Y:F2},{res.Z:F2})");
}
File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-replay.log"), string.Join("\n", lines));
Assert.True(true, string.Join("\n", lines.Take(10)));
}
/// <summary>
/// Diagnostic: replay ONE floor-CP wedge record with the step-up + indoor
/// probes on, capturing why the step-up fails. Output to %TEMP%/lip-wedge-stepup.log.
/// </summary>
[Fact]
public void Diagnostic_ReplayFloorCpRecord_StepUpProbes()
{
var rec = LoadWedgeRecords().First(r => r.BodyBefore is not null
&& r.BodyBefore.ContactPlane.Normal.Z > 0.99f);
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);
File.WriteAllText(Path.Combine(Path.GetTempPath(), "lip-wedge-stepup.log"),
$"record 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}) " +
$"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>
/// 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).
///
/// <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.
/// </para>
/// </summary>
[Fact]
public void DocumentsWedge_LiveFloorCp_PlayerStuckAtCottageFloorEdge()
{
var recs = LoadWedgeRecords();
var rec = recs.First(r => r.BodyBefore is not null
&& r.BodyBefore.ContactPlane.Normal.Z > 0.99f);
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. " +
$"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.");
}
private static PhysicsEngine BuildEngineWithLipFixtures()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
foreach (var cellId in new[] { CottageFloorId, Connector74Id, ThresholdId })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
Assert.True(File.Exists(path), $"Lip fixture missing: {path}");
var dump = CellDumpSerializer.Read(path);
var cell = CellDumpSerializer.Hydrate(dump);
cache.RegisterCellStructForTest(cellId, AttachSyntheticBsp(cell));
}
// Empty-terrain landblock so FindObjCollisions' TryGetLandblockContext
// succeeds at the lip XY (X≈153, Y≈9). Flat far-below surface; the
// indoor BSP path fires first so terrain is never consulted.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static CellPhysics AttachSyntheticBsp(CellPhysics cell)
{
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 0f), Radius = 15f },
};
foreach (var kv in cell.Resolved)
leaf.Polygons.Add(kv.Key);
return new CellPhysics
{
BSP = new PhysicsBSPTree { Root = leaf },
PhysicsPolygons = cell.PhysicsPolygons,
Vertices = cell.Vertices,
WorldTransform = cell.WorldTransform,
InverseWorldTransform = cell.InverseWorldTransform,
Resolved = cell.Resolved,
CellBSP = cell.CellBSP,
Portals = cell.Portals,
PortalPolygons = cell.PortalPolygons,
VisibleCellIds = cell.VisibleCellIds,
};
}
private static string FixtureDir =>
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", "Fixtures", "cellar-lip");
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException("Could not locate solution root (AcDream.slnx).");
}
}