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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
src/AcDream.Core/Physics/PhysicsResolveCapture.cs
Normal file
249
src/AcDream.Core/Physics/PhysicsResolveCapture.cs
Normal 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=<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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue