Apparatus convergence. With the cottage GfxObj 0x01000A2B registered as
a ShadowEntry in BuildEngineWithCellarFixtures, the harness now reproduces
the live cap-event collision normal (cn=(0,0,-1)) exactly, ending the
"harness doesn't reproduce" divergence the prior session's findings doc
identified.
Concretely:
* Adds a minimum-stub landblock (TerrainSurface at z=-1000) so
TryGetLandblockContext succeeds at the cellar XY — production's
FindObjCollisions early-returns without a landblock and would skip
the cottage shadow query.
* Adds RegisterCottageGfxObj that loads the 74-polygon cottage fixture
via GfxObjDumpSerializer.Hydrate, then registers it at the cottage's
world transform (translation (130.5, 11.5, 94.0) + 180° around Z,
derived from the cellar cell's WorldTransform), matching
GameWindow.cs:5893's landblock-baked-static registration shape.
* LiveCompare_FirstCap_HarnessMissesCottageFloorBecauseCottageGfxObjNotRegistered
flips: the cap-normal reproduction is now enforced by
LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal.
* The full per-field round-trip uncovered ONE residual divergence:
live preserves +0.0266m of +X motion through the cap event (edge-
slide along the floor in XY); harness blocks ALL motion at the cap.
Captured by LiveCompare_FirstCap_ResidualXMotionDivergence_Docs...
in documents-the-bug form so the next session has a concrete next
target.
Fixture: tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json
(74 polygons, 6 downward-facing cottage-floor triangles at object-local
Z=0, BSP radius 13.989m matching the live [resolve-bldg] bspR=13.99).
Captured via launch-a6-issue98-cottage-gfxobj-dump.ps1.
In-isolation: all 12 CellarUpTrajectoryReplayTests + 4 GfxObjDumpRoundTripTests
+ 1 new PhysicsDiagnosticsTests pass.
Note on full-suite baseline: the full xUnit serial run shows 8–19
failures depending on order (pre-existing test interaction with shared
statics across PlayerMovementControllerTests, MotionInterpreterTests,
PositionManagerTests, etc.). The flakiness is independent of this
change — confirmed by stashing the harness changes and observing the
same flaky range. Investigating the static-state isolation problem is
out of scope for issue #98; tracked as a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1226 lines
55 KiB
C#
1226 lines
55 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 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,
|
||
};
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// A6.P3 #98 (2026-05-23 PM extension) — live-vs-harness comparison.
|
||
// Loads the 3-record fixture sampled from a live capture in the
|
||
// Holtburg cottage cellar and replays each through the harness's
|
||
// PhysicsEngine. Each test compares one record's outputs (result +
|
||
// body-after) to what the live engine produced, reporting the FIRST
|
||
// per-field divergence. The divergence pinpoints what world state
|
||
// the harness lacks vs production, ending the speculation loop that
|
||
// burned 6 hypotheses on the airborne-at-tick-1 bug.
|
||
// ───────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Tick 0 — spawn/login teleport into the cellar at world Z=92.5333.
|
||
/// No velocity, no contact-plane seed; currentPos == targetPos (no
|
||
/// motion). The simplest test case: replay the call and verify the
|
||
/// harness produces the same ResolveResult + bodyAfter state.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_Tick0_Spawn()
|
||
{
|
||
var (engine, cache) = BuildEngineWithCellarFixtures();
|
||
var captured = LoadCapturedRecord(record => record.Tick == 0);
|
||
AssertCallMatchesCapture(engine, captured);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Tick 376 — player on the cellar ramp at world Z=91.49. Live capture
|
||
/// has bodyAfter.WalkablePolygon = the ramp polygon (normal ≈
|
||
/// (0, 0.719, 0.695), z range 90.99→94.00). If the harness reproduces
|
||
/// the same walkable polygon + ResolveResult, the ramp geometry is
|
||
/// loaded correctly.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_Tick376_OnRamp()
|
||
{
|
||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||
var captured = LoadCapturedRecord(record => record.Tick == 376);
|
||
AssertCallMatchesCapture(engine, captured);
|
||
}
|
||
|
||
/// <summary>
|
||
/// First-cap event — the failing tick. Live engine reports cn=(0,0,-1),
|
||
/// a downward-facing collision normal, capping the foot sphere at
|
||
/// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage
|
||
/// floor) when foot Z = 94.0 - sphereHeight = 92.80. The head bumps
|
||
/// the cottage floor from BELOW — NOT a step-up / AdjustOffset bug.
|
||
///
|
||
/// <para>
|
||
/// Live capture's <c>[resolve]</c> probe pinpointed the blocking
|
||
/// entity: <c>obj=0xA9B47900</c> — a landblock-baked static building
|
||
/// (the cottage GfxObj <c>0x01000A2B</c>). The cottage's floor polys
|
||
/// live in this GfxObj as a ShadowEntry, NOT in any cottage cell.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Apparatus-convergence form (2026-05-23 evening v2): with the
|
||
/// cottage GfxObj registered via <see cref="RegisterCottageGfxObj"/>,
|
||
/// the harness reproduces the live cn=(0,0,-1) cap event. This test
|
||
/// enforces THAT specific reproduction. The post-cap position
|
||
/// processing has a separate residual divergence — documented by
|
||
/// <see cref="LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation"/>.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_FirstCap_HarnessReproducesCottageFloorCapNormal()
|
||
{
|
||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||
var captured = LoadCapturedRecord(record =>
|
||
record.Result.CollisionNormalValid
|
||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||
|
||
// Live must have cn=(0,0,-1) at this point — sanity check.
|
||
Assert.True(captured.Result.CollisionNormalValid,
|
||
"Captured record must have collisionNormalValid=true.");
|
||
Assert.True(captured.Result.CollisionNormal.Z < -0.99f,
|
||
$"Captured record must have downward collision normal; got " +
|
||
$"{captured.Result.CollisionNormal}.");
|
||
|
||
// Replay the call.
|
||
Assert.NotNull(captured.BodyBefore);
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||
var harnessResult = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
// Apparatus convergence: harness reproduces the cap-event collision
|
||
// normal exactly. If this fails, the cottage GfxObj registration
|
||
// has regressed (RegisterCottageGfxObj is broken, the dump fixture
|
||
// is stale, or the harness wiring lost the landblock context).
|
||
Assert.True(harnessResult.CollisionNormalValid,
|
||
"Harness must reproduce the live collision-normal-valid signal " +
|
||
"now that the cottage GfxObj is registered.");
|
||
Assert.True(harnessResult.CollisionNormal.Z < -0.99f,
|
||
$"Harness must reproduce the live downward-facing cap normal " +
|
||
$"(live cn={captured.Result.CollisionNormal}, harness cn={harnessResult.CollisionNormal}).");
|
||
}
|
||
|
||
/// <summary>
|
||
/// A6.P3 issue #98 (2026-05-23 evening v2) — documents-the-bug for the
|
||
/// residual divergence the apparatus surfaced. With the cottage GfxObj
|
||
/// registered, the harness reproduces the live cap-event collision
|
||
/// normal, but the POST-CAP POSITION processing diverges:
|
||
/// <list type="bullet">
|
||
/// <item>Live: full +X motion preserved (sphere slides +0.0266 m in X
|
||
/// along the cottage floor before the Y motion is capped).</item>
|
||
/// <item>Harness: ZERO X motion (sphere stays at its input X position,
|
||
/// only the Y component is blocked).</item>
|
||
/// </list>
|
||
///
|
||
/// <para>
|
||
/// Both X positions agree on Y=7.2243 and Z=92.7390. Only X differs:
|
||
/// live X=141.3865 (requested target), harness X=141.3599 (current = no
|
||
/// move). The requested delta was (+0.0266, -0.4022, 0); live applied
|
||
/// the +X portion, harness applied nothing.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Hypothesis (to investigate next session): live's response to a
|
||
/// cn=(0,0,-1) head-bump treats it as a Z-only constraint and lets the
|
||
/// XY component of the move complete via edge-slide. Harness's BSP path
|
||
/// is rejecting the WHOLE move vector when the cottage floor poly
|
||
/// intersects the head sphere, instead of computing a slid offset.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Documents-the-bug: PASSES today on the asserted residual magnitude.
|
||
/// When a future session fixes the post-cap edge-slide, harness X will
|
||
/// match live X — this test FAILS at that point, signaling that the
|
||
/// X divergence is closed and the test should be folded back into the
|
||
/// strict <see cref="AssertCallMatchesCapture"/> path.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_FirstCap_ResidualXMotionDivergence_DocumentsNextInvestigation()
|
||
{
|
||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||
var captured = LoadCapturedRecord(record =>
|
||
record.Result.CollisionNormalValid
|
||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||
Assert.NotNull(captured.BodyBefore);
|
||
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||
var harnessResult = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
// Live preserved the full +X motion through the cap event; harness
|
||
// blocked it. Y and Z agree.
|
||
Assert.Equal(captured.Result.Position.Y, harnessResult.Position.Y, 4);
|
||
Assert.Equal(captured.Result.Position.Z, harnessResult.Position.Z, 4);
|
||
|
||
float liveDeltaX = captured.Result.Position.X - captured.Input.CurrentPos.X;
|
||
float harnessDeltaX = harnessResult.Position.X - captured.Input.CurrentPos.X;
|
||
|
||
Assert.True(liveDeltaX > 0.02f,
|
||
$"Live must show +X motion after cap (expected ~+0.0266 m, got {liveDeltaX:F4}).");
|
||
Assert.True(MathF.Abs(harnessDeltaX) < 0.001f,
|
||
$"Harness currently zeros X motion through the cap (expected ~0, got {harnessDeltaX:F4}). " +
|
||
"If this assertion starts failing because harness now preserves +X, the post-cap " +
|
||
"edge-slide divergence is closed — fold this back into AssertCallMatchesCapture.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Diagnostic dump: turns on every relevant probe and replays the
|
||
/// first-cap record, so the captured stdout shows which polygon the
|
||
/// harness BSP hit when it computed cn=(0,0,+1) — pinpoints the
|
||
/// missing fixture cell or the wrong-winding-order polygon.
|
||
/// Always passes; this is a one-shot tool, not a regression.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_FirstCap_DiagnosticDump()
|
||
{
|
||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||
try
|
||
{
|
||
var (engine, cache) = BuildEngineWithCellarFixtures();
|
||
|
||
// Dump the cellar cell's polygons so we can see what BSP is
|
||
// testing against. The harness hit cn=(0,0,+1) — find which
|
||
// polygon has that normal.
|
||
DumpCellPolygons(cache, CellarId);
|
||
DumpCellPolygons(cache, CottageNeighborA);
|
||
DumpCellPolygons(cache, CottageNeighborB);
|
||
|
||
var captured = LoadCapturedRecord(record =>
|
||
record.Result.CollisionNormalValid
|
||
&& record.Result.CollisionNormal.Z < -0.99f);
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
|
||
|
||
Console.WriteLine($"=== Replay tick {captured.Tick} ===");
|
||
var result = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
Console.WriteLine(
|
||
$"=== Result pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4}) " +
|
||
$"cn=({result.CollisionNormal.X:F4},{result.CollisionNormal.Y:F4},{result.CollisionNormal.Z:F4}) " +
|
||
$"cnValid={result.CollisionNormalValid} onGround={result.IsOnGround}");
|
||
}
|
||
finally
|
||
{
|
||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||
}
|
||
}
|
||
|
||
private static void DumpCellPolygons(PhysicsDataCache cache, uint cellId)
|
||
{
|
||
var cell = cache.GetCellStruct(cellId);
|
||
if (cell is null)
|
||
{
|
||
Console.WriteLine($"[cell-dump] 0x{cellId:X8} NOT IN CACHE");
|
||
return;
|
||
}
|
||
var t = cell.WorldTransform;
|
||
Console.WriteLine($"[cell-dump] 0x{cellId:X8} resolved-poly-count={cell.Resolved.Count}");
|
||
Console.WriteLine($" WorldTransform.M14={t.M14:F4} M24={t.M24:F4} M34={t.M34:F4} (origin XYZ?)");
|
||
Console.WriteLine($" Translation=({t.Translation.X:F4},{t.Translation.Y:F4},{t.Translation.Z:F4})");
|
||
foreach (var kv in cell.Resolved)
|
||
{
|
||
var p = kv.Value;
|
||
// Show world-frame vertices for the first 2 polys with normal-Z>0.9
|
||
// (floor candidates) — these are the polygons the head sphere
|
||
// could hit from below.
|
||
string vertsWorld = "";
|
||
if (p.Plane.Normal.Z > 0.9f || p.Plane.Normal.Z < -0.9f)
|
||
{
|
||
vertsWorld = " worldVerts=[" + string.Join(",", p.Vertices.Select(v =>
|
||
{
|
||
var w = Vector3.Transform(v, cell.WorldTransform);
|
||
return $"({w.X:F2},{w.Y:F2},{w.Z:F2})";
|
||
})) + "]";
|
||
}
|
||
Console.WriteLine(
|
||
$" poly id=0x{p.Id:X4} sides={p.SidesType} n=({p.Plane.Normal.X:F4},{p.Plane.Normal.Y:F4},{p.Plane.Normal.Z:F4}) d={p.Plane.D:F4} numV={p.NumPoints}{vertsWorld}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the live-capture.jsonl fixture and returns the FIRST record
|
||
/// matching <paramref name="predicate"/>. Throws with a clear error
|
||
/// when none match — keeps the test failure attributed to the
|
||
/// fixture, not to deserialization.
|
||
/// </summary>
|
||
private static ResolveCaptureRecord LoadCapturedRecord(
|
||
Func<ResolveCaptureRecord, bool> predicate)
|
||
{
|
||
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
|
||
Assert.True(File.Exists(path),
|
||
$"Live-capture fixture missing: {path}. Re-run live capture " +
|
||
$"with ACDREAM_CAPTURE_RESOLVE set.");
|
||
|
||
foreach (var line in File.ReadLines(path))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(line))
|
||
continue;
|
||
|
||
var record = System.Text.Json.JsonSerializer
|
||
.Deserialize<ResolveCaptureRecord>(line, CaptureJsonOptions)!;
|
||
if (predicate(record))
|
||
return record;
|
||
}
|
||
|
||
throw new Xunit.Sdk.XunitException(
|
||
"No captured record matched the predicate. Update the fixture " +
|
||
"to include a representative record.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replays one captured ResolveWithTransition call through the harness
|
||
/// engine, seeded with the captured body-before state, and compares
|
||
/// the harness's ResolveResult + body-after vs the captured values.
|
||
/// Reports the FIRST per-field divergence with both values so the
|
||
/// missing apparatus state is named.
|
||
/// </summary>
|
||
private static void AssertCallMatchesCapture(
|
||
PhysicsEngine engine,
|
||
ResolveCaptureRecord captured)
|
||
{
|
||
Assert.NotNull(captured.BodyBefore);
|
||
Assert.NotNull(captured.BodyAfter);
|
||
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||
|
||
var harnessResult = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
// Compare in priority order — most consequential divergence first.
|
||
var divergences = new List<string>();
|
||
|
||
// 1. Result fields
|
||
AddIfDifferent(divergences, "Result.Position",
|
||
captured.Result.Position, harnessResult.Position);
|
||
AddIfDifferent(divergences, "Result.CellId",
|
||
$"0x{captured.Result.CellId:X8}",
|
||
$"0x{harnessResult.CellId:X8}");
|
||
AddIfDifferent(divergences, "Result.IsOnGround",
|
||
captured.Result.IsOnGround, harnessResult.IsOnGround);
|
||
AddIfDifferent(divergences, "Result.CollisionNormalValid",
|
||
captured.Result.CollisionNormalValid,
|
||
harnessResult.CollisionNormalValid);
|
||
if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid)
|
||
{
|
||
AddIfDifferent(divergences, "Result.CollisionNormal",
|
||
captured.Result.CollisionNormal,
|
||
harnessResult.CollisionNormal);
|
||
}
|
||
|
||
// 2. Body-after fields (subset that's most likely to diverge first)
|
||
AddIfDifferent(divergences, "BodyAfter.Position",
|
||
captured.BodyAfter.Position, body.Position);
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid",
|
||
captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid);
|
||
if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid)
|
||
{
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal",
|
||
captured.BodyAfter.ContactPlane.Normal,
|
||
body.ContactPlane.Normal);
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.D",
|
||
captured.BodyAfter.ContactPlane.D,
|
||
body.ContactPlane.D);
|
||
}
|
||
AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid",
|
||
captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid);
|
||
AddIfDifferent(divergences, "BodyAfter.TransientState",
|
||
$"0x{captured.BodyAfter.TransientState:X}",
|
||
$"0x{(uint)body.TransientState:X}");
|
||
|
||
if (divergences.Count > 0)
|
||
{
|
||
string summary = string.Join("\n • ", divergences);
|
||
string header = string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"Harness replay of captured tick {0} diverges from live engine. " +
|
||
"Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " +
|
||
"cellId=0x{7:X8} isOnGround={8}",
|
||
captured.Tick,
|
||
captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z,
|
||
captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z,
|
||
captured.Input.CellId, captured.Input.IsOnGround);
|
||
throw new Xunit.Sdk.XunitException(
|
||
header + "\nDivergences (live → harness):\n • " + summary);
|
||
}
|
||
}
|
||
|
||
private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new()
|
||
{
|
||
Position = snap.Position,
|
||
Orientation = snap.Orientation,
|
||
Velocity = snap.Velocity,
|
||
Acceleration = snap.Acceleration,
|
||
Omega = snap.Omega,
|
||
GroundNormal = snap.GroundNormal,
|
||
SlidingNormal = snap.SlidingNormal,
|
||
ContactPlaneValid = snap.ContactPlaneValid,
|
||
ContactPlane = snap.ContactPlane,
|
||
ContactPlaneCellId = snap.ContactPlaneCellId,
|
||
ContactPlaneIsWater = snap.ContactPlaneIsWater,
|
||
WalkablePolygonValid = snap.WalkablePolygonValid,
|
||
WalkablePlane = snap.WalkablePlane,
|
||
WalkableVertices = snap.WalkableVertices,
|
||
WalkableUp = snap.WalkableUp,
|
||
Elasticity = snap.Elasticity,
|
||
Friction = snap.Friction,
|
||
State = (PhysicsStateFlags)snap.State,
|
||
TransientState = (TransientStateFlags)snap.TransientState,
|
||
LastUpdateTime = snap.LastUpdateTime,
|
||
};
|
||
|
||
private static void AddIfDifferent<T>(
|
||
List<string> divergences, string name, T live, T harness)
|
||
{
|
||
if (EqualityComparer<T>.Default.Equals(live, harness))
|
||
return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live={1} harness={2}", name, live, harness));
|
||
}
|
||
|
||
private static void AddIfDifferent(
|
||
List<string> divergences, string name, Vector3 live, Vector3 harness)
|
||
{
|
||
if (Vector3.DistanceSquared(live, harness) < 1e-6f)
|
||
return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})",
|
||
name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z));
|
||
}
|
||
|
||
private static void AddIfDifferent(
|
||
List<string> divergences, string name, float live, float harness)
|
||
{
|
||
if (MathF.Abs(live - harness) < 1e-3f)
|
||
return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live={1:F4} harness={2:F4}", name, live, harness));
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────
|
||
// 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. Minimum landblock context for FindObjCollisions ──────
|
||
// FindObjCollisions (TransitionTypes.cs:2153) early-returns
|
||
// TransitionState.OK when TryGetLandblockContext fails for the
|
||
// sphere XY. Without a landblock the harness can't query the
|
||
// cottage GfxObj's shadow entries — and that's where the
|
||
// first-cap collision actually lives (live capture confirmed
|
||
// obj=0xA9B47900 fires the cn=(0,0,-1) push).
|
||
//
|
||
// Register an EMPTY-terrain landblock 0xA9B40000 anchored at
|
||
// world origin (0,0). The landblock test
|
||
// (worldX >= 0 && worldX < 192) covers every harness sphere
|
||
// position (X≈141, Y≈7). TerrainSurface gets a flat far-below
|
||
// surface so SampleTerrainZ returns something the indoor BSP
|
||
// path never consults (FindEnvCollisions's indoor branch fires
|
||
// first when the cell has BSP). Outdoor-fallback queries are
|
||
// harmless because the cell's synthetic BSP returns Collided
|
||
// before terrain is checked.
|
||
var heights = new byte[81]; // 9x9 corners
|
||
var heightTable = new float[256];
|
||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below cellar
|
||
var stubTerrain = new TerrainSurface(heights, heightTable);
|
||
engine.AddLandblock(
|
||
landblockId: 0xA9B40000u,
|
||
terrain: stubTerrain,
|
||
cells: Array.Empty<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
// ── 3. Cottage GfxObj 0x01000A2B from dumped fixture ────────
|
||
// Live capture (2026-05-23 PM v2) attributes the first-cap event
|
||
// to obj=0xA9B47900 (entity 0x00A9B479 partIdx=0) — a landblock-
|
||
// baked static building registered as a ShadowEntry. The full
|
||
// polygon table was extracted via ACDREAM_DUMP_GFXOBJS=0x01000A2B
|
||
// (issue #98 evening-v2 apparatus); 74 polygons including six
|
||
// downward-facing cottage-floor triangles at object-local Z=0
|
||
// that the head sphere bumps from below at world Z=94.
|
||
RegisterCottageGfxObj(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>
|
||
/// A6.P3 issue #98 (2026-05-23 evening v2). Loads the cottage GfxObj
|
||
/// <c>0x01000A2B</c> from the JSON fixture
|
||
/// (<c>tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json</c>,
|
||
/// produced via the <c>ACDREAM_DUMP_GFXOBJS</c> capture infrastructure),
|
||
/// hydrates it as a <see cref="GfxObjPhysics"/> with a synthetic
|
||
/// single-leaf BSP, and registers it as a ShadowEntry at the cottage's
|
||
/// world transform — the same shape production's GameWindow.cs:5893
|
||
/// registration uses for landblock-baked statics.
|
||
///
|
||
/// <para>
|
||
/// Transform values come from two evidence sources:
|
||
/// <list type="bullet">
|
||
/// <item>The cellar cell 0xA9B40147's WorldTransform has translation
|
||
/// (130.5, 11.5, 94.0) and a 3×3 with M11=M22=-1 / M33=+1
|
||
/// (a 180° rotation around Z). The cottage GfxObj sits at the
|
||
/// SAME world transform (its building origin is also at
|
||
/// (130.5, 11.5, 94.0) per the existing [resolve-bldg] capture
|
||
/// <c>entOrigin_lb=(130.5,11.5,94.0)</c>).</item>
|
||
/// <item>BoundingSphere radius from the dump's
|
||
/// <see cref="GfxObjDump.BoundingSphereRadius"/> — 13.989 m.
|
||
/// Matches the live <c>bspR=13.99</c> observed in the
|
||
/// [resolve-bldg] capture; cross-validation that the same
|
||
/// building is in play.</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Entity id <c>0x00A9B479</c> mirrors the live capture's
|
||
/// <c>obj=0xA9B47900</c> formula (entity.Id × 256 + partIdx=0). Using
|
||
/// the same id keeps any future probe correlation aligned with live
|
||
/// log conventions.
|
||
/// </para>
|
||
/// </summary>
|
||
private static void RegisterCottageGfxObj(PhysicsEngine engine, PhysicsDataCache cache)
|
||
{
|
||
const uint CottageGfxId = 0x01000A2Bu;
|
||
const uint CottageEntityId = 0x00A9B479u;
|
||
|
||
var fixturePath = Path.Combine(FixtureDir, "0x01000A2B.gfxobj.json");
|
||
Assert.True(File.Exists(fixturePath),
|
||
$"Cottage GfxObj fixture missing: {fixturePath}. Re-run live " +
|
||
$"capture with ACDREAM_DUMP_GFXOBJS=0x01000A2B.");
|
||
|
||
var dump = GfxObjDumpSerializer.Read(fixturePath);
|
||
var physics = GfxObjDumpSerializer.Hydrate(dump);
|
||
cache.RegisterGfxObjForTest(CottageGfxId, physics);
|
||
|
||
// World transform from the cellar cell's WorldTransform: translation
|
||
// (130.5, 11.5, 94.0) + 180° rotation around Z. The cottage GfxObj
|
||
// shares this transform (it IS the cellar/cottage geometry).
|
||
var worldPos = new Vector3(130.5f, 11.5f, 94.0f);
|
||
var worldRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
|
||
|
||
engine.ShadowObjects.Register(
|
||
entityId: CottageEntityId,
|
||
gfxObjId: CottageGfxId,
|
||
worldPos: worldPos,
|
||
rotation: worldRot,
|
||
radius: physics.BoundingSphere?.Radius ?? 14f,
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f,
|
||
landblockId: 0xA9B40000u,
|
||
collisionType: ShadowCollisionType.BSP,
|
||
scale: 1.0f,
|
||
// Landblock-baked statics in production (GameWindow.cs:5899) use
|
||
// `entity.ParentCellId ?? 0u` — the cottage building has no
|
||
// ParentCellId (it's a top-level landblock static), so the
|
||
// scope is landblock-wide (cellScope=0).
|
||
cellScope: 0u);
|
||
}
|
||
|
||
/// <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);
|
||
}
|
||
}
|