Adds two diagnostic-only tests: - Harness_DiagnosticDump_FirstTenTicks: prints trajectory + resolve probe lines for the seeded-body path - Harness_DiagnosticDump_NoBodySeed: same but with body=null, isolating whether the CP seed contributes to the airborne-at-tick-1 issue Also adjusts InitialSphereWorld to lift the sphere by 0.05m above cellar floor (sphere bottom at Z=91.00, not Z=90.95). The lift should give the engine a clean step-down on tick 1 instead of an exact-boundary contact. Experimental finding: NEITHER the no-body-seed path NOR the 0.05m lift changes the airborne-at-tick-1 behavior. With sphere center at world Z=91.48 (0.05m + radius above cellar floor at 90.95): - Tick 1: in=(141.5, 9.5, 91.48), out=(141.5, 9.5, 91.48) — Y move rejected. hit=yes n=(0,0,1) walkable=False. - Tick 2+: Y advances by 0.1/tick, Z stays put, onGround stays False. The hit normal (0,0,1) at tick 1 means the engine treats the cellar floor polygon as a NON-WALKABLE collision target when the sphere is seeded grounded above it. The walkability classifier returns False even though Normal.Z=1.0 > FloorZ=0.6642. This is a real engine bug worth investigating in a future session — independent of the cellar-up freeze. The synthetic ramp polygon registered via RegisterStairRampGfxObj is NOT reached because the sphere is now airborne and floats over the cellar floor without contacting the ramp. Next session pickup options: 1. Debug the airborne-at-tick-1 issue (likely in TransitionTypes FindEnvCollisions indoor BSP path — why does a flat (0,0,1) hit return walkable=False?). Once fixed, the harness should reproduce cellar-up freeze. 2. Pivot to a different M1.5 issue with cleaner reproduction. 3. Use the harness mechanics elsewhere — the synthetic-GfxObj + ShadowEntry pattern is reusable for any indoor-static-collision test (corpse pickup boundaries, door swings, etc.). Test baseline: 1167 + 5 (harness) = 1172 + 8 pre-existing failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
542 lines
22 KiB
C#
542 lines
22 KiB
C#
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
|
||
/// < 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 slightly ABOVE its resting position on the
|
||
/// cellar floor (offset by an additional 0.05 m above sphere-bottom-
|
||
/// on-floor) to avoid the BSP query's floating-point boundary at
|
||
/// exact contact. With sphere center at exactly Z=floor+radius, the
|
||
/// engine reports hit=yes (back-face contact) and the body goes
|
||
/// airborne; with a 0.05 m lift, step-down on tick 1 should snap
|
||
/// the sphere cleanly to the floor.
|
||
///
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </summary>
|
||
private const float InitialZLift = 0.05f;
|
||
private static readonly Vector3 InitialSphereWorld =
|
||
new(141.5f, 9.5f, CellarFloorZ + SphereRadius + InitialZLift);
|
||
|
||
/// <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;
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <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 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:
|
||
/// <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) ─────────────────────────────
|
||
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<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
|
||
RegisterStairRampGfxObj(engine, cache);
|
||
|
||
return (engine, cache);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <c>[poly-dump]</c> 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).
|
||
///
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </summary>
|
||
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<ushort, ResolvedPolygon>
|
||
{
|
||
[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<ushort, Polygon>(),
|
||
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);
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|