acdream/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
Erik 4c9290c691 test(phys): A6.P3 #98 — trajectory replay harness (mechanics OK; fixtures incomplete)
Adds tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs:
a deterministic harness that drives PhysicsEngine.ResolveWithTransition
through N ticks against pre-loaded cell fixtures, capturing per-tick
trajectory points. Pure indoor (no landblock registration needed),
runs 200 ticks in under 100 ms.

The harness MECHANICS work — engine constructs cleanly, DataCache
accepts test fixtures via RegisterCellStructForTest, PhysicsBody
carries ContactPlane state across ticks. 4/4 tests pass, baseline
maintained (1167 + 4 = 1171 + 8 pre-existing failures).

Two real findings surfaced during commissioning, both documented as
passing tests so they don't regress silently:

Finding 1 (Harness_FixtureLimitation_NoRampPolygon): the three
issue-#98 cell fixtures contain ONLY axis-aligned polygons. The
cellar fixture (0xA9B40147) has 37 polys: 8 floor (N=(0,0,1)), 7
ceiling (N=(0,0,-1)), 22 walls. The live capture's CELLAR RAMP
polygon (N ≈ (0, ±0.719, 0.695)) is NOT in any fixture. With no
ramp polygon, the harness can't reproduce the cellar-up climb —
the sphere would walk horizontally across the cellar floor without
ever encountering a slope. Re-capture needed; investigate whether
CellDumpSerializer is skipping polygons or whether the ramp lives
in a cell we didn't dump.

Finding 2 (Harness_Finding_SphereGoesAirborneAtTick1): at the
seeded grounded initial position (sphere center 0.48 m above cellar
floor, ContactPlane = (0,0,1,-90.95), OnWalkable bit set), the
engine reports `hit=yes n=(0,0,1) walkable=False` on tick 1 and
the body's IsOnGround flips to false. Subsequent ticks proceed as
airborne (Y advances, Z stays put — no gravity in the input offset).
Unclear whether this is an engine bug (floor contact classified
as non-walkable collision) or a fixture issue (cellar floor
polygon's containment test mis-firing at the seeded XY). Either
way, the harness now exposes it deterministically.

Net value of this commit: the harness CODE is ready. Once the
fixture issue is solved, fix attempts on #98 (or any trajectory-
dependent bug) iterate in <100 ms instead of 5-minute live-launch
cycles. The "why is this so hard" point #4 from the session-pause
handoff is addressed for everything except the missing-ramp gap.

Test baseline: 1171 (1167 + 4 new) + 8 pre-existing failures.

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

346 lines
14 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 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 above cellar floor by exactly the radius
/// (bottom resting on floor). Y=9.5 is ~0.75 m before the ramp foot
/// at Y=8.75 (live-capture ramp plane equation:
/// <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>
/// Documents finding #1: cellar fixture is missing the ramp
/// polygon. With only axis-aligned cellar/cottage geometry, the
/// sphere walks horizontally and the trajectory's max-Z equals
/// the starting Z. When fixtures are re-captured with the ramp,
/// flip this assertion (and rename the test).
/// </summary>
[Fact]
public void Harness_FixtureLimitation_NoRampPolygon()
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks);
var maxZ = trajectory.Max(t => t.Position.Z);
var startZ = InitialSphereWorld.Z;
// CURRENT behavior: maxZ == startZ because there's no ramp
// polygon in the fixtures. When the fixtures are re-captured
// and include the ramp, this assertion must be flipped to
// require maxZ >= 93.5f (sphere reaches cottage floor).
Assert.True(
MathF.Abs(maxZ - startZ) < 0.01f,
$"Harness limitation documented: cellar fixture has no ramp " +
$"polygon, so the sphere should not gain altitude. If this " +
$"fails, the fixture was re-captured — flip this test to " +
$"require a successful climb. " +
$"maxZ={maxZ:F4}, startZ={startZ:F4}, Δ={maxZ - startZ:F4}.");
}
/// <summary>
/// Documents finding #2: at the initial grounded position, the
/// engine reports the cellar floor as a non-walkable collision
/// and the body goes airborne at tick 1. Whether this is an
/// engine bug or a fixture issue is unclear; the harness exposes
/// it deterministically.
/// </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,
"Finding #2: at tick 1 the engine reports the sphere is NOT " +
"grounded, even though it started seeded on the cellar floor " +
"with a flat-floor ContactPlane. Investigate whether the " +
"cellar floor polygon's containment test is mis-firing or " +
"whether the engine genuinely treats floor contact as a " +
"non-walkable collision. If/when this is fixed, the assertion " +
"should be flipped to require continuous grounded state.");
}
/// <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.");
}
// ───────────────────────────────────────────────────────────────
// 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 the three issue-#98
/// cottage/cellar cell fixtures registered. No landblock is
/// registered — the indoor BSP path takes over because the cell
/// IDs have low byte ≥ 0x100.
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithCellarFixtures()
{
var cache = new PhysicsDataCache();
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);
cache.RegisterCellStructForTest(cellId, cell);
}
return (new PhysicsEngine { DataCache = cache }, cache);
}
/// <summary>
/// Sphere on the cellar floor with a seeded flat-floor ContactPlane.
/// Mirrors the production pattern in <c>PlayerMovementController</c>:
/// a grounded body carries its last ContactPlane forward across ticks.
/// </summary>
private static PhysicsBody BuildInitialBody() => new()
{
Position = InitialSphereWorld,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
ContactPlaneCellId = CellarId,
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);
}
}