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
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -1314,6 +1314,16 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
|
||||||
(~100–500 lines/sec). Pair with retail's cdb breakpoint set at
|
(~100–500 lines/sec). Pair with retail's cdb breakpoint set at
|
||||||
`tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol.
|
`tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol.
|
||||||
Runtime-toggleable via the DebugPanel "Diagnostics" section.
|
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
|
- *(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
|
env-var gate on an experimental per-tick remote motion path. L.3 M2
|
||||||
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
|
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
|
||||||
|
|
|
||||||
|
|
@ -595,6 +595,17 @@ public sealed class PhysicsEngine
|
||||||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||||
uint movingEntityId = 0)
|
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();
|
var transition = new Transition();
|
||||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
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}"));
|
$"[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)
|
if (ok)
|
||||||
{
|
{
|
||||||
bool onGround = ci.ContactPlaneValid
|
bool onGround = ci.ContactPlaneValid
|
||||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||||
|
|
||||||
return new ResolveResult(
|
resolveResult = new ResolveResult(
|
||||||
sp.CheckPos,
|
sp.CheckPos,
|
||||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
|
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
|
||||||
onGround,
|
onGround,
|
||||||
collisionNormalValid,
|
collisionNormalValid,
|
||||||
collisionNormal);
|
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).
|
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||||
// Use whatever position the transition reached (partial movement)
|
resolveResult = new ResolveResult(
|
||||||
// instead of falling back to the no-collision Resolve.
|
sp.CheckPos,
|
||||||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
||||||
// this is correct behavior when completely blocked.
|
partialOnGround,
|
||||||
bool partialOnGround = ci.ContactPlaneValid
|
collisionNormalValid,
|
||||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|
collisionNormal);
|
||||||
|| isOnGround;
|
}
|
||||||
|
|
||||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||||
return new ResolveResult(
|
// with bodyBefore snapshot (taken at method entry, before any
|
||||||
sp.CheckPos,
|
// engine mutation) + bodyAfter snapshot (taken now, after the
|
||||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
// engine wrote back the contact plane / walkable / sliding state
|
||||||
partialOnGround,
|
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
|
||||||
collisionNormalValid,
|
if (captureEnabled)
|
||||||
collisionNormal);
|
{
|
||||||
|
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);
|
||||||
|
|
@ -299,6 +299,147 @@ public class CellarUpTrajectoryReplayTests
|
||||||
$"Took: {sw.ElapsedMilliseconds} ms.");
|
$"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
|
// Harness internals
|
||||||
// ───────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue