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>
This commit is contained in:
parent
5f3b64c548
commit
4c9290c691
1 changed files with 346 additions and 0 deletions
|
|
@ -0,0 +1,346 @@
|
|||
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
|
||||
/// < 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue