acdream/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
Erik fb5fba6229 test(phys): A6.P3 #98 — live ResolveWithTransition capture apparatus
Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.

Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
  with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
  record, JSON Lines writer (thread-safe, flushes per record), process-
  exit hook for clean shutdown.

- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
  method entry, snapshot again before return, refactor the two returns
  into one path so the capture call site is single. Filtered to
  IsPlayer mover flag so NPC/remote DR calls don't pollute.

- CellarUpTrajectoryReplayTests.cs:
  • Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
    ticks with capture on, reads file back, verifies round-trip of
    inputs + body-before/after snapshots.
  • Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
    IsPlayer flag), confirms the file is not created.

Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.

Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.

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

792 lines
34 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,
};
// ───────────────────────────────────────────────────────────────
// 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. NO landblock registered ──────────────────────────────
// Without a landblock, SampleTerrainWalkable returns null and
// FindEnvCollisions's outdoor-fallback path returns OK without
// running ValidateWalkable on stub terrain. This is the right
// shape for indoor-only tests — the cell's BSP would handle
// collision if hydrated, and falling through to stub terrain
// produces spurious (0,1,0) wall hits. FindObjCollisions also
// early-returns without landblock context (line 2153 of
// TransitionTypes.cs), so the synthetic stair GfxObj is also
// skipped — fine for the airborne-at-tick-1 isolation.
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
// Temporarily disabled while debugging the airborne-at-tick-1
// issue. Re-enable once the cell-BSP-is-null + landblock-stub
// interaction is understood, AND we have a way to register
// the stair without needing a landblock (e.g., extend
// FindObjCollisions to query cellScope-only shadows without
// landblock context).
// RegisterStairRampGfxObj(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>
/// 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 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);
}
}