acdream/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
Erik 97fec19dbb test(phys): A6.P3 #98 — comparison harness reproduces cottage-floor cap
Apparatus convergence. With the cottage GfxObj 0x01000A2B registered as
a ShadowEntry in BuildEngineWithCellarFixtures, the harness now reproduces
the live cap-event collision normal (cn=(0,0,-1)) exactly, ending the
"harness doesn't reproduce" divergence the prior session's findings doc
identified.

Concretely:
  * Adds a minimum-stub landblock (TerrainSurface at z=-1000) so
    TryGetLandblockContext succeeds at the cellar XY — production's
    FindObjCollisions early-returns without a landblock and would skip
    the cottage shadow query.
  * Adds RegisterCottageGfxObj that loads the 74-polygon cottage fixture
    via GfxObjDumpSerializer.Hydrate, then registers it at the cottage's
    world transform (translation (130.5, 11.5, 94.0) + 180° around Z,
    derived from the cellar cell's WorldTransform), matching
    GameWindow.cs:5893's landblock-baked-static registration shape.
  * LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
    flips: the cap-normal reproduction is now enforced by
    LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal.
  * The full per-field round-trip uncovered ONE residual divergence:
    live preserves +0.0266m of +X motion through the cap event (edge-
    slide along the floor in XY); harness blocks ALL motion at the cap.
    Captured by LiveCompare_FirstCap_ResidualXMotionDivergence_Docs...
    in documents-the-bug form so the next session has a concrete next
    target.

Fixture: tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
(74 polygons, 6 downward-facing cottage-floor triangles at object-local
Z=0, BSP radius 13.989m matching the live [resolve-bldg] bspR=13.99).
Captured via launch-a6-issue98-cottage-gfxobj-dump.ps1.

In-isolation: all 12 CellarUpTrajectoryReplayTests + 4 GfxObjDumpRoundTripTests
+ 1 new PhysicsDiagnosticsTests pass.

Note on full-suite baseline: the full xUnit serial run shows 8–19
failures depending on order (pre-existing test interaction with shared
statics across PlayerMovementControllerTests, MotionInterpreterTests,
PositionManagerTests, etc.). The flakiness is independent of this
change — confirmed by stashing the harness changes and observing the
same flaky range. Investigating the static-state isolation problem is
out of scope for issue #98; tracked as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:44:50 +02:00

1226 lines
55 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// A6.P3 issue #98 (2026-05-23) — deterministic TRAJECTORY replay
/// harness for the cottage cellar-ascent failure. Drives
/// <see cref="PhysicsEngine.ResolveWithTransition"/> through N physics
/// ticks against pre-loaded cell fixtures, capturing a per-tick
/// trajectory record.
///
/// <para>
/// Unlike <see cref="Issue98CellarUpReplayTests"/> (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).
/// </para>
///
/// <h3>Status as of 2026-05-23 evening: harness mechanics WORK, fixtures
/// INCOMPLETE.</h3>
///
/// <para>
/// The harness compiles and runs the engine through N ticks in
/// &lt; 100 ms total. Two findings during commissioning:
/// </para>
///
/// <list type="number">
/// <item>The three issue-#98 cell fixtures
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40*.json</c>)
/// contain ONLY axis-aligned polygons — cellar floor, cellar
/// ceiling, four cellar walls, cottage floor, cottage walls. The
/// live capture's CELLAR RAMP polygon
/// (normal ≈ <c>(0, ±0.719, 0.695)</c>) 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.</item>
/// <item>Independently: at the sphere's initial position resting on
/// the cellar floor, the engine reports
/// <c>hit=yes n=(0,0,1) walkable=False</c> 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.</item>
/// </list>
///
/// <para>
/// <b>Before this harness can drive issue-#98 trajectory fix attempts,
/// the fixtures need a re-capture</b> that includes:
/// </para>
///
/// <list type="bullet">
/// <item>The cellar ramp polygon (whichever cell it actually lives
/// in — the live capture said cellar cell <c>0xA9B40147</c>,
/// but our dump doesn't have it; investigate
/// <see cref="CellDumpSerializer"/> to see whether some
/// polygons are being skipped during capture).</item>
/// <item>Any neighboring cells the sphere may transit into during
/// the climb (the live capture's
/// <c>[cell-set-summary]</c> showed overlap with
/// <c>0xA9B40143</c> and <c>0xA9B40146</c>, both already in
/// the fixture set — but additional cells beyond these may
/// appear at tick boundaries we haven't observed).</item>
/// </list>
///
/// <para>
/// The current tests document the harness mechanics + the two
/// findings above. When fixtures are re-captured, flip
/// <see cref="CellarUp_FreezesAtRampTop_DocumentsBug"/>'s assertion
/// to require a successful climb and add additional tests for the
/// trajectory shape.
/// </para>
/// </summary>
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;
/// <summary>
/// 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: <c>0.719·y + 0.695·z = 69.5035</c> → y=8.75 at z=90.95).
/// X=141.5 matches the live capture's X.
/// </summary>
private static readonly Vector3 InitialSphereWorld =
new(141.5f, 9.5f, CellarFloorZ + SphereRadius);
/// <summary>
/// Per-tick forward offset (Y direction toward the ramp).
/// Magnitude (~0.10 m) matches the live capture's observed per-tick
/// requested offset.
/// </summary>
private static readonly Vector3 PerTickOffset =
new(0f, -0.10f, 0f);
private const int SimulationTicks = 200;
// ───────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Diagnostic dump: print the first 10 trajectory points + the
/// engine's resolve-probe decisions. Useful when investigating
/// what the harness is actually doing.
/// </summary>
[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;
}
}
/// <summary>
/// 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.
/// </summary>
[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<TrajectoryPoint>
{
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;
}
}
/// <summary>
/// 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.
///
/// <para>
/// Investigation excluded:
/// <list type="bullet">
/// <item>Stub landblock terrain (removed; same hit)</item>
/// <item>Synthetic stair GfxObj (removed; same hit)</item>
/// <item>Cell BSP=null on Hydrate (attached synthetic BSP; same hit)</item>
/// <item>WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains)</item>
/// <item>Initial sphere Z lift 0.0 vs 0.05 m (same hit)</item>
/// <item>PhysicsBody seeded vs body=null (same hit)</item>
/// </list>
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
/// <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,
};
// ───────────────────────────────────────────────────────────────
// 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.
// ───────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[Fact]
public void LiveCompare_Tick0_Spawn()
{
var (engine, cache) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 0);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void LiveCompare_Tick376_OnRamp()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record => record.Tick == 376);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// 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. The head bumps
/// the cottage floor from BELOW — NOT a step-up / AdjustOffset bug.
///
/// <para>
/// Live capture's <c>[resolve]</c> probe pinpointed the blocking
/// entity: <c>obj=0xA9B47900</c> — a landblock-baked static building
/// (the cottage GfxObj <c>0x01000A2B</c>). The cottage's floor polys
/// live in this GfxObj as a ShadowEntry, NOT in any cottage cell.
/// </para>
///
/// <para>
/// Apparatus-convergence form (2026-05-23 evening v2): with the
/// cottage GfxObj registered via <see cref="RegisterCottageGfxObj"/>,
/// the harness reproduces the live cn=(0,0,-1) cap event. This test
/// enforces THAT specific reproduction. The post-cap position
/// processing has a separate residual divergence — documented by
/// <see cref="LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation"/>.
/// </para>
/// </summary>
[Fact]
public void LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var captured = LoadCapturedRecord(record =>
record.Result.CollisionNormalValid
&& record.Result.CollisionNormal.Z < -0.99f);
// Live must have cn=(0,0,-1) at this point — sanity check.
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}.");
// Replay the call.
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);
// Apparatus convergence: harness reproduces the cap-event collision
// normal exactly. If this fails, the cottage GfxObj registration
// has regressed (RegisterCottageGfxObj is broken, the dump fixture
// is stale, or the harness wiring lost the landblock context).
Assert.True(harnessResult.CollisionNormalValid,
"Harness must reproduce the live collision-normal-valid signal " +
"now that the cottage GfxObj is registered.");
Assert.True(harnessResult.CollisionNormal.Z < -0.99f,
$"Harness must reproduce the live downward-facing cap normal " +
$"(live cn={captured.Result.CollisionNormal}, harness cn={harnessResult.CollisionNormal}).");
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2) — documents-the-bug for the
/// residual divergence the apparatus surfaced. With the cottage GfxObj
/// registered, the harness reproduces the live cap-event collision
/// normal, but the POST-CAP POSITION processing diverges:
/// <list type="bullet">
/// <item>Live: full +X motion preserved (sphere slides +0.0266 m in X
/// along the cottage floor before the Y motion is capped).</item>
/// <item>Harness: ZERO X motion (sphere stays at its input X position,
/// only the Y component is blocked).</item>
/// </list>
///
/// <para>
/// Both X positions agree on Y=7.2243 and Z=92.7390. Only X differs:
/// live X=141.3865 (requested target), harness X=141.3599 (current = no
/// move). The requested delta was (+0.0266, -0.4022, 0); live applied
/// the +X portion, harness applied nothing.
/// </para>
///
/// <para>
/// Hypothesis (to investigate next session): live's response to a
/// cn=(0,0,-1) head-bump treats it as a Z-only constraint and lets the
/// XY component of the move complete via edge-slide. Harness's BSP path
/// is rejecting the WHOLE move vector when the cottage floor poly
/// intersects the head sphere, instead of computing a slid offset.
/// </para>
///
/// <para>
/// Documents-the-bug: PASSES today on the asserted residual magnitude.
/// When a future session fixes the post-cap edge-slide, harness X will
/// match live X — this test FAILS at that point, signaling that the
/// X divergence is closed and the test should be folded back into the
/// strict <see cref="AssertCallMatchesCapture"/> path.
/// </para>
/// </summary>
[Fact]
public void LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation()
{
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 preserved the full +X motion through the cap event; harness
// blocked it. Y and Z agree.
Assert.Equal(captured.Result.Position.Y, harnessResult.Position.Y, 4);
Assert.Equal(captured.Result.Position.Z, harnessResult.Position.Z, 4);
float liveDeltaX = captured.Result.Position.X - captured.Input.CurrentPos.X;
float harnessDeltaX = harnessResult.Position.X - captured.Input.CurrentPos.X;
Assert.True(liveDeltaX > 0.02f,
$"Live must show +X motion after cap (expected ~+0.0266 m, got {liveDeltaX:F4}).");
Assert.True(MathF.Abs(harnessDeltaX) < 0.001f,
$"Harness currently zeros X motion through the cap (expected ~0, got {harnessDeltaX:F4}). " +
"If this assertion starts failing because harness now preserves +X, the post-cap " +
"edge-slide divergence is closed — fold this back into AssertCallMatchesCapture.");
}
/// <summary>
/// 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.
/// </summary>
[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}");
}
}
/// <summary>
/// Reads the live-capture.jsonl fixture and returns the FIRST record
/// matching <paramref name="predicate"/>. Throws with a clear error
/// when none match — keeps the test failure attributed to the
/// fixture, not to deserialization.
/// </summary>
private static ResolveCaptureRecord LoadCapturedRecord(
Func<ResolveCaptureRecord, bool> 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<ResolveCaptureRecord>(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.");
}
/// <summary>
/// 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.
/// </summary>
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<string>();
// 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<T>(
List<string> divergences, string name, T live, T harness)
{
if (EqualityComparer<T>.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<string> 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<string> 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
// ───────────────────────────────────────────────────────────────
/// <summary>
/// One point in the simulated trajectory. Captured per tick.
/// </summary>
public sealed record TrajectoryPoint(
int Tick,
Vector3 Position,
uint CellId,
bool IsOnGround,
bool CpValid);
/// <summary>
/// Builds a <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>The three issue-#98 cottage/cellar cell fixtures registered.</item>
/// <item>A stub landblock so <c>TryGetLandblockContext</c> succeeds
/// at the cellar XY (needed for FindObjCollisions to query
/// the shadow registry).</item>
/// <item>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
/// <c>[poly-dump]</c> data
/// (<c>docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log</c>),
/// transformed to world coordinates so the registered object
/// sits at world origin with identity rotation/scale.</item>
/// </list>
/// </summary>
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. Minimum landblock context for FindObjCollisions ──────
// FindObjCollisions (TransitionTypes.cs:2153) early-returns
// TransitionState.OK when TryGetLandblockContext fails for the
// sphere XY. Without a landblock the harness can't query the
// cottage GfxObj's shadow entries — and that's where the
// first-cap collision actually lives (live capture confirmed
// obj=0xA9B47900 fires the cn=(0,0,-1) push).
//
// Register an EMPTY-terrain landblock 0xA9B40000 anchored at
// world origin (0,0). The landblock test
// (worldX >= 0 && worldX < 192) covers every harness sphere
// position (X≈141, Y≈7). TerrainSurface gets a flat far-below
// surface so SampleTerrainZ returns something the indoor BSP
// path never consults (FindEnvCollisions's indoor branch fires
// first when the cell has BSP). Outdoor-fallback queries are
// harmless because the cell's synthetic BSP returns Collided
// before terrain is checked.
var heights = new byte[81]; // 9x9 corners
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below cellar
var stubTerrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: stubTerrain,
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// ── 3. Cottage GfxObj 0x01000A2B from dumped fixture ────────
// Live capture (2026-05-23 PM v2) attributes the first-cap event
// to obj=0xA9B47900 (entity 0x00A9B479 partIdx=0) — a landblock-
// baked static building registered as a ShadowEntry. The full
// polygon table was extracted via ACDREAM_DUMP_GFXOBJS=0x01000A2B
// (issue #98 evening-v2 apparatus); 74 polygons including six
// downward-facing cottage-floor triangles at object-local Z=0
// that the head sphere bumps from below at world Z=94.
RegisterCottageGfxObj(engine, cache);
return (engine, cache);
}
/// <summary>
/// Wraps a hydrated <see cref="CellPhysics"/> with a synthetic
/// single-leaf <see cref="PhysicsBSPTree"/> that references every
/// polygon in <c>cell.Resolved</c>. <c>CellDumpSerializer.Hydrate</c>
/// 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).
/// </summary>
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,
};
}
/// <summary>
/// A6.P3 issue #98 (2026-05-23 evening v2). Loads the cottage GfxObj
/// <c>0x01000A2B</c> from the JSON fixture
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json</c>,
/// produced via the <c>ACDREAM_DUMP_GFXOBJS</c> capture infrastructure),
/// hydrates it as a <see cref="GfxObjPhysics"/> with a synthetic
/// single-leaf BSP, and registers it as a ShadowEntry at the cottage's
/// world transform — the same shape production's GameWindow.cs:5893
/// registration uses for landblock-baked statics.
///
/// <para>
/// Transform values come from two evidence sources:
/// <list type="bullet">
/// <item>The cellar cell 0xA9B40147's WorldTransform has translation
/// (130.5, 11.5, 94.0) and a 3×3 with M11=M22=-1 / M33=+1
/// (a 180° rotation around Z). The cottage GfxObj sits at the
/// SAME world transform (its building origin is also at
/// (130.5, 11.5, 94.0) per the existing [resolve-bldg] capture
/// <c>entOrigin_lb=(130.5,11.5,94.0)</c>).</item>
/// <item>BoundingSphere radius from the dump's
/// <see cref="GfxObjDump.BoundingSphereRadius"/> — 13.989 m.
/// Matches the live <c>bspR=13.99</c> observed in the
/// [resolve-bldg] capture; cross-validation that the same
/// building is in play.</item>
/// </list>
/// </para>
///
/// <para>
/// Entity id <c>0x00A9B479</c> mirrors the live capture's
/// <c>obj=0xA9B47900</c> formula (entity.Id × 256 + partIdx=0). Using
/// the same id keeps any future probe correlation aligned with live
/// log conventions.
/// </para>
/// </summary>
private static void RegisterCottageGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
{
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u;
var fixturePath = Path.Combine(FixtureDir, "0x01000A2B.gfxobj.json");
Assert.True(File.Exists(fixturePath),
$"Cottage GfxObj fixture missing: {fixturePath}. Re-run live " +
$"capture with ACDREAM_DUMP_GFXOBJS=0x01000A2B.");
var dump = GfxObjDumpSerializer.Read(fixturePath);
var physics = GfxObjDumpSerializer.Hydrate(dump);
cache.RegisterGfxObjForTest(CottageGfxId, physics);
// World transform from the cellar cell's WorldTransform: translation
// (130.5, 11.5, 94.0) + 180° rotation around Z. The cottage GfxObj
// shares this transform (it IS the cellar/cottage geometry).
var worldPos = new Vector3(130.5f, 11.5f, 94.0f);
var worldRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
engine.ShadowObjects.Register(
entityId: CottageEntityId,
gfxObjId: CottageGfxId,
worldPos: worldPos,
rotation: worldRot,
radius: physics.BoundingSphere?.Radius ?? 14f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B40000u,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
// Landblock-baked statics in production (GameWindow.cs:5899) use
// `entity.ParentCellId ?? 0u` — the cottage building has no
// ParentCellId (it's a top-level landblock static), so the
// scope is landblock-wide (cellScope=0).
cellScope: 0u);
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item><c>ContactPlaneValid</c> + <c>ContactPlane</c>: copied into
/// <c>CollisionInfo.ContactPlane</c> via the body parameter
/// seeding in <see cref="PhysicsEngine.ResolveWithTransition"/>.</item>
/// <item><c>WalkablePolygonValid</c> + <c>WalkablePlane</c> +
/// <c>WalkableVertices</c>: read by
/// <see cref="PhysicsEngine.ResolveWithTransition"/> lines
/// 665-673 to call <c>SpherePath.SetWalkable(...)</c>, which
/// sets <c>HasWalkablePolygon=true</c>. 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.</item>
/// </list>
/// </summary>
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,
};
/// <summary>
/// Drives <paramref name="tickCount"/> physics ticks. Each tick
/// applies <see cref="PerTickOffset"/> as the requested forward
/// motion, calls <see cref="PhysicsEngine.ResolveWithTransition"/>,
/// writes the result back to <paramref name="body"/>, and records
/// a <see cref="TrajectoryPoint"/>.
///
/// <para>
/// Cross-tick ContactPlane persistence is via <paramref name="body"/>
/// — 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 <c>PlayerMovementController</c>.
/// </para>
/// </summary>
private static List<TrajectoryPoint> SimulateTicks(
PhysicsEngine engine,
PhysicsBody body,
uint initialCellId,
int tickCount)
{
uint cellId = initialCellId;
bool isOnGround = true;
var trajectory = new List<TrajectoryPoint>(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);
}
}