test(phys): A6.P3 #98 — comparison harness + first evidence-driven finding
The capture apparatus pays off on the FIRST iteration. Three records
sampled from a live cottage-cellar session — tick 0 (spawn at Z=92.53),
tick 376 (player on the cellar ramp at Z=91.49), and tick 1183 (first
cap event, foot Z=92.74 with cn=(0,0,-1)) — replayed against the
harness engine reveal:
- LiveCompare_Tick0_Spawn: PASSES (full round-trip).
- LiveCompare_Tick376_OnRamp: PASSES (ramp walkable
polygon hydrates correctly,
engine reproduces live).
- LiveCompare_FirstCap_HeadHitsCottageFloor: FAILS by exactly the
divergence shape that names
the missing fixture.
The cap-record divergence:
Result.Position: live=(141.3865,7.2243,92.7390)
harness=(141.3599,7.2243,92.7390) (Y slid; X stuck)
Result.CollisionNormal:live=(0,0,-1) ← downward = cottage floor from below
harness=(0,0,+1) ← upward = some other floor
Plus the LiveCompare_FirstCap_DiagnosticDump test (always passes; it's
a probe-firing scratch test) prints every cell polygon in world frame:
Cellar 0xA9B40147 — ceiling polys at world Z=93.80 cover X=133-142,
Y=-1.0-11.5 but NOT the sphere XY of (141.39, 7.03) — at the right
edge of Y=7.03 the ceiling quads are at Y<3.90 or Y>8.70.
Cottage 0xA9B40143 — floor polys at world Z=94.0 cover X=136.7-140.5,
Y=3.9-13.1 but NOT (141.39, 7.03) either — at X=141.39 we are 0.89m
east of the floor quad's rightmost vertex.
Cottage 0xA9B40146 — only 4 walls, no floor.
So both cells we have CAN'T produce the live's cn=(0,0,-1). The actual
blocking polygon must be in a cell or static object we haven't loaded
into the harness yet. The cellar is rectangularly bounded; the cottage
above has a floor that spans the cottage, but the floor polygon RIGHT
ABOVE the ramp top (which is where the freeze fires) is in some OTHER
cell — either a separate cottage-floor sub-cell or a building static
GfxObj.
This is the first evidence-driven step in the saga. Six sessions of
speculation produced ten failed fix shapes; the apparatus produced
this finding in one round trip. Next step: re-capture with
ACDREAM_PROBE_POLY_DUMP + ACDREAM_DUMP_CELLS covering 0xA9B40140-
0xA9B4014F to identify the missing fixture cell.
Adds:
- LiveCompare_Tick0_Spawn / Tick376_OnRamp / FirstCap_HeadHitsCottageFloor
- LiveCompare_FirstCap_DiagnosticDump (always passes; dumps cell polys
in world frame + enables every relevant probe so the captured stdout
shows the harness BSP query path)
- tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl
(3 representative records from the 41,228-record live capture)
- AssertCallMatchesCapture helper: per-field diff with Vector3 / float
tolerances, reports every divergence not just the first.
Test baseline maintained at 1172 + 8 baseline + 5 new tests pass +
1 known-failing test that pinpoints the bug = 1178 + 9 (where the
new failure is the desired evidence-driven test).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fb5fba6229
commit
44614ab591
2 changed files with 328 additions and 0 deletions
|
|
@ -0,0 +1,3 @@
|
|||
{"tick":0,"timestampMs":40919993,"input":{"currentPos":{"x":140.93564,"y":7.424385,"z":92.5333},"targetPos":{"x":140.93564,"y":7.424385,"z":92.5333},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0,"y":0,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":false,"contactPlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"contactPlaneCellId":0,"contactPlaneIsWater":false,"walkablePolygonValid":false,"walkablePlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"walkableVertices":null,"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0,"y":0,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":false,"contactPlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"contactPlaneCellId":0,"contactPlaneIsWater":false,"walkablePolygonValid":false,"walkablePlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"walkableVertices":null,"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}}
|
||||
{"tick":376,"timestampMs":40923854,"input":{"currentPos":{"x":141.02515,"y":8.432315,"z":91.48935},"targetPos":{"x":141.02515,"y":8.432315,"z":91.48935},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0.64338976,"y":11.830655,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0.64338976,"y":11.830655,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}}
|
||||
{"tick":1183,"timestampMs":40927362,"input":{"currentPos":{"x":141.35991,"y":7.224303,"z":92.73901},"targetPos":{"x":141.38649,"y":6.822069,"z":92.73901},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":141.38649,"y":6.822069,"z":92.73901},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.999456,"w":0.032981448},"velocity":{"x":0.7811123,"y":-11.822362,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":141.38649,"y":7.224303,"z":92.73901},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":0,"y":0,"z":-1}},"bodyAfter":{"position":{"x":141.38649,"y":6.822069,"z":92.73901},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.999456,"w":0.032981448},"velocity":{"x":0.7811123,"y":-11.822362,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}}
|
||||
|
|
@ -440,6 +440,331 @@ public class CellarUpTrajectoryReplayTests
|
|||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// A6.P3 #98 (2026-05-23 PM extension) — live-vs-harness comparison.
|
||||
// Loads the 3-record fixture sampled from a live capture in the
|
||||
// Holtburg cottage cellar and replays each through the harness's
|
||||
// PhysicsEngine. Each test compares one record's outputs (result +
|
||||
// body-after) to what the live engine produced, reporting the FIRST
|
||||
// per-field divergence. The divergence pinpoints what world state
|
||||
// the harness lacks vs production, ending the speculation loop that
|
||||
// burned 6 hypotheses on the airborne-at-tick-1 bug.
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Tick 0 — spawn/login teleport into the cellar at world Z=92.5333.
|
||||
/// No velocity, no contact-plane seed; currentPos == targetPos (no
|
||||
/// motion). The simplest test case: replay the call and verify the
|
||||
/// harness produces the same ResolveResult + bodyAfter state.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_Tick0_Spawn()
|
||||
{
|
||||
var (engine, cache) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record => record.Tick == 0);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tick 376 — player on the cellar ramp at world Z=91.49. Live capture
|
||||
/// has bodyAfter.WalkablePolygon = the ramp polygon (normal ≈
|
||||
/// (0, 0.719, 0.695), z range 90.99→94.00). If the harness reproduces
|
||||
/// the same walkable polygon + ResolveResult, the ramp geometry is
|
||||
/// loaded correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_Tick376_OnRamp()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record => record.Tick == 376);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First-cap event — the failing tick. Live engine reports cn=(0,0,-1),
|
||||
/// a downward-facing collision normal, capping the foot sphere at
|
||||
/// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage
|
||||
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. So the head is
|
||||
/// bumping the cottage floor from BELOW.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the actual #98 bug, NOT a step-up / AdjustOffset problem
|
||||
/// — it's a head-sphere collision against a polygon that retail
|
||||
/// doesn't have (cottage floor should be punched-through above the
|
||||
/// ramp). Whether the harness reproduces the cap pinpoints whether
|
||||
/// the cottage-cell floor polygon set is the cause.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_FirstCap_HeadHitsCottageFloor()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record =>
|
||||
record.Result.CollisionNormalValid
|
||||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic dump: turns on every relevant probe and replays the
|
||||
/// first-cap record, so the captured stdout shows which polygon the
|
||||
/// harness BSP hit when it computed cn=(0,0,+1) — pinpoints the
|
||||
/// missing fixture cell or the wrong-winding-order polygon.
|
||||
/// Always passes; this is a one-shot tool, not a regression.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LiveCompare_FirstCap_DiagnosticDump()
|
||||
{
|
||||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||||
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||||
try
|
||||
{
|
||||
var (engine, cache) = BuildEngineWithCellarFixtures();
|
||||
|
||||
// Dump the cellar cell's polygons so we can see what BSP is
|
||||
// testing against. The harness hit cn=(0,0,+1) — find which
|
||||
// polygon has that normal.
|
||||
DumpCellPolygons(cache, CellarId);
|
||||
DumpCellPolygons(cache, CottageNeighborA);
|
||||
DumpCellPolygons(cache, CottageNeighborB);
|
||||
|
||||
var captured = LoadCapturedRecord(record =>
|
||||
record.Result.CollisionNormalValid
|
||||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||||
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
|
||||
|
||||
Console.WriteLine($"=== Replay tick {captured.Tick} ===");
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: captured.Input.CurrentPos,
|
||||
targetPos: captured.Input.TargetPos,
|
||||
cellId: captured.Input.CellId,
|
||||
sphereRadius: captured.Input.SphereRadius,
|
||||
sphereHeight: captured.Input.SphereHeight,
|
||||
stepUpHeight: captured.Input.StepUpHeight,
|
||||
stepDownHeight: captured.Input.StepDownHeight,
|
||||
isOnGround: captured.Input.IsOnGround,
|
||||
body: body,
|
||||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
Console.WriteLine(
|
||||
$"=== Result pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4}) " +
|
||||
$"cn=({result.CollisionNormal.X:F4},{result.CollisionNormal.Y:F4},{result.CollisionNormal.Z:F4}) " +
|
||||
$"cnValid={result.CollisionNormalValid} onGround={result.IsOnGround}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||||
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
|
||||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DumpCellPolygons(PhysicsDataCache cache, uint cellId)
|
||||
{
|
||||
var cell = cache.GetCellStruct(cellId);
|
||||
if (cell is null)
|
||||
{
|
||||
Console.WriteLine($"[cell-dump] 0x{cellId:X8} NOT IN CACHE");
|
||||
return;
|
||||
}
|
||||
var t = cell.WorldTransform;
|
||||
Console.WriteLine($"[cell-dump] 0x{cellId:X8} resolved-poly-count={cell.Resolved.Count}");
|
||||
Console.WriteLine($" WorldTransform.M14={t.M14:F4} M24={t.M24:F4} M34={t.M34:F4} (origin XYZ?)");
|
||||
Console.WriteLine($" Translation=({t.Translation.X:F4},{t.Translation.Y:F4},{t.Translation.Z:F4})");
|
||||
foreach (var kv in cell.Resolved)
|
||||
{
|
||||
var p = kv.Value;
|
||||
// Show world-frame vertices for the first 2 polys with normal-Z>0.9
|
||||
// (floor candidates) — these are the polygons the head sphere
|
||||
// could hit from below.
|
||||
string vertsWorld = "";
|
||||
if (p.Plane.Normal.Z > 0.9f || p.Plane.Normal.Z < -0.9f)
|
||||
{
|
||||
vertsWorld = " worldVerts=[" + string.Join(",", p.Vertices.Select(v =>
|
||||
{
|
||||
var w = Vector3.Transform(v, cell.WorldTransform);
|
||||
return $"({w.X:F2},{w.Y:F2},{w.Z:F2})";
|
||||
})) + "]";
|
||||
}
|
||||
Console.WriteLine(
|
||||
$" poly id=0x{p.Id:X4} sides={p.SidesType} n=({p.Plane.Normal.X:F4},{p.Plane.Normal.Y:F4},{p.Plane.Normal.Z:F4}) d={p.Plane.D:F4} numV={p.NumPoints}{vertsWorld}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the live-capture.jsonl fixture and returns the FIRST record
|
||||
/// matching <paramref name="predicate"/>. Throws with a clear error
|
||||
/// when none match — keeps the test failure attributed to the
|
||||
/// fixture, not to deserialization.
|
||||
/// </summary>
|
||||
private static ResolveCaptureRecord LoadCapturedRecord(
|
||||
Func<ResolveCaptureRecord, bool> predicate)
|
||||
{
|
||||
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
|
||||
Assert.True(File.Exists(path),
|
||||
$"Live-capture fixture missing: {path}. Re-run live capture " +
|
||||
$"with ACDREAM_CAPTURE_RESOLVE set.");
|
||||
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
|
||||
var record = System.Text.Json.JsonSerializer
|
||||
.Deserialize<ResolveCaptureRecord>(line, CaptureJsonOptions)!;
|
||||
if (predicate(record))
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
"No captured record matched the predicate. Update the fixture " +
|
||||
"to include a representative record.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays one captured ResolveWithTransition call through the harness
|
||||
/// engine, seeded with the captured body-before state, and compares
|
||||
/// the harness's ResolveResult + body-after vs the captured values.
|
||||
/// Reports the FIRST per-field divergence with both values so the
|
||||
/// missing apparatus state is named.
|
||||
/// </summary>
|
||||
private static void AssertCallMatchesCapture(
|
||||
PhysicsEngine engine,
|
||||
ResolveCaptureRecord captured)
|
||||
{
|
||||
Assert.NotNull(captured.BodyBefore);
|
||||
Assert.NotNull(captured.BodyAfter);
|
||||
|
||||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||||
|
||||
var harnessResult = engine.ResolveWithTransition(
|
||||
currentPos: captured.Input.CurrentPos,
|
||||
targetPos: captured.Input.TargetPos,
|
||||
cellId: captured.Input.CellId,
|
||||
sphereRadius: captured.Input.SphereRadius,
|
||||
sphereHeight: captured.Input.SphereHeight,
|
||||
stepUpHeight: captured.Input.StepUpHeight,
|
||||
stepDownHeight: captured.Input.StepDownHeight,
|
||||
isOnGround: captured.Input.IsOnGround,
|
||||
body: body,
|
||||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||||
movingEntityId: captured.Input.MovingEntityId);
|
||||
|
||||
// Compare in priority order — most consequential divergence first.
|
||||
var divergences = new List<string>();
|
||||
|
||||
// 1. Result fields
|
||||
AddIfDifferent(divergences, "Result.Position",
|
||||
captured.Result.Position, harnessResult.Position);
|
||||
AddIfDifferent(divergences, "Result.CellId",
|
||||
$"0x{captured.Result.CellId:X8}",
|
||||
$"0x{harnessResult.CellId:X8}");
|
||||
AddIfDifferent(divergences, "Result.IsOnGround",
|
||||
captured.Result.IsOnGround, harnessResult.IsOnGround);
|
||||
AddIfDifferent(divergences, "Result.CollisionNormalValid",
|
||||
captured.Result.CollisionNormalValid,
|
||||
harnessResult.CollisionNormalValid);
|
||||
if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid)
|
||||
{
|
||||
AddIfDifferent(divergences, "Result.CollisionNormal",
|
||||
captured.Result.CollisionNormal,
|
||||
harnessResult.CollisionNormal);
|
||||
}
|
||||
|
||||
// 2. Body-after fields (subset that's most likely to diverge first)
|
||||
AddIfDifferent(divergences, "BodyAfter.Position",
|
||||
captured.BodyAfter.Position, body.Position);
|
||||
AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid",
|
||||
captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid);
|
||||
if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid)
|
||||
{
|
||||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal",
|
||||
captured.BodyAfter.ContactPlane.Normal,
|
||||
body.ContactPlane.Normal);
|
||||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.D",
|
||||
captured.BodyAfter.ContactPlane.D,
|
||||
body.ContactPlane.D);
|
||||
}
|
||||
AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid",
|
||||
captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid);
|
||||
AddIfDifferent(divergences, "BodyAfter.TransientState",
|
||||
$"0x{captured.BodyAfter.TransientState:X}",
|
||||
$"0x{(uint)body.TransientState:X}");
|
||||
|
||||
if (divergences.Count > 0)
|
||||
{
|
||||
string summary = string.Join("\n • ", divergences);
|
||||
string header = string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"Harness replay of captured tick {0} diverges from live engine. " +
|
||||
"Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " +
|
||||
"cellId=0x{7:X8} isOnGround={8}",
|
||||
captured.Tick,
|
||||
captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z,
|
||||
captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z,
|
||||
captured.Input.CellId, captured.Input.IsOnGround);
|
||||
throw new Xunit.Sdk.XunitException(
|
||||
header + "\nDivergences (live → harness):\n • " + summary);
|
||||
}
|
||||
}
|
||||
|
||||
private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new()
|
||||
{
|
||||
Position = snap.Position,
|
||||
Orientation = snap.Orientation,
|
||||
Velocity = snap.Velocity,
|
||||
Acceleration = snap.Acceleration,
|
||||
Omega = snap.Omega,
|
||||
GroundNormal = snap.GroundNormal,
|
||||
SlidingNormal = snap.SlidingNormal,
|
||||
ContactPlaneValid = snap.ContactPlaneValid,
|
||||
ContactPlane = snap.ContactPlane,
|
||||
ContactPlaneCellId = snap.ContactPlaneCellId,
|
||||
ContactPlaneIsWater = snap.ContactPlaneIsWater,
|
||||
WalkablePolygonValid = snap.WalkablePolygonValid,
|
||||
WalkablePlane = snap.WalkablePlane,
|
||||
WalkableVertices = snap.WalkableVertices,
|
||||
WalkableUp = snap.WalkableUp,
|
||||
Elasticity = snap.Elasticity,
|
||||
Friction = snap.Friction,
|
||||
State = (PhysicsStateFlags)snap.State,
|
||||
TransientState = (TransientStateFlags)snap.TransientState,
|
||||
LastUpdateTime = snap.LastUpdateTime,
|
||||
};
|
||||
|
||||
private static void AddIfDifferent<T>(
|
||||
List<string> divergences, string name, T live, T harness)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(live, harness))
|
||||
return;
|
||||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"{0}: live={1} harness={2}", name, live, harness));
|
||||
}
|
||||
|
||||
private static void AddIfDifferent(
|
||||
List<string> divergences, string name, Vector3 live, Vector3 harness)
|
||||
{
|
||||
if (Vector3.DistanceSquared(live, harness) < 1e-6f)
|
||||
return;
|
||||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})",
|
||||
name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z));
|
||||
}
|
||||
|
||||
private static void AddIfDifferent(
|
||||
List<string> divergences, string name, float live, float harness)
|
||||
{
|
||||
if (MathF.Abs(live - harness) < 1e-3f)
|
||||
return;
|
||||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||||
"{0}: live={1:F4} harness={2:F4}", name, live, harness));
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Harness internals
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue