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 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:
/// 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;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 10);
var msg = "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}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}"));
// Always pass — this is a diagnostic test; the resolve
// 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;
}
}
///
/// 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.
///
[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.");
}
///
/// 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.");
}
// ───────────────────────────────────────────────────────────────
// 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) ─────────────────────────────
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);
}
// ── 2. Stub landblock so TryGetLandblockContext succeeds ───
// FindObjCollisions early-returns if no landblock covers the
// sphere's XY. The cellar is in the world's first landblock
// (worldOffset 0,0 covers 0..192m). We don't need real terrain
// for indoor BSP collision — minimal heights array suffices.
var heights = new byte[81];
Array.Fill(heights, (byte)0);
var heightTab = new float[256];
for (int i = 0; i < 256; i++) heightTab[i] = i * 1.0f;
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: new TerrainSurface(heights, heightTab),
cells: Array.Empty(),
portals: Array.Empty(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
RegisterStairRampGfxObj(engine, cache);
return (engine, cache);
}
///
/// 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 a seeded flat-floor ContactPlane.
/// Mirrors the production pattern in PlayerMovementController:
/// a grounded body carries its last ContactPlane forward across ticks.
///
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,
};
///
/// 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);
}
}