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:
parent
ec47159a2e
commit
fb5fba6229
4 changed files with 461 additions and 16 deletions
|
|
@ -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
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue