acdream/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
Erik 5c6bdbe30d test(phys): A6.P3 #98 — harness deep investigation; airborne-at-tick-1 root cause not yet isolated
Multi-step investigation of the airborne-at-tick-1 bug per the
systematic-debugging skill. Several hypotheses tested via the
harness, each producing the same (0,1,0) hit normal at tick 1:

1. WalkablePolygon seeding ADDED to BuildInitialBody (was missing).
   PhysicsEngine.cs:665-673 requires body.WalkablePolygonValid +
   WalkableVertices to call SpherePath.SetWalkable. With seeded
   walkable poly: walkPoly=True survives tick 1 (was False before).
   BUT engine still reports hit=(0,1,0) and body goes airborne.
2. Initial Z lift removed (back to 0): same airborne behavior.
3. Synthetic stair GfxObj DISABLED: same (0,1,0) hit. Hit is not
   from FindObjCollisions.
4. Stub landblock REMOVED: same (0,1,0). FindObjCollisions early-
   returns without landblock context, FindEnvCollisions's outdoor
   terrain returns null. Hit is not from terrain.
5. SYNTHETIC BSP attached to cell fixtures (Hydrate sets BSP=null
   per its xmldoc; without BSP the indoor branch is skipped, falls
   through to outdoor terrain). One-leaf BSP referencing every poly
   in cell.Resolved. Indoor BSP path now runs. Same (0,1,0) hit.

Trace timeline at tick 1:
  find-start: walkPoly=True, CP valid, oi=0x303 (Contact+OnWalkable)
  after-adjust: req=(0,-0.1,0) adj=(0,-0.1,0) — no projection change
  before-insert: check=(141.5, 9.4, 91.43)
  stepdown-enter (Contact-recovery): stepDown=True, height=0.04
  stepdown-after-offset: check=(141.5, 9.4, 91.39) — moved DOWN 0.04
  stepdown-after-insert: state=OK, cp=n/a (no walkable found)
  stepdown-reject
  (second stepdown attempt — same outcome)
  after-insert: state=Collided, hit=n/a, walkPoly=False
  after-validate: state=OK, hit=(0,1,0), slide=(0,1,0)
                   oi=0x300 (Contact+OnWalkable CLEARED)

The (0,1,0) hit is set by ValidateTransition between after-insert
and after-validate. ValidateTransition's default-push-up code path
sets UnitZ=(0,0,1), NOT UnitY=(0,1,0). So something INSIDE
TransitionalInsert sets ci.CollisionNormal=(0,1,0) before
ValidateTransition runs (12 SetCollisionNormal call sites in
TransitionTypes.cs — root cause not isolated to one).

Per systematic-debugging skill: 5+ hypotheses tested without
convergence = "question architecture". The bug is hidden deeper
than a single misconfigured init field.

Next session pickup: build a side-by-side instrumentation harness
that mimics PlayerMovementController's EXACT call sequence
(PhysicsBody field state, ResolveWithTransition args, frame
ordering) and compare per-tick divergence against a live capture.
The harness is missing some piece of state production carries
across ticks — find what piece.

Apparatus progress (committed):
- Harness with synthetic stair GfxObj registration (Issue #98 ramp polygon now constructable programmatically)
- Synthetic cell-BSP attachment (AttachSyntheticBsp) — unlocks indoor
  BSP collision path for hydrated cell fixtures
- WalkablePolygon seeding in BuildInitialBody (PhysicsBody seeding pattern documented)
- Three diagnostic dump tests for tick-by-tick traces

Test baseline: 1167 + 5 (harness) = 1172 + 8 pre-existing failures.

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

651 lines
28 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.");
}
// ───────────────────────────────────────────────────────────────
// 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);
}
}