fix(phys): A6.P4 door bug — AddAllOutsideCells coord convention + replay apparatus

CellTransit.AddAllOutsideCells assumed sphere coords were absolute world
coords (subtracting lbXf = 0xA9 * 192 = 32448 from the sphere position).
Production has used landblock-local coords since Phase A.1
(streaming-center landblock at world origin), so the subtraction
produced localX = -32316, gridX = -1346 → out-of-range → early return
→ ZERO outdoor cells added.

For outdoor primary cells the bug was masked by GetNearbyObjects's
radial sweep. For indoor primary cells (where #98 gates the outdoor
sweep), the door's outdoor cell 0xA9B40029 never reached
portalReachableCells, the door's BSP was never queried, and the player
walked through Holtburg cottage doors unimpeded.

Fix: AddAllOutsideCells treats worldSphereCenter as landblock-local
directly. Matches retail CLandCell::add_all_outside_cells which uses
the per-cell 6-byte landblock-relative position struct.

Existing CellTransitAddAllOutsideCellsTests + CellTransitFindCellSetTests
updated to use landblock-local sphere coords (they were the only callers
using the world-coord convention; production never did).

Apparatus shipped:
- DoorBugTrajectoryReplayTests — live-capture-driven replay harness
  that pinpointed the bug per-field at unit-test speed (<500ms iteration)
- AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell — direct
  unit test that demonstrates the fix
- FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos
  — verifies cell-portal traversal at the captured sphere position
- DoorSetupGfxObjInspectionTests.HoltburgCottage_CellPortals_DatInspection
  — dat-direct EnvCell + Environment.Cells + portal-poly inspector
- Fixture: tests/AcDream.Core.Tests/Fixtures/door-bug/live-capture.jsonl
  (tick 13558 walkthrough + tick 22760 outdoor block)

Visual verification (user-driven at Holtburg cottage door, ~50cm off-center):
- outside→inside RUN: now BLOCKS (was: walks through)
- outside→inside WALK: presumed blocks (not retested)
- inside→outside RUN: PARTIAL — body intersects door, sphere slides through
- inside→outside WALK: same partial behavior

The remaining inside→outside asymmetry is a SEPARATE bug in BSP
collision response for two-sided polygons. The [bsp-test] probe now
fires 245 times for the door entity from indoor (was 0 pre-fix) —
door IS being queried; the BSP polygon-level collision response is
the new bug. Handoff at
docs/research/2026-05-25-door-bug-partial-fix-shipped.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 07:53:34 +02:00
parent 6a2c432e5a
commit 28cd97be62
8 changed files with 1134 additions and 40 deletions

View file

@ -0,0 +1,570 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using Xunit;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// A6.P4 door bug (2026-05-24) — trajectory replay harness for the
/// Holtburg cottage door walk-through bug. User-reported: approach a
/// CLOSED door OFF-CENTER (≈50 cm from the doorway centerline), the
/// player walks through unimpeded. Live capture
/// (<c>door-walkthrough.jsonl</c>) confirms:
///
/// <list type="bullet">
/// <item>Tick 13558 — player at (132.36, 16.81, 94) cell 0xA9B40150
/// (indoor cottage cell), targets (132.43, 17.20, 94). Engine
/// returns <c>result.Position = target</c> with
/// <c>collisionNormalValid = false</c>. Clean walkthrough.</item>
/// <item>Tick 22760 — player at (133.14, 18.02, 94) cell 0xA9B40029
/// (outdoor cell, 0.57 m EAST of door center), targets
/// (133.10, 17.60, 94). Engine BLOCKS at Y=18.018, cn=(0, +1, 0).
/// The door's BSP fires correctly for THIS approach.</item>
/// </list>
///
/// The bug is positional: the door blocks SOME approaches but not the
/// indoor-cell approach. This harness replays both representative ticks
/// against a fresh engine seeded with the door alone (registered via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> at the captured
/// BSP world transform, cellScope=0u to mirror production). The FIRST
/// per-field divergence between live and harness outputs names what
/// apparatus state production has that the harness lacks — short-
/// circuiting the speculative-fix loop that closed in handoff
/// <c>docs/research/2026-05-24-door-collision-session-end-handoff.md</c>.
///
/// <para>
/// SKIP if ACDREAM_DAT_DIR (or the default
/// <c>%USERPROFILE%\Documents\Asheron's Call</c>) is unavailable — keeps
/// CI green. Local developer runs always have it.
/// </para>
/// </summary>
public class DoorBugTrajectoryReplayTests
{
// ── Door geometry from live capture ───────────────────────────────
// [entity-source] id=0x000F4246 src=0x020019FF gfxObj=0x020019FF
// lb=0xA9B40029 shapes=cyl1+bsp1 state=0x00010008
// [bsp-test] obj=0x000F4246 gfx=0x010044B5 radius=1.975
// pos=(132.57,16.99,95.36)
// [cyl-test] obj=0x000F4246 radius=0.100 height=0.200
// pos=(132.56,17.11,94.10)
private const uint DoorEntityId = 0x000F4246u;
private const uint DoorGfxObjId = 0x010044B5u;
private const uint DoorClosedState = 0x00010008u; // PERSISTENT_PS | 0x8 (no ETHEREAL)
private const uint DoorLandblockId = 0xA9B40000u;
private static readonly Vector3 BspWorldPos = new(132.57f, 16.99f, 95.36f);
private const float BspRadius = 1.975f;
private static readonly Vector3 CylWorldPos = new(132.56f, 17.11f, 94.10f);
private const float CylRadius = 0.10f;
private const float CylHeight = 0.20f;
// ── Tests ─────────────────────────────────────────────────────────
/// <summary>
/// Replay tick 13558 — the walkthrough. Player at (132.36, 16.81, 94)
/// indoor cell 0xA9B40150, runs NE to (132.43, 17.20, 94) crossing
/// the door's BSP Y-range [16.86, 17.12]. Live engine reports
/// <c>collisionNormalValid=false</c>, result.Position == target — sphere
/// walks through. The harness should reproduce the same null collision
/// IF the bug is upstream of BSP query (door not returned by
/// GetNearbyObjects from the indoor primary cell), OR fire a BSP
/// collision IF the harness's portal-reachable cell set includes the
/// door's outdoor cell when production's doesn't.
/// </summary>
[Fact]
public void LiveCompare_DoorOffCenterWalkthrough_Tick13558()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildEngineWithDoorFixture(datDir);
var captured = LoadCapturedRecord(r => r.Tick == 13558);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// Replay tick 22760 — the door BLOCKS. Player at (133.14, 18.02, 94)
/// outdoor cell 0xA9B40029, walks SW to (133.10, 17.60, 94). Live engine
/// reports collision with cn=(0, +1, 0) (+Y wall facing north, blocks
/// south motion). Sphere stopped at Y=18.018. This is the WORKING
/// case — the door's BSP correctly blocks when queried from the
/// outdoor primary cell. If the harness diverges here, the door
/// registration itself is wrong.
/// </summary>
[Fact]
public void LiveCompare_DoorBlocksFromOutside_Tick22760()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildEngineWithDoorFixture(datDir);
var captured = LoadCapturedRecord(r => r.Tick == 22760);
AssertCallMatchesCapture(engine, captured);
}
/// <summary>
/// Diagnostic dump: every relevant probe ON, replay tick 13558,
/// captured stdout shows what GetNearbyObjects / CellTransit /
/// BSPQuery actually did. Use this when the LiveCompare test FAILS
/// to see the engine's internal decisions on the failing tick.
/// Always passes (diagnostic-only).
/// </summary>
[Fact]
public void Diagnostic_Tick13558_DumpEngineInternals()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeBuildingEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePushBackEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
try
{
var (engine, _) = BuildEngineWithDoorFixture(datDir);
var captured = LoadCapturedRecord(r => r.Tick == 13558);
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
Console.WriteLine("=== Replay tick 13558 (the walkthrough) ===");
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(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"=== Harness: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
result.Position.X, result.Position.Y, result.Position.Z,
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
result.CollisionNormalValid, result.IsOnGround, result.CellId));
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"=== Live: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
captured.Result.Position.X, captured.Result.Position.Y, captured.Result.Position.Z,
captured.Result.CollisionNormal.X, captured.Result.CollisionNormal.Y, captured.Result.CollisionNormal.Z,
captured.Result.CollisionNormalValid, captured.Result.IsOnGround, captured.Result.CellId));
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeBuildingEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePushBackEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
}
}
/// <summary>
/// Drive <see cref="CellTransit.FindTransitCellsSphere"/> directly with
/// cell 0xA9B40150 hydrated from the real dat and the sphere position
/// captured at tick 13558. Asserts <c>exitOutside</c> fires for the
/// 0xFFFF exit portal. If this PASSES, the cell-portal code is correct
/// in isolation and the production bug is upstream (cell not loaded
/// in cache, primary cell mis-classified, etc.). If it FAILS, the
/// portal traversal IS the bug.
/// </summary>
[Fact]
public void FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
// ── 1. Hydrate cell 0xA9B40150 from the real dat ────────────
using var dats = new DatCollection(datDir, DatAccessType.Read);
const uint CellId = 0xA9B40150u;
const uint EnvCellPrefix = 0x0D000000u;
var envCell = dats.Get<DatReaderWriter.DBObjs.EnvCell>(CellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
EnvCellPrefix | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct));
Assert.NotNull(cellStruct);
// ── 2. Build the cell's worldTransform matching production
// (GameWindow.cs:5404-5406) ──────────────────────────────
var cellOriginWorld = envCell.Position.Origin;
var worldTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
Matrix4x4.CreateTranslation(cellOriginWorld);
// ── 3. Hydrate into CellPhysics via CacheCellStruct ─────────
var cache = new PhysicsDataCache();
cache.CacheCellStruct(CellId, envCell, cellStruct!, worldTransform);
var cellPhysics = cache.GetCellStruct(CellId);
Assert.NotNull(cellPhysics);
Assert.NotNull(cellPhysics!.Portals);
Assert.Contains(cellPhysics.Portals, p => p.OtherCellId == 0xFFFFu);
// ── 4. Captured sphere position at tick 13558 ───────────────
var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f);
const float sphereRadius = 0.48f;
// Confirm sphere is INSIDE cell 0x0150's BSP (sanity).
Assert.NotNull(cellPhysics.CellBSP);
var localCenter = Vector3.Transform(sphereWorld, cellPhysics.InverseWorldTransform);
// ── 5. Run FindTransitCellsSphere — does it fire exitOutside? ─
var candidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cellPhysics, CellId,
sphereWorld, sphereRadius,
candidates,
out bool exitOutside);
// Diagnostic — print the localCenter + each portal's
// sphere-vs-plane distance so we see what the test computed.
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"localCenter=({0:F4},{1:F4},{2:F4}) radius={3:F4}",
localCenter.X, localCenter.Y, localCenter.Z, sphereRadius));
foreach (var portal in cellPhysics.Portals)
{
if (cellPhysics.PortalPolygons is null
|| !cellPhysics.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
continue;
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
float rad = sphereRadius + 0.02f;
bool hit = dist > -rad && dist < rad;
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
" portal otherCellId=0x{0:X4} polyId=0x{1:X4} n=({2:F4},{3:F4},{4:F4}) d={5:F4} dist={6:F4} rad={7:F4} hit={8}",
portal.OtherCellId, portal.PolygonId,
poly.Plane.Normal.X, poly.Plane.Normal.Y, poly.Plane.Normal.Z,
poly.Plane.D, dist, rad, hit));
}
Assert.True(exitOutside,
"Captured sphere at tick 13558 is straddling cell 0xA9B40150's " +
"exit portal (0xFFFF) plane. FindTransitCellsSphere should fire " +
"exitOutside, but did not. Candidates returned: " +
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
}
/// <summary>
/// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId
/// 0xA9B40150. Expects outdoor cell 0xA9B40029 (the door's cell) in
/// the result.
///
/// <para>
/// This is the suspected root cause of the bug: AddAllOutsideCells
/// computes <c>localX = worldSphereCenter.X - lbXf</c> where
/// <c>lbXf = ((cellId &gt;&gt; 24) &amp; 0xFF) * 192</c>. For cellId
/// 0xA9B40150, lbXf = 0xA9 * 192 = 32448. If sphere coords are
/// LANDBLOCK-LOCAL (as the JSONL capture shows: x=132.36, NOT
/// 32580.36), the subtraction produces localX = -32316 → gridX = -1346
/// → early return → NO cells added → door invisible from indoor.
/// </para>
/// </summary>
[Fact]
public void AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell()
{
var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f);
const float sphereRadius = 0.48f;
const uint currentCellId = 0xA9B40150u;
var candidates = new HashSet<uint>();
CellTransit.AddAllOutsideCells(
sphereWorld, sphereRadius, currentCellId, candidates);
const uint expectedDoorCell = 0xA9B40029u;
Assert.True(candidates.Contains(expectedDoorCell),
$"AddAllOutsideCells with landblock-local sphere ({sphereWorld.X:F2}, " +
$"{sphereWorld.Y:F2}, {sphereWorld.Z:F2}) and indoor primary cell " +
$"0x{currentCellId:X8} should add outdoor cell 0x{expectedDoorCell:X8} " +
$"(where the door lives). Got: " +
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
}
// ── Engine + door fixture ─────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>Door GfxObj 0x010044B5 hydrated from the real dat
/// (mirrors <c>DoorSetupGfxObjInspectionTests</c>'s read pattern
/// via <see cref="DatCollection.Get{T}"/>).</item>
/// <item>A landblock 0xA9B40000 stub (terrain far below) so
/// <c>TryGetLandblockContext</c> succeeds at the door's XY.</item>
/// <item>Door registered via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> at the
/// captured BSP world center — entity world pos = BSP world pos,
/// entity rot = identity, one BSP shape at local position zero.
/// This bypasses ShadowShapeBuilder.FromSetup; the test's
/// pure goal is to put a slab in the right world location and
/// see whether the engine sees it from the captured tick's
/// primary cell. cellScope=0u (default) mirrors production's
/// door registration at GameWindow.cs:3158-3167.</item>
/// </list>
///
/// <para>
/// No cell fixture is registered for the player's indoor cell
/// 0xA9B40150. Without one, <c>CellTransit.FindCellSet</c> can't
/// portal-walk from indoor → outdoor, so the door (in outdoor cell
/// 0xA9B40029) won't be reachable from the indoor primary. That's
/// the BUG'S ROOT IF the test reproduces the live cnValid=false at
/// tick 13558 — the indoor cell's portal graph is missing the
/// outdoor cell connection.
/// </para>
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithDoorFixture(string datDir)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// 1. Hydrate door BSP from real dat (CacheGfxObj handles ResolvePolygons,
// BoundingSphere extraction, and visual AABB fallback).
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var gfx = dats.Get<GfxObj>(DoorGfxObjId);
Assert.NotNull(gfx);
Assert.NotNull(gfx!.PhysicsBSP);
Assert.NotNull(gfx.PhysicsBSP!.Root);
cache.CacheGfxObj(DoorGfxObjId, gfx);
}
Assert.NotNull(cache.GetGfxObj(DoorGfxObjId));
// 2. Stub landblock so TryGetLandblockContext succeeds at the door XY.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below Z=94
var stubTerrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(
landblockId: DoorLandblockId,
terrain: stubTerrain,
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// 3. Register the door — BSP shape only (the captured bug attaches
// to the BSP slab; the cylinder is a small foot collider).
// entityWorldPos = BSP world pos so LocalPos=0 puts the BSP at
// the captured center. cellScope=0u mirrors production.
var bspShape = new ShadowShape(
GfxObjId: DoorGfxObjId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.BSP,
Radius: BspRadius,
CylHeight: 0f);
var cylShape = new ShadowShape(
GfxObjId: 0u,
LocalPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: CylRadius,
CylHeight: CylHeight);
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: BspWorldPos,
entityWorldRot: Quaternion.Identity,
shapes: new[] { cylShape, bspShape },
state: DoorClosedState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId);
return (engine, cache);
}
// ── Capture loading + comparison (mirrors CellarUpTrajectoryReplayTests) ──
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static ResolveCaptureRecord LoadCapturedRecord(
Func<ResolveCaptureRecord, bool> predicate)
{
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
Assert.True(File.Exists(path),
$"Door-bug live-capture fixture missing: {path}.");
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line))
continue;
var record = System.Text.Json.JsonSerializer
.Deserialize<ResolveCaptureRecord>(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture.");
}
/// <summary>
/// Replays one captured ResolveWithTransition call against
/// <paramref name="engine"/>, seeded with bodyBefore, and reports
/// the first per-field divergence between live and harness.
/// </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);
var divergences = new List<string>();
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);
}
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,
"Door-bug 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));
}
private static string FixtureDir =>
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
"Fixtures", "door-bug");
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);
}
}