using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// A6.P3 issue #98 (2026-05-23) — deterministic TRAJECTORY replay
/// harness for the cottage cellar-ascent failure. Drives
/// through N physics
/// ticks against pre-loaded cell fixtures, capturing a per-tick
/// trajectory record.
///
///
/// Unlike (which tests a SINGLE
/// failing-frame's geometry against our walkable predicates), this
/// harness drives MANY ticks through the full engine to reproduce the
/// trajectory itself — once the fixtures support it (see below).
///
///
/// Status as of 2026-05-23 evening: harness mechanics WORK, fixtures
/// INCOMPLETE.
///
///
/// The harness compiles and runs the engine through N ticks in
/// < 100 ms total. Two findings during commissioning:
///
///
///
/// - The three issue-#98 cell fixtures
/// (tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40*.json)
/// contain ONLY axis-aligned polygons — cellar floor, cellar
/// ceiling, four cellar walls, cottage floor, cottage walls. The
/// live capture's CELLAR RAMP polygon
/// (normal ≈ (0, ±0.719, 0.695)) is NOT in any of the
/// fixtures. Without it the harness can't reproduce the climb
/// trajectory — the sphere walks across the cellar floor
/// horizontally and never encounters a slope.
/// - Independently: at the sphere's initial position resting on
/// the cellar floor, the engine reports
/// hit=yes n=(0,0,1) walkable=False on tick 1 and rejects
/// the forward move. The grounded state flips off and subsequent
/// ticks proceed as airborne (no Z change). This may be a real
/// engine bug (touching the floor classified as non-walkable
/// collision) or a fixture issue (cellar floor poly's
/// containment test mis-firing). Either way, the harness
/// exposes it deterministically — that's the point.
///
///
///
/// Before this harness can drive issue-#98 trajectory fix attempts,
/// the fixtures need a re-capture that includes:
///
///
///
/// - The cellar ramp polygon (whichever cell it actually lives
/// in — the live capture said cellar cell 0xA9B40147,
/// but our dump doesn't have it; investigate
/// to see whether some
/// polygons are being skipped during capture).
/// - Any neighboring cells the sphere may transit into during
/// the climb (the live capture's
/// [cell-set-summary] showed overlap with
/// 0xA9B40143 and 0xA9B40146, both already in
/// the fixture set — but additional cells beyond these may
/// appear at tick boundaries we haven't observed).
///
///
///
/// The current tests document the harness mechanics + the two
/// findings above. When fixtures are re-captured, flip
/// 's assertion
/// to require a successful climb and add additional tests for the
/// trajectory shape.
///
///
public class CellarUpTrajectoryReplayTests
{
// ── Cellar / cottage geometry constants ────────────────────────
private const uint CellarId = 0xA9B40147u;
private const uint CottageNeighborA = 0xA9B40143u;
private const uint CottageNeighborB = 0xA9B40146u;
private const float CellarFloorZ = 90.95f;
private const float CottageFloorZ = 94.00f;
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.60f;
private const float StepDownHeight = 0.04f;
///
/// Sphere center starts exactly at its natural resting position on
/// the cellar floor: bottom on floor, center at Z = floor + radius.
/// Y=9.5 is ~0.75 m before the ramp foot at Y=8.75 (live-capture
/// ramp plane: 0.719·y + 0.695·z = 69.5035 → y=8.75 at z=90.95).
/// X=141.5 matches the live capture's X.
///
private static readonly Vector3 InitialSphereWorld =
new(141.5f, 9.5f, CellarFloorZ + SphereRadius);
///
/// Per-tick forward offset (−Y direction toward the ramp).
/// Magnitude (~0.10 m) matches the live capture's observed per-tick
/// requested offset.
///
private static readonly Vector3 PerTickOffset =
new(0f, -0.10f, 0f);
private const int SimulationTicks = 200;
// ───────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────
///
/// Confirms the harness compiles, the engine runs the simulation,
/// and a trajectory comes back with the expected number of points.
/// Does NOT assert on trajectory CONTENT — fixture limitations
/// (see class summary) make content-level assertions premature.
///
[Fact]
public void Harness_CompilesAndRunsSimulation()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks);
Assert.Equal(SimulationTicks + 1, trajectory.Count);
Assert.Equal(0, trajectory[0].Tick);
Assert.Equal(SimulationTicks, trajectory[^1].Tick);
}
///
/// Diagnostic dump: print the first 10 trajectory points + the
/// engine's resolve-probe decisions. Useful when investigating
/// what the harness is actually doing.
///
[Fact]
public void Harness_DiagnosticDump_FirstTenTicks()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 2);
var msg = "Trajectory (2 ticks):\n " +
string.Join("\n ", trajectory.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}"));
// Always pass — this is a diagnostic test; the probe
// output appears in the test runner's captured stdout
// and the trajectory in the assertion message on failure.
Assert.True(true, msg);
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
}
}
///
/// Experiment: drive without a PhysicsBody (no CP seeding, no
/// cross-tick state). Tests whether the airborne-at-tick-1 issue
/// is caused by the seeded CP creating a false collision against
/// the cellar floor.
///
[Fact]
public void Harness_DiagnosticDump_NoBodySeed()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
uint cellId = CellarId;
bool isOnGround = true;
Vector3 pos = InitialSphereWorld;
var trajectory = new List
{
new(0, pos, cellId, isOnGround, false),
};
for (int tick = 1; tick <= 10; tick++)
{
Vector3 target = pos + PerTickOffset;
var result = engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
isOnGround,
body: null, // ← no body, no CP seed
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
trajectory.Add(new(tick, pos, cellId, isOnGround, false));
}
var msg = "No-body trajectory (10 ticks):\n " +
string.Join("\n ", trajectory.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"onGround={p.IsOnGround}"));
Assert.True(true, msg);
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
}
}
///
/// Documents the deep-investigation finding (2026-05-23 evening
/// extension): the seeded grounded sphere still goes airborne at
/// tick 1 with hit=(0,1,0) — a +Y wall normal that doesn't match
/// any registered geometry. The hit is set by ValidateTransition
/// after the inner TransitionalInsert returns Collided, but the
/// source of the (0,1,0) inside TransitionalInsert is not yet
/// isolated.
///
///
/// Investigation excluded:
///
/// - Stub landblock terrain (removed; same hit)
/// - Synthetic stair GfxObj (removed; same hit)
/// - Cell BSP=null on Hydrate (attached synthetic BSP; same hit)
/// - WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains)
/// - Initial sphere Z lift 0.0 vs 0.05 m (same hit)
/// - PhysicsBody seeded vs body=null (same hit)
///
///
///
///
/// Next session's investigation move: build a side-by-side
/// instrumentation harness that calls the EXACT same
/// ResolveWithTransition invocation as production's
/// PlayerMovementController, with identical body state, and
/// compare per-tick state divergence. The harness setup must be
/// missing some piece of state that production carries from a
/// prior live tick — find what piece.
///
///
[Fact]
public void Harness_Finding_SphereGoesAirborneAtTick1()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 3);
Assert.True(trajectory[0].IsOnGround,
"Tick 0 is the seeded starting state and must report grounded.");
Assert.False(trajectory[1].IsOnGround,
"Open finding: at tick 1 the engine reports the sphere is NOT " +
"grounded, even though it started seeded with ContactPlane + " +
"WalkablePolygon on the cellar floor and the cell has a " +
"synthetic BSP wrapping every polygon. Hit normal is (0,1,0) — " +
"doesn't match any registered geometry. Source of (0,1,0) " +
"inside TransitionalInsert is not yet isolated. See the class " +
"doc for the exclusion list and next investigation move.");
}
///
/// Perf budget for the harness: 200 ticks must complete in well
/// under 500 ms. If this ever fails, the inner loop has regressed
/// and the whole point of the harness — fast iteration on physics
/// fixes — is at risk.
///
[Fact]
public void Harness_SimulationRunsInUnder500ms()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var sw = System.Diagnostics.Stopwatch.StartNew();
_ = SimulateTicks(engine, body, CellarId, SimulationTicks);
sw.Stop();
Assert.True(sw.ElapsedMilliseconds < 500,
$"200-tick simulation should complete in under 500 ms. " +
$"Took: {sw.ElapsedMilliseconds} ms.");
}
///
/// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests
/// the probe. Drives 3 ticks with
/// capture enabled, then reads the JSON-Lines file back and verifies:
///
/// - One record per call.
/// - Inputs round-trip (currentPos, targetPos, cellId, flags).
/// - Body-before and body-after snapshots are present.
///
/// 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.
///
[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(
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);
}
}
///
/// Capture is filtered to IsPlayer mover flag. Calls without
/// that flag (NPC, remote dead-reckoning) must NOT pollute the
/// capture file.
///
[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);
}
}
///
/// Shared deserialization options matching
/// 's serializer. IncludeFields
/// is required because Vector3/Quaternion/Plane store components as
/// fields, not properties.
///
public static readonly System.Text.Json.JsonSerializerOptions CaptureJsonOptions =
new()
{
IncludeFields = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
};
// ───────────────────────────────────────────────────────────────
// A6.P3 #98 (2026-05-23 PM extension) — live-vs-harness comparison.
// Loads the 3-record fixture sampled from a live capture in the
// Holtburg cottage cellar and replays each through the harness's
// PhysicsEngine. Each test compares one record's outputs (result +
// body-after) to what the live engine produced, reporting the FIRST
// per-field divergence. The divergence pinpoints what world state
// the harness lacks vs production, ending the speculation loop that
// burned 6 hypotheses on the airborne-at-tick-1 bug.
// ───────────────────────────────────────────────────────────────
///
/// Tick 0 — spawn/login teleport into the cellar at world Z=92.5333.
/// No velocity, no contact-plane seed; currentPos == targetPos (no
/// motion). The simplest test case: replay the call and verify the
/// harness produces the same ResolveResult + bodyAfter state.
///
[Fact]
public void LiveCompare_Tick0_Spawn()
{
var (engine, cache) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 0);
AssertCallMatchesCapture(engine, captured);
}
///
/// Tick 376 — player on the cellar ramp at world Z=91.49. Live capture
/// has bodyAfter.WalkablePolygon = the ramp polygon (normal ≈
/// (0, 0.719, 0.695), z range 90.99→94.00). If the harness reproduces
/// the same walkable polygon + ResolveResult, the ramp geometry is
/// loaded correctly.
///
[Fact]
public void LiveCompare_Tick376_OnRamp()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 376);
AssertCallMatchesCapture(engine, captured);
}
///
/// First-cap event — the failing tick. Live engine reports cn=(0,0,-1),
/// a downward-facing collision normal, capping the foot sphere at
/// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. So the head is
/// bumping the cottage floor from BELOW.
///
///
/// This is the actual #98 bug, NOT a step-up / AdjustOffset problem.
/// Live capture's [resolve] probe pinpoints the blocking
/// entity: obj=0xA9B47900 — a landblock-baked static building
/// (the cottage GfxObj). The cottage's floor polygons live in this
/// GfxObj, registered as a ShadowEntry, NOT in any of the cottage's
/// cells. The harness's
/// loads cell fixtures but does NOT register the cottage GfxObj, so
/// the harness fails to reproduce the cap — DOCUMENTED here as the
/// divergence pattern.
///
///
///
/// Documents-the-bug pattern: passes WHILE the harness lacks the
/// cottage GfxObj. When a future session adds the cottage GfxObj
/// (full polygon list extracted from the live [poly-dump] +
/// [resolve-bldg] probes), this test will start failing —
/// the signal to flip it from documenting-the-bug to enforcing-the-fix.
///
///
[Fact]
public void LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
Assert.NotNull(captured.BodyBefore);
var body = SeedBodyFromSnapshot(captured.BodyBefore);
var harnessResult = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Live reported cn=(0,0,-1) blocking the climb at this point.
Assert.True(captured.Result.CollisionNormalValid,
"Captured record must have collisionNormalValid=true.");
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
$"Captured record must have downward collision normal; got " +
$"{captured.Result.CollisionNormal}.");
// Harness does NOT reproduce the live downward push because the
// cottage GfxObj is not registered — the blocking polygon lives
// in static obj 0xA9B47900, which BuildEngineWithCellarFixtures
// intentionally skips today (RegisterStairRampGfxObj is commented
// out). When the cottage GfxObj's full polygon set is added to
// the harness, this assertion will start to fail — flip the test
// to assert the live cn=(0,0,-1) round-trips at that point.
Assert.False(
harnessResult.CollisionNormalValid
&& harnessResult.CollisionNormal.Z < -0.99f,
"Harness should NOT reproduce the cottage-floor cap yet — " +
"if it does, the cottage GfxObj has been added and this test " +
"needs to flip to AssertCallMatchesCapture(engine, captured).");
}
///
/// Diagnostic dump: turns on every relevant probe and replays the
/// first-cap record, so the captured stdout shows which polygon the
/// harness BSP hit when it computed cn=(0,0,+1) — pinpoints the
/// missing fixture cell or the wrong-winding-order polygon.
/// Always passes; this is a one-shot tool, not a regression.
///
[Fact]
public void LiveCompare_FirstCap_DiagnosticDump()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
PhysicsDiagnostics.ProbePushBackEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
try
{
var (engine, cache) = BuildEngineWithCellarFixtures();
// Dump the cellar cell's polygons so we can see what BSP is
// testing against. The harness hit cn=(0,0,+1) — find which
// polygon has that normal.
DumpCellPolygons(cache, CellarId);
DumpCellPolygons(cache, CottageNeighborA);
DumpCellPolygons(cache, CottageNeighborB);
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
Console.WriteLine($"=== Replay tick {captured.Tick} ===");
var result = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
Console.WriteLine(
$"=== Result pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4}) " +
$"cn=({result.CollisionNormal.X:F4},{result.CollisionNormal.Y:F4},{result.CollisionNormal.Z:F4}) " +
$"cnValid={result.CollisionNormalValid} onGround={result.IsOnGround}");
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
PhysicsDiagnostics.ProbePushBackEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
private static void DumpCellPolygons(PhysicsDataCache cache, uint cellId)
{
var cell = cache.GetCellStruct(cellId);
if (cell is null)
{
Console.WriteLine($"[cell-dump] 0x{cellId:X8} NOT IN CACHE");
return;
}
var t = cell.WorldTransform;
Console.WriteLine($"[cell-dump] 0x{cellId:X8} resolved-poly-count={cell.Resolved.Count}");
Console.WriteLine($" WorldTransform.M14={t.M14:F4} M24={t.M24:F4} M34={t.M34:F4} (origin XYZ?)");
Console.WriteLine($" Translation=({t.Translation.X:F4},{t.Translation.Y:F4},{t.Translation.Z:F4})");
foreach (var kv in cell.Resolved)
{
var p = kv.Value;
// Show world-frame vertices for the first 2 polys with normal-Z>0.9
// (floor candidates) — these are the polygons the head sphere
// could hit from below.
string vertsWorld = "";
if (p.Plane.Normal.Z > 0.9f || p.Plane.Normal.Z < -0.9f)
{
vertsWorld = " worldVerts=[" + string.Join(",", p.Vertices.Select(v =>
{
var w = Vector3.Transform(v, cell.WorldTransform);
return $"({w.X:F2},{w.Y:F2},{w.Z:F2})";
})) + "]";
}
Console.WriteLine(
$" poly id=0x{p.Id:X4} sides={p.SidesType} n=({p.Plane.Normal.X:F4},{p.Plane.Normal.Y:F4},{p.Plane.Normal.Z:F4}) d={p.Plane.D:F4} numV={p.NumPoints}{vertsWorld}");
}
}
///
/// Reads the live-capture.jsonl fixture and returns the FIRST record
/// matching . Throws with a clear error
/// when none match — keeps the test failure attributed to the
/// fixture, not to deserialization.
///
private static ResolveCaptureRecord LoadCapturedRecord(
Func predicate)
{
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
Assert.True(File.Exists(path),
$"Live-capture fixture missing: {path}. Re-run live capture " +
$"with ACDREAM_CAPTURE_RESOLVE set.");
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line))
continue;
var record = System.Text.Json.JsonSerializer
.Deserialize(line, CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture " +
"to include a representative record.");
}
///
/// Replays one captured ResolveWithTransition call through the harness
/// engine, seeded with the captured body-before state, and compares
/// the harness's ResolveResult + body-after vs the captured values.
/// Reports the FIRST per-field divergence with both values so the
/// missing apparatus state is named.
///
private static void AssertCallMatchesCapture(
PhysicsEngine engine,
ResolveCaptureRecord captured)
{
Assert.NotNull(captured.BodyBefore);
Assert.NotNull(captured.BodyAfter);
var body = SeedBodyFromSnapshot(captured.BodyBefore);
var harnessResult = engine.ResolveWithTransition(
currentPos: captured.Input.CurrentPos,
targetPos: captured.Input.TargetPos,
cellId: captured.Input.CellId,
sphereRadius: captured.Input.SphereRadius,
sphereHeight: captured.Input.SphereHeight,
stepUpHeight: captured.Input.StepUpHeight,
stepDownHeight: captured.Input.StepDownHeight,
isOnGround: captured.Input.IsOnGround,
body: body,
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
movingEntityId: captured.Input.MovingEntityId);
// Compare in priority order — most consequential divergence first.
var divergences = new List();
// 1. Result fields
AddIfDifferent(divergences, "Result.Position",
captured.Result.Position, harnessResult.Position);
AddIfDifferent(divergences, "Result.CellId",
$"0x{captured.Result.CellId:X8}",
$"0x{harnessResult.CellId:X8}");
AddIfDifferent(divergences, "Result.IsOnGround",
captured.Result.IsOnGround, harnessResult.IsOnGround);
AddIfDifferent(divergences, "Result.CollisionNormalValid",
captured.Result.CollisionNormalValid,
harnessResult.CollisionNormalValid);
if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid)
{
AddIfDifferent(divergences, "Result.CollisionNormal",
captured.Result.CollisionNormal,
harnessResult.CollisionNormal);
}
// 2. Body-after fields (subset that's most likely to diverge first)
AddIfDifferent(divergences, "BodyAfter.Position",
captured.BodyAfter.Position, body.Position);
AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid",
captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid);
if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid)
{
AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal",
captured.BodyAfter.ContactPlane.Normal,
body.ContactPlane.Normal);
AddIfDifferent(divergences, "BodyAfter.ContactPlane.D",
captured.BodyAfter.ContactPlane.D,
body.ContactPlane.D);
}
AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid",
captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid);
AddIfDifferent(divergences, "BodyAfter.TransientState",
$"0x{captured.BodyAfter.TransientState:X}",
$"0x{(uint)body.TransientState:X}");
if (divergences.Count > 0)
{
string summary = string.Join("\n • ", divergences);
string header = string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Harness replay of captured tick {0} diverges from live engine. " +
"Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " +
"cellId=0x{7:X8} isOnGround={8}",
captured.Tick,
captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z,
captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z,
captured.Input.CellId, captured.Input.IsOnGround);
throw new Xunit.Sdk.XunitException(
header + "\nDivergences (live → harness):\n • " + summary);
}
}
private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new()
{
Position = snap.Position,
Orientation = snap.Orientation,
Velocity = snap.Velocity,
Acceleration = snap.Acceleration,
Omega = snap.Omega,
GroundNormal = snap.GroundNormal,
SlidingNormal = snap.SlidingNormal,
ContactPlaneValid = snap.ContactPlaneValid,
ContactPlane = snap.ContactPlane,
ContactPlaneCellId = snap.ContactPlaneCellId,
ContactPlaneIsWater = snap.ContactPlaneIsWater,
WalkablePolygonValid = snap.WalkablePolygonValid,
WalkablePlane = snap.WalkablePlane,
WalkableVertices = snap.WalkableVertices,
WalkableUp = snap.WalkableUp,
Elasticity = snap.Elasticity,
Friction = snap.Friction,
State = (PhysicsStateFlags)snap.State,
TransientState = (TransientStateFlags)snap.TransientState,
LastUpdateTime = snap.LastUpdateTime,
};
private static void AddIfDifferent(
List divergences, string name, T live, T harness)
{
if (EqualityComparer.Default.Equals(live, harness))
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live={1} harness={2}", name, live, harness));
}
private static void AddIfDifferent(
List divergences, string name, Vector3 live, Vector3 harness)
{
if (Vector3.DistanceSquared(live, harness) < 1e-6f)
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})",
name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z));
}
private static void AddIfDifferent(
List divergences, string name, float live, float harness)
{
if (MathF.Abs(live - harness) < 1e-3f)
return;
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}: live={1:F4} harness={2:F4}", name, live, harness));
}
// ───────────────────────────────────────────────────────────────
// Harness internals
// ───────────────────────────────────────────────────────────────
///
/// One point in the simulated trajectory. Captured per tick.
///
public sealed record TrajectoryPoint(
int Tick,
Vector3 Position,
uint CellId,
bool IsOnGround,
bool CpValid);
///
/// Builds a with:
///
/// - The three issue-#98 cottage/cellar cell fixtures registered.
/// - A stub landblock so TryGetLandblockContext succeeds
/// at the cellar XY (needed for FindObjCollisions to query
/// the shadow registry).
/// - A SYNTHETIC stair-piece GfxObj containing the cellar ramp
/// polygon, registered as a ShadowEntry scoped to the cellar
/// cell. Reconstructed programmatically from the live-capture
/// [poly-dump] data
/// (docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log),
/// transformed to world coordinates so the registered object
/// sits at world origin with identity rotation/scale.
///
///
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithCellarFixtures()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// ── 1. Cell fixtures (existing) + synthetic BSP ──────────────
// CellDumpSerializer.Hydrate intentionally sets BSP=null (the DAT
// PhysicsBSPTree isn't in the dump format). Without a non-null BSP,
// FindEnvCollisions's indoor branch (TransitionTypes.cs:1840) is
// skipped — the engine falls through to outdoor terrain queries
// that produce spurious wall hits. Construct a single-leaf BSP
// wrapping the cell's Resolved polygons, so the indoor path fires
// like production.
foreach (var cellId in new[] { CellarId, CottageNeighborA, CottageNeighborB })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
Assert.True(File.Exists(path),
$"Fixture missing: {path}. Re-run cell-dump capture " +
$"(commit 3f56915 captured the originals).");
var dump = CellDumpSerializer.Read(path);
var cell = CellDumpSerializer.Hydrate(dump);
var cellWithBsp = AttachSyntheticBsp(cell);
cache.RegisterCellStructForTest(cellId, cellWithBsp);
}
// ── 2. NO landblock registered ──────────────────────────────
// Without a landblock, SampleTerrainWalkable returns null and
// FindEnvCollisions's outdoor-fallback path returns OK without
// running ValidateWalkable on stub terrain. This is the right
// shape for indoor-only tests — the cell's BSP would handle
// collision if hydrated, and falling through to stub terrain
// produces spurious (0,1,0) wall hits. FindObjCollisions also
// early-returns without landblock context (line 2153 of
// TransitionTypes.cs), so the synthetic stair GfxObj is also
// skipped — fine for the airborne-at-tick-1 isolation.
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
// Temporarily disabled while debugging the airborne-at-tick-1
// issue. Re-enable once the cell-BSP-is-null + landblock-stub
// interaction is understood, AND we have a way to register
// the stair without needing a landblock (e.g., extend
// FindObjCollisions to query cellScope-only shadows without
// landblock context).
// RegisterStairRampGfxObj(engine, cache);
return (engine, cache);
}
///
/// Wraps a hydrated with a synthetic
/// single-leaf that references every
/// polygon in cell.Resolved. CellDumpSerializer.Hydrate
/// intentionally sets BSP=null (per its xmldoc) because the dump
/// format doesn't capture the DAT BSP tree. Without a non-null BSP,
/// FindEnvCollisions's indoor branch is skipped — the engine then
/// falls through to outdoor terrain queries that misfire. A flat
/// single-leaf BSP is sufficient for the BSP query to find every
/// polygon by exhaustive iteration (slower than a real BSP but
/// correct).
///
private static CellPhysics AttachSyntheticBsp(CellPhysics cell)
{
// Compute a bounding sphere that encompasses every polygon in the
// cell — center at the origin of the cell's WORLD transform plus
// a margin radius. The cellar fixture is ~12 m × 12 m × 3 m.
var bsphereCenter = new Vector3(0f, 0f, 0f); // cell local
var bsphereRadius = 15f;
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = bsphereCenter, Radius = bsphereRadius },
};
foreach (var kv in cell.Resolved)
leaf.Polygons.Add(kv.Key);
var bspTree = new PhysicsBSPTree { Root = leaf };
// CellPhysics has init-only properties; rebuild a new instance
// with BSP set, copying every other field unchanged.
return new CellPhysics
{
BSP = bspTree,
PhysicsPolygons = cell.PhysicsPolygons,
Vertices = cell.Vertices,
WorldTransform = cell.WorldTransform,
InverseWorldTransform = cell.InverseWorldTransform,
Resolved = cell.Resolved,
CellBSP = cell.CellBSP,
Portals = cell.Portals,
PortalPolygons = cell.PortalPolygons,
VisibleCellIds = cell.VisibleCellIds,
};
}
///
/// Constructs a synthetic GfxObj containing the cellar ramp polygon
/// in WORLD coordinates and registers it as a ShadowEntry scoped to
/// the cellar cell. The polygon's vertices + normal are reproduced
/// from the live capture's [poly-dump] data (commit pre-3f56915),
/// transformed to world frame so the GfxObj can sit at world origin
/// with identity rotation/scale (simplifies the
/// FindObjCollisions local-to-world transform).
///
///
/// Live capture's local polygon vertices (in building frame):
/// (0.8,-1.59,-1.5), (0.8,1.31,1.5), (-0.8,1.31,1.5), (-0.8,-1.59,-1.5).
/// Building's world transform: origin (141.5, 7.155, 92.455), 180° yaw
/// around Z. After applying yaw + translation, world vertices are:
/// (140.7, 8.745, 90.955), (140.7, 5.845, 93.955),
/// (142.3, 5.845, 93.955), (142.3, 8.745, 90.955).
/// World normal = (0, 0.719, 0.695), world d = -69.5035 — matches
/// the live cdb capture exactly.
///
///
private static void RegisterStairRampGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
{
const ushort RampPolyId = 0x0008;
const uint StairGfxId = 0xDEADBEEFu;
const uint StairEntityId = 0xC0FFEE00u;
// World-frame vertices (winding order preserved from live capture).
var v0 = new Vector3(140.7f, 8.745f, 90.955f); // ramp foot, X=-side
var v1 = new Vector3(140.7f, 5.845f, 93.955f); // ramp top, X=-side
var v2 = new Vector3(142.3f, 5.845f, 93.955f); // ramp top, X=+side
var v3 = new Vector3(142.3f, 8.745f, 90.955f); // ramp foot, X=+side
var verts = new[] { v0, v1, v2, v3 };
// Compute normal from cross(v1-v0, v3-v0).
var edge0 = v1 - v0;
var edge1 = v3 - v0;
var normal = Vector3.Normalize(Vector3.Cross(edge0, edge1));
// Plane equation: N·p + d = 0 → d = -N·v0.
float d = -Vector3.Dot(normal, v0);
var resolved = new Dictionary
{
[RampPolyId] = new ResolvedPolygon
{
Vertices = verts,
Plane = new System.Numerics.Plane(normal, d),
NumPoints = 4,
SidesType = CullMode.Landblock,
},
};
// Minimal one-leaf BSP containing the ramp poly. Bounding sphere
// encompasses the polygon (center at poly centroid).
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = new Vector3(141.5f, 7.295f, 92.455f),
Radius = 3.0f,
},
};
leaf.Polygons.Add(RampPolyId);
var bspTree = new PhysicsBSPTree { Root = leaf };
var gfxPhysics = new GfxObjPhysics
{
BSP = bspTree,
PhysicsPolygons = new Dictionary(),
Vertices = new VertexArray(),
Resolved = resolved,
BoundingSphere = leaf.BoundingSphere,
};
cache.RegisterGfxObjForTest(StairGfxId, gfxPhysics);
// ShadowEntry: object at world origin (0,0,0), identity rotation,
// scale 1.0 — keeps the polygon's WORLD-frame vertices intact
// through the FindObjCollisions local-transform math.
// cellScope = CellarId so the entry is only queried when the sphere
// is in cellar cell (matches retail's per-cell shadow scoping for
// interior statics — Issue #91 family).
engine.ShadowObjects.Register(
entityId: StairEntityId,
gfxObjId: StairGfxId,
worldPos: Vector3.Zero,
rotation: Quaternion.Identity,
radius: 5.0f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B40000u,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: CellarId);
}
///
/// Sphere on the cellar floor with BOTH a seeded ContactPlane AND a
/// seeded WalkablePolygon. Both are required by the engine to treat
/// the body as truly grounded:
///
/// - ContactPlaneValid + ContactPlane: copied into
/// CollisionInfo.ContactPlane via the body parameter
/// seeding in .
/// - WalkablePolygonValid + WalkablePlane +
/// WalkableVertices: read by
/// lines
/// 665-673 to call SpherePath.SetWalkable(...), which
/// sets HasWalkablePolygon=true. Without this, the
/// engine treats the sphere as "grounded but with no walkable
/// polygon anchor" — a contradictory state that fires step-down
/// probes which reject and clear the grounded flag.
///
///
private static PhysicsBody BuildInitialBody() => new()
{
Position = InitialSphereWorld,
Orientation = Quaternion.Identity,
// ContactPlane: cellar floor at world Z=90.95.
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
ContactPlaneCellId = CellarId,
// WalkablePolygon: cellar floor poly 24 (the cellar quad under
// sphere XY=(141.5, 9.5)), transformed to world coordinates via
// the cell's 180° yaw + origin (130.5, 11.5, 94.0). Local verts
// [(-11.6, 0, -3.05), (-11.6, 3.1, -3.05), (-9.6, 3.1, -3.05),
// (-9.6, 0, -3.05)] → world [(142.1, 11.5, 90.95),
// (142.1, 8.4, 90.95), (140.1, 8.4, 90.95), (140.1, 11.5, 90.95)].
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
WalkableVertices = new[]
{
new Vector3(142.1f, 11.5f, 90.95f),
new Vector3(142.1f, 8.4f, 90.95f),
new Vector3(140.1f, 8.4f, 90.95f),
new Vector3(140.1f, 11.5f, 90.95f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable,
};
///
/// Drives physics ticks. Each tick
/// applies as the requested forward
/// motion, calls ,
/// writes the result back to , and records
/// a .
///
///
/// Cross-tick ContactPlane persistence is via
/// — the engine writes its final CP back to the body, then reads
/// it as the seed for the next tick. This mirrors the production
/// pattern in PlayerMovementController.
///
///
private static List SimulateTicks(
PhysicsEngine engine,
PhysicsBody body,
uint initialCellId,
int tickCount)
{
uint cellId = initialCellId;
bool isOnGround = true;
var trajectory = new List(tickCount + 1)
{
new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid),
};
for (int tick = 1; tick <= tickCount; tick++)
{
Vector3 target = body.Position + PerTickOffset;
var result = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: target,
cellId: cellId,
sphereRadius: SphereRadius,
sphereHeight: SphereHeight,
stepUpHeight: StepUpHeight,
stepDownHeight: StepDownHeight,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer
| ObjectInfoState.EdgeSlide,
movingEntityId: 0);
body.Position = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
trajectory.Add(new(
tick,
body.Position,
cellId,
isOnGround,
body.ContactPlaneValid));
}
return trajectory;
}
private static string FixtureDir =>
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
"Fixtures", "issue98");
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;
while (!string.IsNullOrEmpty(dir))
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Path.GetDirectoryName(dir);
}
throw new InvalidOperationException(
"Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
}
}