xUnit's default parallel execution let diagnostic-harness tests (CellarUp, DoorBug, DoorCollisionApparatus) mutate PhysicsResolveCapture.CapturePath and PhysicsDiagnostics probe flags concurrently with victim tests (MotionInterpreter, PositionManager, PlayerMovementController, DispatcherToMovement, BSPStepUp), producing a flaky 14-26 failure range. Fixes: - Add PhysicsResolveCapture.ResetForTest() + PhysicsDiagnostics.ResetForTest() as documented test-only reset APIs (never called from production paths). - Add IDisposable to CellarUpTrajectoryReplayTests with ctor/Dispose calling both ResetForTest() — prevents CapturePath from leaking between the Capture_* tests in the same class (the immediate root cause of Capture_SkipsNonPlayerCalls finding an unexpected file). - Add xunit.runner.json (maxParallelThreads=1, parallelizeTestCollections=false) to AcDream.Core.Tests — eliminates parallelism-induced probe-flag leaks across all test classes without requiring [Collection] boilerplate on every offender. After: two consecutive runs produce the identical 12-failure set. Confirmed: LiveCompare_FirstCap_FixClosesCottageFloorCap passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
267 lines
10 KiB
C#
267 lines
10 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);
|
|
|
|
/// <summary>
|
|
/// Test-only reset: close any open writer, clear <see cref="CapturePath"/>,
|
|
/// and reset the tick counter to 0. Call from test constructors and
|
|
/// <c>IDisposable.Dispose()</c> to prevent static state from leaking
|
|
/// across test-class boundaries.
|
|
///
|
|
/// <para>
|
|
/// This method is intentionally <c>public</c> so test projects can call it
|
|
/// without reflection, but it must NEVER be called from production code paths.
|
|
/// </para>
|
|
/// </summary>
|
|
public static void ResetForTest()
|
|
{
|
|
Close();
|
|
CapturePath = null;
|
|
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);
|