test(phys): A6.P3 #98 — live ResolveWithTransition capture apparatus

Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.

Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
  with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
  record, JSON Lines writer (thread-safe, flushes per record), process-
  exit hook for clean shutdown.

- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
  method entry, snapshot again before return, refactor the two returns
  into one path so the capture call site is single. Filtered to
  IsPlayer mover flag so NPC/remote DR calls don't pollute.

- CellarUpTrajectoryReplayTests.cs:
  • Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
    ticks with capture on, reads file back, verifies round-trip of
    inputs + body-before/after snapshots.
  • Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
    IsPlayer flag), confirms the file is not created.

Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.

Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-23 19:41:11 +02:00
parent ec47159a2e
commit fb5fba6229
4 changed files with 461 additions and 16 deletions

View file

@ -299,6 +299,147 @@ public class CellarUpTrajectoryReplayTests
$"Took: {sw.ElapsedMilliseconds} ms.");
}
/// <summary>
/// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests
/// the <see cref="PhysicsResolveCapture"/> probe. Drives 3 ticks with
/// capture enabled, then reads the JSON-Lines file back and verifies:
/// <list type="bullet">
/// <item>One record per call.</item>
/// <item>Inputs round-trip (currentPos, targetPos, cellId, flags).</item>
/// <item>Body-before and body-after snapshots are present.</item>
/// </list>
/// This proves the production probe is wire-correct before we ask the
/// user to run a live capture in the cellar — if this test passes, the
/// only variable left is what's different about the live run.
/// </summary>
[Fact]
public void Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled()
{
string capturePath = Path.Combine(
Path.GetTempPath(),
$"acdream_capture_{Guid.NewGuid():N}.jsonl");
try
{
PhysicsResolveCapture.CapturePath = capturePath;
PhysicsResolveCapture.ResetTickCounter();
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
_ = SimulateTicks(engine, body, CellarId, 3);
PhysicsResolveCapture.Close();
Assert.True(File.Exists(capturePath), "Capture file should exist.");
var lines = File.ReadAllLines(capturePath);
Assert.Equal(3, lines.Length);
var records = lines
.Select(static l => System.Text.Json.JsonSerializer.Deserialize<ResolveCaptureRecord>(
l, CaptureJsonOptions))
.ToList();
// Tick monotonic 0,1,2.
Assert.Equal(0, records[0]!.Tick);
Assert.Equal(1, records[1]!.Tick);
Assert.Equal(2, records[2]!.Tick);
// Inputs at tick 0 must match the harness's initial position.
var firstInput = records[0]!.Input;
Assert.Equal(InitialSphereWorld.X, firstInput.CurrentPos.X, 4);
Assert.Equal(InitialSphereWorld.Y, firstInput.CurrentPos.Y, 4);
Assert.Equal(InitialSphereWorld.Z, firstInput.CurrentPos.Z, 4);
Assert.Equal(CellarId, firstInput.CellId);
Assert.True(firstInput.IsOnGround,
"First tick is seeded grounded.");
// Body before + after snapshots present.
Assert.NotNull(records[0]!.BodyBefore);
Assert.NotNull(records[0]!.BodyAfter);
// Body-before's ContactPlane should match the seeded floor plane.
var cpBefore = records[0]!.BodyBefore!.ContactPlane;
Assert.Equal(0f, cpBefore.Normal.X, 5);
Assert.Equal(0f, cpBefore.Normal.Y, 5);
Assert.Equal(1f, cpBefore.Normal.Z, 5);
Assert.Equal(-CellarFloorZ, cpBefore.D, 3);
}
finally
{
PhysicsResolveCapture.CapturePath = null;
PhysicsResolveCapture.Close();
if (File.Exists(capturePath))
File.Delete(capturePath);
}
}
/// <summary>
/// Capture is filtered to <c>IsPlayer</c> mover flag. Calls without
/// that flag (NPC, remote dead-reckoning) must NOT pollute the
/// capture file.
/// </summary>
[Fact]
public void Capture_SkipsNonPlayerCalls()
{
string capturePath = Path.Combine(
Path.GetTempPath(),
$"acdream_capture_npc_{Guid.NewGuid():N}.jsonl");
try
{
PhysicsResolveCapture.CapturePath = capturePath;
PhysicsResolveCapture.ResetTickCounter();
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
// Drive 3 ticks WITHOUT IsPlayer flag — simulates an NPC path.
uint cellId = CellarId;
bool isOnGround = true;
for (int i = 0; i < 3; i++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
body.Position, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: body,
moverFlags: ObjectInfoState.EdgeSlide, // ← no IsPlayer
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
}
PhysicsResolveCapture.Close();
// No records written because no IsPlayer call ran.
Assert.False(File.Exists(capturePath),
"Capture file should NOT exist when only non-player calls ran.");
}
finally
{
PhysicsResolveCapture.CapturePath = null;
PhysicsResolveCapture.Close();
if (File.Exists(capturePath))
File.Delete(capturePath);
}
}
/// <summary>
/// Shared deserialization options matching
/// <see cref="PhysicsResolveCapture"/>'s serializer. <c>IncludeFields</c>
/// is required because Vector3/Quaternion/Plane store components as
/// fields, not properties.
/// </summary>
public static readonly System.Text.Json.JsonSerializerOptions CaptureJsonOptions =
new()
{
IncludeFields = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
};
// ───────────────────────────────────────────────────────────────
// Harness internals
// ───────────────────────────────────────────────────────────────