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>
249 lines
9.9 KiB
C#
249 lines
9.9 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Numerics;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
|
|
namespace AcDream.Core.Physics;
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// A6.P3 issue #98 (2026-05-23) — live capture of every player-side
|
|
// PhysicsEngine.ResolveWithTransition call so the trajectory replay harness
|
|
// (tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs) can
|
|
// load the captured fixture and replay the EXACT inputs deterministically.
|
|
// The captured outputs become the diff target: any per-field divergence
|
|
// between live and replay pinpoints harness apparatus that is missing,
|
|
// short-circuiting six rounds of guessing what state the harness lacks.
|
|
//
|
|
// Enabled by setting ACDREAM_CAPTURE_RESOLVE=<path>. Each call appends one
|
|
// JSON Lines record (self-contained — no cross-line dependencies). Filtered
|
|
// to IsPlayer mover flag so NPC / remote ResolveWithTransition calls don't
|
|
// pollute the capture.
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A6.P3 issue #98 live capture of player ResolveWithTransition calls.
|
|
/// Pairs with <see cref="CellarUpTrajectoryReplayTests"/>'s replay harness:
|
|
/// the capture is the test's input fixture AND the diff-target output.
|
|
/// Off unless <c>ACDREAM_CAPTURE_RESOLVE=<path></c> is set.
|
|
/// </summary>
|
|
public static class PhysicsResolveCapture
|
|
{
|
|
/// <summary>
|
|
/// Output path for captured records. Each call appends one JSON Lines
|
|
/// record. When null/empty, capture is off (one null-check cost per
|
|
/// call, no allocations). Initialized from
|
|
/// <c>ACDREAM_CAPTURE_RESOLVE</c> at process start; runtime mutation
|
|
/// allowed for tests.
|
|
/// </summary>
|
|
public static string? CapturePath { get; set; } =
|
|
Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_RESOLVE");
|
|
|
|
/// <summary>True when <see cref="CapturePath"/> is non-empty.</summary>
|
|
public static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath);
|
|
|
|
private static int _tickCounter;
|
|
private static StreamWriter? _writer;
|
|
private static readonly object _writerLock = new();
|
|
|
|
// System.Numerics.Vector3 / Quaternion / Plane expose their components
|
|
// as public FIELDS (X/Y/Z[/W], Normal/D), not properties. Without
|
|
// IncludeFields=true the resulting JSON serializes them as empty
|
|
// objects ({}) — verified by smoke-test failure 2026-05-23 evening.
|
|
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
|
{
|
|
WriteIndented = false,
|
|
IncludeFields = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Captures every PhysicsBody field that
|
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/> reads or writes.
|
|
/// Copies value-type fields directly (Vector3 / Plane / Quaternion
|
|
/// are structs — no aliasing) and clones <c>WalkableVertices</c>
|
|
/// since the engine may replace the array.
|
|
/// </summary>
|
|
public static PhysicsBodySnapshot Snapshot(PhysicsBody body) => new(
|
|
Position: body.Position,
|
|
Orientation: body.Orientation,
|
|
Velocity: body.Velocity,
|
|
Acceleration: body.Acceleration,
|
|
Omega: body.Omega,
|
|
GroundNormal: body.GroundNormal,
|
|
SlidingNormal: body.SlidingNormal,
|
|
ContactPlaneValid: body.ContactPlaneValid,
|
|
ContactPlane: body.ContactPlane,
|
|
ContactPlaneCellId: body.ContactPlaneCellId,
|
|
ContactPlaneIsWater: body.ContactPlaneIsWater,
|
|
WalkablePolygonValid: body.WalkablePolygonValid,
|
|
WalkablePlane: body.WalkablePlane,
|
|
WalkableVertices: body.WalkableVertices is null
|
|
? null
|
|
: (Vector3[])body.WalkableVertices.Clone(),
|
|
WalkableUp: body.WalkableUp,
|
|
Elasticity: body.Elasticity,
|
|
Friction: body.Friction,
|
|
State: (uint)body.State,
|
|
TransientState: (uint)body.TransientState,
|
|
LastUpdateTime: body.LastUpdateTime);
|
|
|
|
/// <summary>
|
|
/// Write one JSON-Lines record for a player ResolveWithTransition call.
|
|
/// Caller MUST guard with <c>if (!IsEnabled) return;</c> before snapshotting
|
|
/// the body — this method assumes the caller paid the snapshot cost.
|
|
/// </summary>
|
|
public static void LogCall(
|
|
ResolveCallInputs input,
|
|
PhysicsBodySnapshot? bodyBefore,
|
|
ResolveCallResult result,
|
|
PhysicsBodySnapshot? bodyAfter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(CapturePath))
|
|
return;
|
|
|
|
var record = new ResolveCaptureRecord(
|
|
Tick: Interlocked.Increment(ref _tickCounter) - 1,
|
|
TimestampMs: (long)(System.Diagnostics.Stopwatch.GetTimestamp()
|
|
* 1000.0 / System.Diagnostics.Stopwatch.Frequency),
|
|
Input: input,
|
|
BodyBefore: bodyBefore,
|
|
Result: result,
|
|
BodyAfter: bodyAfter);
|
|
|
|
string json = JsonSerializer.Serialize(record, s_jsonOptions);
|
|
|
|
lock (_writerLock)
|
|
{
|
|
EnsureWriter_NoLock();
|
|
_writer!.WriteLine(json);
|
|
_writer.Flush();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Close the capture file. Called by the writer's process-exit hook
|
|
/// in <see cref="EnsureWriter_NoLock"/>; tests may also call it
|
|
/// manually to release the file before re-opening.
|
|
/// </summary>
|
|
public static void Close()
|
|
{
|
|
lock (_writerLock)
|
|
{
|
|
if (_writer is not null)
|
|
{
|
|
_writer.Flush();
|
|
_writer.Dispose();
|
|
_writer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset the per-process tick counter. Tests call this between
|
|
/// fixtures so the JSON tick field starts at zero.
|
|
/// </summary>
|
|
public static void ResetTickCounter() =>
|
|
Interlocked.Exchange(ref _tickCounter, 0);
|
|
|
|
private static void EnsureWriter_NoLock()
|
|
{
|
|
if (_writer is not null)
|
|
return;
|
|
|
|
var path = CapturePath!;
|
|
var dir = Path.GetDirectoryName(path);
|
|
if (!string.IsNullOrEmpty(dir))
|
|
Directory.CreateDirectory(dir);
|
|
|
|
// Append mode — multiple sessions can accumulate in the same file
|
|
// (the user can split by Tick=0 boundaries later).
|
|
var stream = new FileStream(
|
|
path,
|
|
FileMode.Append,
|
|
FileAccess.Write,
|
|
FileShare.Read);
|
|
_writer = new StreamWriter(stream)
|
|
{
|
|
AutoFlush = false,
|
|
};
|
|
|
|
// Best-effort: flush + close on process exit so a hard-kill doesn't
|
|
// truncate the last record.
|
|
AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close();
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Serializable records. System.Text.Json serializes Vector3/Quaternion/Plane
|
|
// natively as {X,Y,Z[,W]} objects — no custom converters needed. Numeric
|
|
// types and bools are camelCased per the global JsonSerializerOptions above.
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// One captured player-side ResolveWithTransition call. Self-contained:
|
|
/// every field needed to replay the call is here.
|
|
/// </summary>
|
|
public sealed record ResolveCaptureRecord(
|
|
int Tick,
|
|
long TimestampMs,
|
|
ResolveCallInputs Input,
|
|
PhysicsBodySnapshot? BodyBefore,
|
|
ResolveCallResult Result,
|
|
PhysicsBodySnapshot? BodyAfter);
|
|
|
|
/// <summary>
|
|
/// Every parameter to
|
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/>, in capture form.
|
|
/// </summary>
|
|
public sealed record ResolveCallInputs(
|
|
Vector3 CurrentPos,
|
|
Vector3 TargetPos,
|
|
uint CellId,
|
|
float SphereRadius,
|
|
float SphereHeight,
|
|
float StepUpHeight,
|
|
float StepDownHeight,
|
|
bool IsOnGround,
|
|
uint MoverFlags,
|
|
uint MovingEntityId);
|
|
|
|
/// <summary>
|
|
/// Every field of <see cref="ResolveResult"/>, in capture form.
|
|
/// </summary>
|
|
public sealed record ResolveCallResult(
|
|
Vector3 Position,
|
|
uint CellId,
|
|
bool IsOnGround,
|
|
bool CollisionNormalValid,
|
|
Vector3 CollisionNormal);
|
|
|
|
/// <summary>
|
|
/// Snapshot of every <see cref="PhysicsBody"/> field that
|
|
/// <see cref="PhysicsEngine.ResolveWithTransition"/> reads or writes.
|
|
/// Captured both before and after the call so the comparison harness
|
|
/// can verify that engine mutation matches between live and replay.
|
|
/// </summary>
|
|
public sealed record PhysicsBodySnapshot(
|
|
Vector3 Position,
|
|
Quaternion Orientation,
|
|
Vector3 Velocity,
|
|
Vector3 Acceleration,
|
|
Vector3 Omega,
|
|
Vector3 GroundNormal,
|
|
Vector3 SlidingNormal,
|
|
bool ContactPlaneValid,
|
|
Plane ContactPlane,
|
|
uint ContactPlaneCellId,
|
|
bool ContactPlaneIsWater,
|
|
bool WalkablePolygonValid,
|
|
Plane WalkablePlane,
|
|
Vector3[]? WalkableVertices,
|
|
Vector3 WalkableUp,
|
|
float Elasticity,
|
|
float Friction,
|
|
uint State,
|
|
uint TransientState,
|
|
double LastUpdateTime);
|