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:
Erik 2026-05-23 19:58:51 +02:00
parent fb5fba6229
commit 44614ab591
2 changed files with 328 additions and 0 deletions

View file

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

View file

@ -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
// ───────────────────────────────────────────────────────────────