using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// A6.P3 issue #98 (2026-05-23) — deterministic TRAJECTORY replay /// harness for the cottage cellar-ascent failure. Drives /// through N physics /// ticks against pre-loaded cell fixtures, capturing a per-tick /// trajectory record. /// /// /// Unlike (which tests a SINGLE /// failing-frame's geometry against our walkable predicates), this /// harness drives MANY ticks through the full engine to reproduce the /// trajectory itself — once the fixtures support it (see below). /// /// ///

Status as of 2026-05-23 evening: harness mechanics WORK, fixtures /// INCOMPLETE.

/// /// /// The harness compiles and runs the engine through N ticks in /// < 100 ms total. Two findings during commissioning: /// /// /// /// The three issue-#98 cell fixtures /// (tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40*.json) /// contain ONLY axis-aligned polygons — cellar floor, cellar /// ceiling, four cellar walls, cottage floor, cottage walls. The /// live capture's CELLAR RAMP polygon /// (normal ≈ (0, ±0.719, 0.695)) is NOT in any of the /// fixtures. Without it the harness can't reproduce the climb /// trajectory — the sphere walks across the cellar floor /// horizontally and never encounters a slope. /// Independently: at the sphere's initial position resting on /// the cellar floor, the engine reports /// hit=yes n=(0,0,1) walkable=False on tick 1 and rejects /// the forward move. The grounded state flips off and subsequent /// ticks proceed as airborne (no Z change). This may be a real /// engine bug (touching the floor classified as non-walkable /// collision) or a fixture issue (cellar floor poly's /// containment test mis-firing). Either way, the harness /// exposes it deterministically — that's the point. /// /// /// /// Before this harness can drive issue-#98 trajectory fix attempts, /// the fixtures need a re-capture that includes: /// /// /// /// The cellar ramp polygon (whichever cell it actually lives /// in — the live capture said cellar cell 0xA9B40147, /// but our dump doesn't have it; investigate /// to see whether some /// polygons are being skipped during capture). /// Any neighboring cells the sphere may transit into during /// the climb (the live capture's /// [cell-set-summary] showed overlap with /// 0xA9B40143 and 0xA9B40146, both already in /// the fixture set — but additional cells beyond these may /// appear at tick boundaries we haven't observed). /// /// /// /// The current tests document the harness mechanics + the two /// findings above. When fixtures are re-captured, flip /// 's assertion /// to require a successful climb and add additional tests for the /// trajectory shape. /// ///
public class CellarUpTrajectoryReplayTests { // ── Cellar / cottage geometry constants ──────────────────────── private const uint CellarId = 0xA9B40147u; private const uint CottageNeighborA = 0xA9B40143u; private const uint CottageNeighborB = 0xA9B40146u; private const float CellarFloorZ = 90.95f; private const float CottageFloorZ = 94.00f; private const float SphereRadius = 0.48f; private const float SphereHeight = 1.20f; private const float StepUpHeight = 0.60f; private const float StepDownHeight = 0.04f; /// /// Sphere center starts 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: 0.719·y + 0.695·z = 69.5035 → y=8.75 at z=90.95). /// X=141.5 matches the live capture's X. /// private static readonly Vector3 InitialSphereWorld = new(141.5f, 9.5f, CellarFloorZ + SphereRadius); /// /// Per-tick forward offset (−Y direction toward the ramp). /// Magnitude (~0.10 m) matches the live capture's observed per-tick /// requested offset. /// private static readonly Vector3 PerTickOffset = new(0f, -0.10f, 0f); private const int SimulationTicks = 200; // ─────────────────────────────────────────────────────────────── // Tests // ─────────────────────────────────────────────────────────────── /// /// Confirms the harness compiles, the engine runs the simulation, /// and a trajectory comes back with the expected number of points. /// Does NOT assert on trajectory CONTENT — fixture limitations /// (see class summary) make content-level assertions premature. /// [Fact] public void Harness_CompilesAndRunsSimulation() { var (engine, _) = BuildEngineWithCellarFixtures(); var body = BuildInitialBody(); var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks); Assert.Equal(SimulationTicks + 1, trajectory.Count); Assert.Equal(0, trajectory[0].Tick); Assert.Equal(SimulationTicks, trajectory[^1].Tick); } /// /// Diagnostic dump: print the first 10 trajectory points + the /// engine's resolve-probe decisions. Useful when investigating /// what the harness is actually doing. /// [Fact] public void Harness_DiagnosticDump_FirstTenTicks() { PhysicsDiagnostics.ProbeResolveEnabled = true; 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; } } /// /// 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. /// [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 { 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; } } /// /// 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. /// /// /// Investigation excluded: /// /// Stub landblock terrain (removed; same hit) /// Synthetic stair GfxObj (removed; same hit) /// Cell BSP=null on Hydrate (attached synthetic BSP; same hit) /// WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains) /// Initial sphere Z lift 0.0 vs 0.05 m (same hit) /// PhysicsBody seeded vs body=null (same hit) /// /// /// /// /// 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. /// /// [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."); } /// /// Perf budget for the harness: 200 ticks must complete in well /// under 500 ms. If this ever fails, the inner loop has regressed /// and the whole point of the harness — fast iteration on physics /// fixes — is at risk. /// [Fact] public void Harness_SimulationRunsInUnder500ms() { var (engine, _) = BuildEngineWithCellarFixtures(); var body = BuildInitialBody(); var sw = System.Diagnostics.Stopwatch.StartNew(); _ = SimulateTicks(engine, body, CellarId, SimulationTicks); sw.Stop(); Assert.True(sw.ElapsedMilliseconds < 500, $"200-tick simulation should complete in under 500 ms. " + $"Took: {sw.ElapsedMilliseconds} ms."); } /// /// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests /// the probe. Drives 3 ticks with /// capture enabled, then reads the JSON-Lines file back and verifies: /// /// One record per call. /// Inputs round-trip (currentPos, targetPos, cellId, flags). /// Body-before and body-after snapshots are present. /// /// 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. /// [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( 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); } } /// /// Capture is filtered to IsPlayer mover flag. Calls without /// that flag (NPC, remote dead-reckoning) must NOT pollute the /// capture file. /// [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); } } /// /// Shared deserialization options matching /// 's serializer. IncludeFields /// is required because Vector3/Quaternion/Plane store components as /// fields, not properties. /// 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. // ─────────────────────────────────────────────────────────────── /// /// 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. /// [Fact] public void LiveCompare_Tick0_Spawn() { var (engine, cache) = BuildEngineWithCellarFixtures(); var captured = LoadCapturedRecord(record => record.Tick == 0); AssertCallMatchesCapture(engine, captured); } /// /// 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. /// [Fact] public void LiveCompare_Tick376_OnRamp() { var (engine, _) = BuildEngineWithCellarFixtures(); var captured = LoadCapturedRecord(record => record.Tick == 376); AssertCallMatchesCapture(engine, captured); } /// /// First-cap event — replays the live tick where the engine reported /// the cottage-floor cap (cn=(0,0,-1) at world Z=92.74). This test /// documents the issue #98 FIX (2026-05-24): with the indoor-primary-cell /// gate on 's outdoor /// radial sweep, the cottage GfxObj is no longer returned to an indoor /// (cellar) primary cell, so the head-sphere head-bump into the cottage /// floor at world Z=94 does not fire. /// /// /// Architectural anchor: retail's CObjCell::find_cell_list at /// acclient_2013_pseudo_c.txt:308751-308769 branches indoor/outdoor /// on the registering object's m_position cell type — outdoor statics /// like the landblock-baked cottage are added to OUTDOOR cells' /// shadow_object_list only (via add_all_outside_cells), never to /// indoor EnvCells. CEnvCell::find_collisions at 309560 only /// iterates this->shadow_object_list, so indoor cells never test /// against the cottage. Our fix mirrors this by gating the outdoor /// radial sweep in GetNearbyObjects on the sphere's primary cell /// type. /// /// /// /// If this test starts failing because the cap reappears, the /// primaryCellId wiring at TransitionTypes.cs:2180 or the /// gate at ShadowObjectRegistry.cs:GetNearbyObjects has /// regressed. The harness still registers the cottage with the /// production cellScope=0 (landblock-wide) shape, so the apparatus /// itself proves the fix lives in the query path, not the registration /// path. /// /// [Fact] public void LiveCompare_FirstCap_FixClosesCottageFloorCap() { 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 that the // fixture still contains the bug-shape record we're replaying. 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); // Issue #98 fix: the cottage-floor cap (cn.z near -1) must not fire // from an indoor primary cell. The harness MAY still produce some // other collision response (e.g. cn=(0,0,+1) from the ramp walkable // surface, or a wall hit) — we explicitly assert ONLY that the // downward-facing cottage-floor cap is gone. Assert.False( harnessResult.CollisionNormalValid && harnessResult.CollisionNormal.Z < -0.99f, $"Issue #98 fix should prevent the downward-facing cottage-floor " + $"cap. Harness produced cn={harnessResult.CollisionNormal} " + $"(valid={harnessResult.CollisionNormalValid}). If z is back near " + $"-1, the GetNearbyObjects indoor-primary gate has regressed."); } /// /// 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. /// [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}"); } } /// /// Reads the live-capture.jsonl fixture and returns the FIRST record /// matching . Throws with a clear error /// when none match — keeps the test failure attributed to the /// fixture, not to deserialization. /// private static ResolveCaptureRecord LoadCapturedRecord( Func 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(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."); } /// /// 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. /// 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(); // 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( List divergences, string name, T live, T harness) { if (EqualityComparer.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 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 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 // ─────────────────────────────────────────────────────────────── /// /// One point in the simulated trajectory. Captured per tick. /// public sealed record TrajectoryPoint( int Tick, Vector3 Position, uint CellId, bool IsOnGround, bool CpValid); /// /// Builds a with: /// /// The three issue-#98 cottage/cellar cell fixtures registered. /// A stub landblock so TryGetLandblockContext succeeds /// at the cellar XY (needed for FindObjCollisions to query /// the shadow registry). /// A SYNTHETIC stair-piece GfxObj containing the cellar ramp /// polygon, registered as a ShadowEntry scoped to the cellar /// cell. Reconstructed programmatically from the live-capture /// [poly-dump] data /// (docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_polydump/acdream.log), /// transformed to world coordinates so the registered object /// sits at world origin with identity rotation/scale. /// /// private static (PhysicsEngine engine, PhysicsDataCache cache) BuildEngineWithCellarFixtures() { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; // ── 1. Cell fixtures (existing) + 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(), portals: Array.Empty(), 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); } /// /// Wraps a hydrated with a synthetic /// single-leaf that references every /// polygon in cell.Resolved. CellDumpSerializer.Hydrate /// 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). /// 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, }; } /// /// A6.P3 issue #98 (2026-05-23 evening v2). Loads the cottage GfxObj /// 0x01000A2B from the JSON fixture /// (tests/AcDream.Core.Tests/Fixtures/issue98/0x01000A2B.gfxobj.json, /// produced via the ACDREAM_DUMP_GFXOBJS capture infrastructure), /// hydrates it as a 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. /// /// /// Transform values come from two evidence sources: /// /// 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 /// entOrigin_lb=(130.5,11.5,94.0)). /// BoundingSphere radius from the dump's /// — 13.989 m. /// Matches the live bspR=13.99 observed in the /// [resolve-bldg] capture; cross-validation that the same /// building is in play. /// /// /// /// /// Entity id 0x00A9B479 mirrors the live capture's /// obj=0xA9B47900 formula (entity.Id × 256 + partIdx=0). Using /// the same id keeps any future probe correlation aligned with live /// log conventions. /// /// 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); } /// /// 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: /// /// ContactPlaneValid + ContactPlane: copied into /// CollisionInfo.ContactPlane via the body parameter /// seeding in . /// WalkablePolygonValid + WalkablePlane + /// WalkableVertices: read by /// lines /// 665-673 to call SpherePath.SetWalkable(...), which /// sets HasWalkablePolygon=true. 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. /// /// 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, }; /// /// Drives physics ticks. Each tick /// applies as the requested forward /// motion, calls , /// writes the result back to , and records /// a . /// /// /// Cross-tick ContactPlane persistence is via /// — the engine writes its final CP back to the body, then reads /// it as the seed for the next tick. This mirrors the production /// pattern in PlayerMovementController. /// /// private static List SimulateTicks( PhysicsEngine engine, PhysicsBody body, uint initialCellId, int tickCount) { uint cellId = initialCellId; bool isOnGround = true; var trajectory = new List(tickCount + 1) { new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid), }; for (int tick = 1; tick <= tickCount; tick++) { Vector3 target = body.Position + PerTickOffset; var result = engine.ResolveWithTransition( currentPos: body.Position, targetPos: target, cellId: cellId, sphereRadius: SphereRadius, sphereHeight: SphereHeight, stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, isOnGround: isOnGround, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0); body.Position = result.Position; cellId = result.CellId; isOnGround = result.IsOnGround; trajectory.Add(new( tick, body.Position, cellId, isOnGround, body.ContactPlaneValid)); } return trajectory; } private static string FixtureDir => Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", "Fixtures", "issue98"); private static string SolutionRoot() { var dir = AppContext.BaseDirectory; while (!string.IsNullOrEmpty(dir)) { if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) return dir; dir = Path.GetDirectoryName(dir); } throw new InvalidOperationException( "Could not locate AcDream.slnx from " + AppContext.BaseDirectory); } }