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

@ -1314,6 +1314,16 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
(~100500 lines/sec). Pair with retail's cdb breakpoint set at
`tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol.
Runtime-toggleable via the DebugPanel "Diagnostics" section.
- `ACDREAM_CAPTURE_RESOLVE=<path>` — A6.P3 #98 live capture of every
player-side `PhysicsEngine.ResolveWithTransition` call (2026-05-23 PM
apparatus). Each call appends one JSON Lines record with full inputs,
PhysicsBody snapshot before AND after, plus the `ResolveResult`.
Filtered to `IsPlayer` mover flag — NPC / remote DR calls don't
pollute. Pairs with the trajectory replay harness comparison test
(`CellarUpTrajectoryReplayTests.Capture_*`) to diff captured vs harness
state per field — the first divergence pinpoints missing apparatus
state. Capture is OFF when the env var is unset (one null-check
cost per call).
- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an
env-var gate on an experimental per-tick remote motion path. L.3 M2
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +

View file

@ -595,6 +595,17 @@ public sealed class PhysicsEngine
ObjectInfoState moverFlags = ObjectInfoState.None,
uint movingEntityId = 0)
{
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
// the body BEFORE the engine mutates it so the replay test can seed its
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
bool captureEnabled = PhysicsResolveCapture.IsEnabled
&& moverFlags.HasFlag(ObjectInfoState.IsPlayer);
PhysicsBodySnapshot? bodyBeforeSnap =
captureEnabled && body is not null
? PhysicsResolveCapture.Snapshot(body)
: null;
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
transition.ObjectInfo.StepDownHeight = stepDownHeight;
@ -825,34 +836,68 @@ public sealed class PhysicsEngine
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
}
ResolveResult resolveResult;
if (ok)
{
bool onGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
return new ResolveResult(
resolveResult = new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
onGround,
collisionNormalValid,
collisionNormal);
}
else
{
// Transition failed (e.g., stuck in corner, too many steps).
// Use whatever position the transition reached (partial movement)
// instead of falling back to the no-collision Resolve.
// If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
// Transition failed (e.g., stuck in corner, too many steps).
// Use whatever position the transition reached (partial movement)
// instead of falling back to the no-collision Resolve.
// If CheckPos hasn't moved from CurPos, the player stays put —
// this is correct behavior when completely blocked.
bool partialOnGround = ci.ContactPlaneValid
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|| isOnGround;
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
resolveResult = new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
partialOnGround,
collisionNormalValid,
collisionNormal);
}
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
return new ResolveResult(
sp.CheckPos,
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
partialOnGround,
collisionNormalValid,
collisionNormal);
// A6.P3 #98 capture: emit one JSON Lines record per player call,
// with bodyBefore snapshot (taken at method entry, before any
// engine mutation) + bodyAfter snapshot (taken now, after the
// engine wrote back the contact plane / walkable / sliding state
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
if (captureEnabled)
{
PhysicsResolveCapture.LogCall(
new ResolveCallInputs(
CurrentPos: currentPos,
TargetPos: targetPos,
CellId: cellId,
SphereRadius: sphereRadius,
SphereHeight: sphereHeight,
StepUpHeight: stepUpHeight,
StepDownHeight: stepDownHeight,
IsOnGround: isOnGround,
MoverFlags: (uint)moverFlags,
MovingEntityId: movingEntityId),
bodyBeforeSnap,
new ResolveCallResult(
Position: resolveResult.Position,
CellId: resolveResult.CellId,
IsOnGround: resolveResult.IsOnGround,
CollisionNormalValid: resolveResult.CollisionNormalValid,
CollisionNormal: resolveResult.CollisionNormal),
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
}
return resolveResult;
}
}

View file

@ -0,0 +1,249 @@
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=&lt;path&gt;</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);

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