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;
///
/// 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
/// (door-walkthrough.jsonl) confirms:
///
///
/// - Tick 13558 — player at (132.36, 16.81, 94) cell 0xA9B40150
/// (indoor cottage cell), targets (132.43, 17.20, 94). Engine
/// returns result.Position = target with
/// collisionNormalValid = false. Clean walkthrough.
/// - 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.
///
///
/// 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
/// 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
/// docs/research/2026-05-24-door-collision-session-end-handoff.md.
///
///
/// SKIP if ACDREAM_DAT_DIR (or the default
/// %USERPROFILE%\Documents\Asheron's Call) is unavailable — keeps
/// CI green. Local developer runs always have it.
///
///
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 ─────────────────────────────────────────────────────────
///
/// 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
/// collisionNormalValid=false, 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.
///
[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);
}
///
/// 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.
///
[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);
}
///
/// 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).
///
[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;
}
}
///
/// Drive directly with
/// cell 0xA9B40150 hydrated from the real dat and the sphere position
/// captured at tick 13558. Asserts exitOutside 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.
///
[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(CellId);
Assert.NotNull(envCell);
var environment = dats.Get(
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();
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}")));
}
///
/// A6.P4 inside-out asymmetric collision (2026-05-25 evening) —
/// synthesizes a sphere approaching a faithfully-registered door
/// from each side and asserts the BSP collision fires symmetrically.
///
///
/// Door registered via the SAME path production uses
/// (ShadowShapeBuilder.FromSetup + RegisterMultiPart) with the
/// real Setup 0x020019FF loaded from the dat, including the
/// part-0 BSP's PlacementFrame[Default][0] origin of
/// (-0.006, 0.125, 1.275). Entity world rotation is 180° around Z
/// to match the cottage's world transform (per the cellar fixture
/// pattern + observed [bsp-test] world position alignment).
///
///
///
/// After the 180° entity rotation, the slab's local Y thickness axis
/// maps to world -Y. Slab spans world Y in approximately
/// [.Y - 0.261, .Y]
/// (entity Y minus 0 to entity Y minus 0.261 thickness). Two faces
/// matter:
///
/// - Higher-Y face (world Y ≈ entity.Y) has world
/// normal +Y. A sphere NORTH of the slab moving SOUTH (-Y)
/// hits this face. cn should be near (0, +1, 0).
/// - Lower-Y face (world Y ≈ entity.Y - 0.261) has world
/// normal -Y. A sphere SOUTH of the slab moving NORTH (+Y)
/// hits this face. cn should be near (0, -1, 0).
///
///
///
///
/// User-reported behavior post-AddAllOutsideCells-fix:
/// outside→inside blocks cleanly; inside→outside shows the body
/// partially intersecting the door before sphere slides through.
/// If the asymmetry is in BSP collision, these tests will
/// reproduce it at unit-test speed.
///
///
///
/// Geometric pin (2026-05-25 evening, CORRECTED) — pins where the
/// cottage door's BSP slab actually lives in world space relative
/// to the player's sphere.
///
///
/// The cottage door Setup 0x020019FF has:
///
/// - One CylSphere (r=0.10, h=0.20, origin=(0, 0, 0.018)) — a
/// TINY foot collider at entity Z + 0.018, extending Z just
/// to 0.218 above entity Z.
/// - Part 0 = GfxObj 0x010044B5 (BSP slab 1.925 × 0.261 × 2.490 m),
/// placed via PlacementFrames[Default][0].Origin =
/// (-0.006, 0.125, 1.275). The slab's local Z=0 origin sits
/// at entity Z + 1.275 — i.e., the slab's BOTTOM is 1.275 m
/// ABOVE the door's entity foot.
///
///
///
///
/// AABB measured: min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255).
/// The slab's local origin is at the slab's GEOMETRIC CENTER (each
/// axis is roughly symmetric around 0). With partFrame.Z = +1.275
/// lifting the local origin up from the entity, the slab's world
/// extents are:
///
///
///
/// - X: [131.635, 133.560] (1.925 m wide; after 180° entity Z rot)
/// - Y: [16.848, 17.109] (0.261 m thick)
/// - Z: [94.139, 96.630] (2.491 m tall, bottom JUST above floor)
///
///
///
/// Player sphere (radius 0.48, height 1.20) at floor Z=94 extends
/// Z=[94, 95.20]. Slab bottom (94.139) is BELOW sphere top (95.20)
/// by 1.061 m. The slab DOES overlap the sphere in Z over
/// world Z range [94.139, 95.20]. The slab is at sphere height,
/// not above it.
///
///
///
/// The foot cylinder (r=0.10, h=0.20) sits at world Z [94.118, 94.318]
/// — barely above the floor, well within the sphere's foot region.
///
///
///
/// Both shapes are at collision-able height. So the post-fix
/// inside-out walkthrough at off-center positions is NOT explained
/// by the slab being above the sphere. The bug must be in the BSP
/// polygon-level collision response, OR in how the multi-cell
/// portal-reachable cells produce the shapes list for a player on
/// the indoor side of the doorway. Next session investigation:
/// add a focused test that replays the captured inside-out
/// walkthrough tick with the door registered at its FAITHFUL
/// production transform (180° entity rot + dat-loaded partFrame)
/// and shows what BSPQuery.FindCollisions actually does at that
/// tick.
///
///
///
/// A6.P4 inside-out bug investigation (2026-05-25 late evening) —
/// hypothesis test: the asymmetric inside-out walkthrough is the
/// sphere walking AROUND the door slab via the cottage wall area
/// east/west of the doorway opening. The cottage exterior walls
/// are part of GfxObj 0x01000A2B (the cottage building, same one
/// from issue #98's cellar floor cap). Issue #98's indoor-primary-cell
/// gate removed cottage-WALL visibility along with the cottage FLOOR
/// — too aggressive. From indoor primary cells, the cottage walls
/// adjacent to the doorway can't block the sphere.
///
///
/// This test reproduces the captured tick 3254 (sphere at
/// (133.655, 17.590, 94) in indoor cell 0xA9B40150, moving to
/// (133.549, 17.599, 94)) with the cottage GfxObj registered as
/// landblock-baked static. If, with the cottage walls visible, the
/// sphere is blocked from being at X=133.655 (which is OUTSIDE the
/// doorway opening, INSIDE the cottage wall geometry), the bug
/// is confirmed as #98's overly-aggressive gate.
///
///
[Fact]
public void InsideOut_Tick3254_WithCottageWalls_ShouldBlock()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, cache) = BuildFaithfulDoorEngine(datDir);
// Add cottage GfxObj 0x01000A2B as landblock-baked static,
// mirroring production GameWindow.RegisterLiveEntityCollision's
// cellScope=0u (landblock-wide).
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u; // matches issue #98 fixture id
var cottageFixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
Assert.True(File.Exists(cottageFixturePath));
var cottageDump = GfxObjDumpSerializer.Read(cottageFixturePath);
var cottagePhysics = GfxObjDumpSerializer.Hydrate(cottageDump);
cache.RegisterGfxObjForTest(CottageGfxId, cottagePhysics);
engine.ShadowObjects.Register(
entityId: CottageEntityId,
gfxObjId: CottageGfxId,
worldPos: new Vector3(130.5f, 11.5f, 94.0f),
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI),
radius: cottagePhysics.BoundingSphere?.Radius ?? 14f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u);
// Replay captured tick 3254 inputs exactly.
var currentPos = new Vector3(133.65524f, 17.58999f, 94f);
var targetPos = new Vector3(133.54903f, 17.599283f, 94f);
var (result, body) = ResolveAt(engine, currentPos, targetPos, 0xA9B40150u);
// Expected: cottage wall east of doorway blocks the sphere
// from being at X=133.655 (or, at minimum, blocks the +Y slide).
// Currently (per the user's report) the sphere walks past unimpeded.
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Harness tick 3254 reply: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) " +
"cnValid={6} cell=0x{7:X8}",
result.Position.X, result.Position.Y, result.Position.Z,
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
result.CollisionNormalValid, result.CellId));
// Document the production state via assertion: the sphere DID make
// Y motion (+0.009) at this tick (target.Y > input.Y). If the
// cottage wall blocks correctly, harness Y should stay at input Y
// (sphere fully blocked, can't move north past cottage wall).
// Currently this test demonstrates the bug shape.
Assert.True(result.Position.Y < targetPos.Y - 0.005f,
$"BUG REPRODUCTION: harness allowed Y motion ({result.Position.Y}) toward " +
$"target ({targetPos.Y}). Cottage wall should block sphere at X=133.655 " +
$"(0.095 m east of slab east edge). If this assertion FAILS, the cottage " +
$"wall is now blocking as expected — the #98 gate fix landed.");
}
///
/// A6.P4 corner-slide hypothesis (2026-05-25 late) — reproduces the
/// inside-out walkthrough at unit-test speed. Builds engine with
/// BOTH cottage GfxObj 0x01000A2B (which contains the north exterior
/// wall east of doorway at X=[133.5, 136.3], Y=17.10) AND cell
/// 0xA9B40150's BSP (alcove east wall at X=133.5, Y=[16.5, 17.1]).
/// Sphere starts inside alcove sliding against east wall, then
/// walks NORTH. If harness slides sphere past the corner at
/// (133.5, 17.10) to end up at X > 133.5 Y > 17.10, bug reproduced.
///
[Fact]
public void CornerSlide_AlcoveEastToCottageNorth_ShouldBlock()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, cache) = BuildFaithfulDoorEngine(datDir);
// 1. Register cottage GfxObj (contains the north exterior wall).
const uint CottageGfxId = 0x01000A2Bu;
const uint CottageEntityId = 0x00A9B479u;
var cottageFixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
var cottageDump = GfxObjDumpSerializer.Read(cottageFixturePath);
var cottagePhysics = GfxObjDumpSerializer.Hydrate(cottageDump);
cache.RegisterGfxObjForTest(CottageGfxId, cottagePhysics);
engine.ShadowObjects.Register(
entityId: CottageEntityId,
gfxObjId: CottageGfxId,
worldPos: new Vector3(130.5f, 11.5f, 94.0f),
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI),
radius: cottagePhysics.BoundingSphere?.Radius ?? 14f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f,
cellScope: 0u);
// 2. Load cell 0xA9B40150 BSP into cache (the alcove walls).
const uint AlcoveCellId = 0xA9B40150u;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var envCell = dats.Get(AlcoveCellId);
Assert.NotNull(envCell);
var environment = dats.Get(
0x0D000000u | envCell!.EnvironmentId);
Assert.NotNull(environment);
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct));
var cellOriginWorld = envCell.Position.Origin;
var cellTransform =
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
Matrix4x4.CreateTranslation(cellOriginWorld);
cache.CacheCellStruct(AlcoveCellId, envCell, cellStruct!, cellTransform);
}
Assert.NotNull(cache.GetCellStruct(AlcoveCellId));
// 3. Sphere setup: inside alcove, near east wall.
// Alcove east wall at world X=133.5, Y=[16.5, 17.1]. Sphere at
// X=132.95 (sphere east edge 133.43 just west of wall), Y=16.8
// (inside alcove Y range).
var currentPos = new Vector3(132.95f, 16.8f, 94f);
// Walk sphere in +Y direction (toward cottage exterior north wall).
// Repeat several ticks with small steps to mimic walk-speed motion.
Vector3 pos = currentPos;
uint cellId = AlcoveCellId;
bool isOnGround = true;
var body = new PhysicsBody
{
Position = pos,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
ContactPlaneCellId = cellId,
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
WalkableVertices = new[]
{
new Vector3(120f, 10f, 94f),
new Vector3(145f, 10f, 94f),
new Vector3(145f, 30f, 94f),
new Vector3(120f, 30f, 94f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
Console.WriteLine($"Start: pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) cell=0x{cellId:X8}");
for (int t = 1; t <= 50; t++)
{
var target = pos + new Vector3(0f, 0.05f, 0f); // walk speed
var result = engine.ResolveWithTransition(
currentPos: pos,
targetPos: target,
cellId: cellId,
sphereRadius: 0.48f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.5f,
isOnGround: isOnGround,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: DoorEntityId + 1);
pos = result.Position;
cellId = result.CellId;
isOnGround = result.IsOnGround;
body.Position = pos;
if (t % 5 == 0 || result.CollisionNormalValid)
{
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"t={0,2} pos=({1:F3},{2:F3},{3:F3}) cell=0x{4:X8} cnValid={5} cn=({6:F2},{7:F2},{8:F2})",
t, pos.X, pos.Y, pos.Z, cellId,
result.CollisionNormalValid,
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z));
}
// Stop if sphere has clearly walked through the wall.
if (pos.Y > 18f) break;
}
Console.WriteLine($"Final pos: ({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) cell=0x{cellId:X8}");
// Document expected: sphere should stop at sphere center Y =
// 17.10 - 0.48 = 16.62 (cottage north wall + sphere reach).
// Bug: sphere slides past corner and exits north.
Assert.True(pos.Y < 17.20f,
$"BUG REPRODUCTION: sphere walked from inside alcove to Y={pos.Y:F3} " +
$"(past cottage north wall at Y=17.10). Cottage wall should have blocked " +
$"sphere at Y ≈ 16.62 (wall - sphere reach). If this assertion FAILS, " +
$"the corner handling at (X=133.5, Y=17.10) is letting sphere slide past.");
}
///
/// Diagnostic: dump cottage GfxObj 0x01000A2B polygons near world
/// position (133.655, 17.59, 94.5) — the sphere position where the
/// inside-out walkthrough happens. Identifies which cottage polys
/// are at sphere height in that area, to know whether walls / floors
/// / nothing.
///
[Fact]
public void Diagnostic_CottagePolys_NearWalkthroughPosition()
{
var fixturePath = Path.Combine(SolutionRoot(),
"tests", "AcDream.Core.Tests", "Fixtures", "issue98",
"0x01000A2B.gfxobj.json");
var dump = GfxObjDumpSerializer.Read(fixturePath);
var physics = GfxObjDumpSerializer.Hydrate(dump);
// Cottage world transform: pos (130.5, 11.5, 94), rotation 180° Z.
var cottagePos = new Vector3(130.5f, 11.5f, 94.0f);
var cottageRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
// Failing sphere position: (133.655, 17.59, 94 .. 95.20)
// Sphere world AABB: X[133.175, 134.135], Y[17.110, 18.070], Z[94, 95.20]
var sphereCenterX = 133.655f;
var sphereCenterY = 17.59f;
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
"Cottage GfxObj 0x01000A2B: {0} polys total, BS radius {1:F3}",
physics.Resolved.Count, physics.BoundingSphere?.Radius ?? 0f));
Console.WriteLine("Looking for polys whose world-vertex bbox overlaps sphere AABB:");
Console.WriteLine($" Sphere X=[{sphereCenterX-0.48f:F3}, {sphereCenterX+0.48f:F3}]");
Console.WriteLine($" Sphere Y=[{sphereCenterY-0.48f:F3}, {sphereCenterY+0.48f:F3}]");
Console.WriteLine($" Sphere Z=[94.000, 95.200]");
// Also dump ALL polys with vertices near sphere XY (loose: 3m window)
// so we can see what wall geometry the cottage HAS in the area.
Console.WriteLine("");
Console.WriteLine("=== Cottage polys with bbox extending into (X in [130,138], Y in [13,21]) ===");
int nearXYCount = 0;
foreach (var (polyId, poly) in physics.Resolved)
{
float wxMin = float.MaxValue, wxMax = float.MinValue;
float wyMin = float.MaxValue, wyMax = float.MinValue;
float wzMin = float.MaxValue, wzMax = float.MinValue;
foreach (var v in poly.Vertices)
{
var rotated = Vector3.Transform(v, cottageRot);
var world = cottagePos + rotated;
if (world.X < wxMin) wxMin = world.X; if (world.X > wxMax) wxMax = world.X;
if (world.Y < wyMin) wyMin = world.Y; if (world.Y > wyMax) wyMax = world.Y;
if (world.Z < wzMin) wzMin = world.Z; if (world.Z > wzMax) wzMax = world.Z;
}
// Wide search window.
if (wxMax < 130 || wxMin > 138) continue;
if (wyMax < 13 || wyMin > 21) continue;
nearXYCount++;
var nWorld = Vector3.Transform(poly.Plane.Normal, cottageRot);
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
" poly 0x{0:X4} n=({1:F2},{2:F2},{3:F2}) X=[{4:F2},{5:F2}] Y=[{6:F2},{7:F2}] Z=[{8:F2},{9:F2}]",
polyId, nWorld.X, nWorld.Y, nWorld.Z, wxMin, wxMax, wyMin, wyMax, wzMin, wzMax));
}
Console.WriteLine($" Total: {nearXYCount}");
int matched = 0;
int matchedXY = 0;
Console.WriteLine("");
Console.WriteLine("=== Tight: All cottage polys with XY overlap of sphere AABB (any Z) ===");
foreach (var (polyId, poly) in physics.Resolved)
{
// Transform vertices to world space.
float wxMin = float.MaxValue, wxMax = float.MinValue;
float wyMin = float.MaxValue, wyMax = float.MinValue;
float wzMin = float.MaxValue, wzMax = float.MinValue;
foreach (var v in poly.Vertices)
{
var rotated = Vector3.Transform(v, cottageRot);
var world = cottagePos + rotated;
if (world.X < wxMin) wxMin = world.X; if (world.X > wxMax) wxMax = world.X;
if (world.Y < wyMin) wyMin = world.Y; if (world.Y > wyMax) wyMax = world.Y;
if (world.Z < wzMin) wzMin = world.Z; if (world.Z > wzMax) wzMax = world.Z;
}
bool xOverlap = wxMax >= sphereCenterX - 0.48f && wxMin <= sphereCenterX + 0.48f;
bool yOverlap = wyMax >= sphereCenterY - 0.48f && wyMin <= sphereCenterY + 0.48f;
bool zOverlap = wzMax >= 94f && wzMin <= 95.20f;
if (xOverlap && yOverlap)
{
matchedXY++;
var nWorld = Vector3.Transform(poly.Plane.Normal, cottageRot);
string zMark = zOverlap ? " *** Z-OVERLAP ***" : "";
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
" poly 0x{0:X4} n=({1:F3},{2:F3},{3:F3}) bbox X=[{4:F3},{5:F3}] Y=[{6:F3},{7:F3}] Z=[{8:F3},{9:F3}]{10}",
polyId, nWorld.X, nWorld.Y, nWorld.Z, wxMin, wxMax, wyMin, wyMax, wzMin, wzMax, zMark));
}
if (xOverlap && yOverlap && zOverlap) matched++;
}
Console.WriteLine($" XY-overlap polys (any Z): {matchedXY}");
Console.WriteLine($" XYZ-overlap polys: {matched}");
}
[Fact]
public void Geometric_DoorSlabAtSphereHeight_OverlapsInZ()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
DatReaderWriter.Types.Frame partFrame;
float slabLocalZMin = float.MaxValue;
float slabLocalZMax = float.MinValue;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var setup = dats.Get(0x020019FFu)!;
Assert.NotNull(setup);
Assert.True(setup.PlacementFrames.ContainsKey(DatReaderWriter.Enums.Placement.Default));
partFrame = setup.PlacementFrames[DatReaderWriter.Enums.Placement.Default].Frames[0];
var gfx = dats.Get(DoorGfxObjId)!;
Assert.NotNull(gfx.PhysicsPolygons);
// Walk every physics polygon vertex to find local Z extents.
foreach (var poly in gfx.PhysicsPolygons.Values)
{
foreach (ushort vid in poly.VertexIds)
{
if (!gfx.VertexArray.Vertices.TryGetValue(vid, out var sv)) continue;
if (sv.Origin.Z < slabLocalZMin) slabLocalZMin = sv.Origin.Z;
if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z;
}
}
}
// Slab local origin shifted up by partFrame.Z. Slab world Z extents:
float partWorldZ = DoorSpawnPos.Z + partFrame.Origin.Z;
float slabWorldZBottom = partWorldZ + slabLocalZMin;
float slabWorldZTop = partWorldZ + slabLocalZMax;
const float SphereHeight = 1.20f;
const float PlayerFootZ = 94f;
float sphereTopZ = PlayerFootZ + SphereHeight;
// The slab IS at sphere height — bottom should be below sphere top.
Assert.True(slabWorldZBottom < sphereTopZ,
$"Door slab bottom ({slabWorldZBottom:F3}) should be BELOW " +
$"player sphere top ({sphereTopZ:F3}). Slab Z range = " +
$"[{slabWorldZBottom:F3}, {slabWorldZTop:F3}]. Player sphere Z = " +
$"[{PlayerFootZ:F3}, {sphereTopZ:F3}]. The slab IS at " +
$"sphere height (overlap from {MathF.Max(slabWorldZBottom, PlayerFootZ):F3} " +
$"to {MathF.Min(slabWorldZTop, sphereTopZ):F3}). So the inside-out " +
$"walkthrough is NOT caused by the slab being above the sphere — " +
$"the bug must be in BSP polygon-level collision response.");
}
[Fact]
public void Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildFaithfulDoorEngine(datDir);
// Sphere starts SOUTH of slab (low Y), moves NORTH (+Y) toward door.
// Slab world Y ∈ [16.84, 17.10] approximately after 180° entity rot.
// Sphere south edge needs to be just south of slab south face.
var currentPos = new Vector3(132.5f, 16.3f, 94f);
var targetPos = new Vector3(132.5f, 16.7f, 94f); // +0.4 m north
var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor);
// The slab's south face has world normal (0, -1, 0) after the
// 180° entity rotation. Sphere moving +Y hits it; collision
// normal should oppose motion, i.e., negative Y component.
Assert.True(result.CollisionNormalValid,
$"Outside-in: door should block sphere. Got: pos={result.Position}, " +
$"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}.");
Assert.True(result.CollisionNormal.Y < -0.5f,
$"Outside-in: cn.Y should be negative (south face normal). " +
$"Got cn={result.CollisionNormal}.");
}
[Fact]
public void Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
var (engine, _) = BuildFaithfulDoorEngine(datDir);
// Sphere starts NORTH of slab (high Y), moves SOUTH (-Y) toward door.
var currentPos = new Vector3(132.5f, 17.6f, 94f);
var targetPos = new Vector3(132.5f, 17.2f, 94f); // -0.4 m south
var (result, body) = ResolveAt(engine, currentPos, targetPos, DoorCellOutdoor);
// The slab's north face has world normal (0, +1, 0) after the
// 180° entity rotation. Sphere moving -Y hits it; collision
// normal should oppose motion, i.e., positive Y component.
Assert.True(result.CollisionNormalValid,
$"Inside-out: door should block sphere. Got: pos={result.Position}, " +
$"cnValid={result.CollisionNormalValid}, cn={result.CollisionNormal}.");
Assert.True(result.CollisionNormal.Y > 0.5f,
$"Inside-out: cn.Y should be positive (north face normal). " +
$"Got cn={result.CollisionNormal}.");
}
///
/// Faithful engine: registers the real Setup 0x020019FF door via
/// ShadowShapeBuilder.FromSetup at the captured entity world position
/// (132.6, 17.1, 94.1) with the cottage's 180° Z rotation. Mirrors
/// production GameWindow.RegisterLiveEntityCollision exactly.
///
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildFaithfulDoorEngine(string datDir)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// Cache GfxObj 0x010044B5 (the BSP slab) from dat.
DatReaderWriter.DBObjs.Setup setup;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var gfx = dats.Get(DoorGfxObjId);
Assert.NotNull(gfx);
cache.CacheGfxObj(DoorGfxObjId, gfx!);
setup = dats.Get(0x020019FFu)!;
Assert.NotNull(setup);
}
// Stub landblock at (0, 0) so TryGetLandblockContext succeeds.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: DoorLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty(),
portals: Array.Empty(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// Build shape list the same way production does
// (GameWindow.RegisterLiveEntityCollision):
// 1. ShadowShapeBuilder.FromSetup with entScale=1
// 2. Substitute BSP shape's radius with the real BoundingSphere.Radius
var rawShapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f,
id => cache.GetGfxObj(id)?.BSP?.Root is not null);
var shapes = new List(rawShapes.Count);
foreach (var s in rawShapes)
{
if (s.CollisionType == ShadowCollisionType.BSP)
{
var phys = cache.GetGfxObj(s.GfxObjId);
float bspR = phys?.BoundingSphere?.Radius ?? 2f;
shapes.Add(s with { Radius = bspR });
}
else
{
shapes.Add(s);
}
}
Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP);
// Register the door at the cottage's entity world transform:
// - Position from the captured spawn data: (132.6, 17.1, 94.1)
// - Rotation 180° around Z to match cottage orientation
// (consistent with [bsp-test] world position alignment)
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: DoorSpawnPos,
entityWorldRot: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI),
shapes: shapes,
state: DoorClosedState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId);
return (engine, cache);
}
///
/// Run one call
/// against at the given positions/cell,
/// returning the result + the body's post-call state.
///
private static (ResolveResult result, PhysicsBody body)
ResolveAt(PhysicsEngine engine, Vector3 currentPos, Vector3 targetPos, uint cellId)
{
var body = new PhysicsBody
{
Position = currentPos,
Orientation = Quaternion.Identity,
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
ContactPlaneCellId = cellId,
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -94f),
WalkableVertices = new[]
{
// Big walkable poly covering Y in [10, 30], X in [120, 145].
new Vector3(120f, 10f, 94f),
new Vector3(145f, 10f, 94f),
new Vector3(145f, 30f, 94f),
new Vector3(120f, 30f, 94f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
var result = engine.ResolveWithTransition(
currentPos: currentPos,
targetPos: targetPos,
cellId: cellId,
sphereRadius: 0.48f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.5f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: DoorEntityId + 1);
return (result, body);
}
// The captured door spawn position from launch.log [entity-source]:
// "live: spawn ... name=Door setup=0x020019FF pos=(132.6,17.1,94.1)@0xA9B40029"
private static readonly Vector3 DoorSpawnPos = new(132.6f, 17.1f, 94.1f);
private const uint DoorCellOutdoor = 0xA9B40029u;
///
/// Direct test of with
/// the captured sphere position (132.36, 16.81, 94) and currentCellId
/// 0xA9B40150. Expects outdoor cell 0xA9B40029 (the door's cell) in
/// the result.
///
///
/// This is the suspected root cause of the bug: AddAllOutsideCells
/// computes localX = worldSphereCenter.X - lbXf where
/// lbXf = ((cellId >> 24) & 0xFF) * 192. 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.
///
///
[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();
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 ─────────────────────────────────────────
///
/// Build a fresh with:
///
/// - Door GfxObj 0x010044B5 hydrated from the real dat
/// (mirrors DoorSetupGfxObjInspectionTests's read pattern
/// via ).
/// - A landblock 0xA9B40000 stub (terrain far below) so
/// TryGetLandblockContext succeeds at the door's XY.
/// - Door registered via
/// 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.
///
///
///
/// No cell fixture is registered for the player's indoor cell
/// 0xA9B40150. Without one, CellTransit.FindCellSet 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.
///
///
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(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(),
portals: Array.Empty(),
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 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(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture.");
}
///
/// A6.P5 (2026-05-25) — loads one of the three fixed records from
/// over-penetration-capture.jsonl by index:
///
/// - 0 — the over-penetration tick (cell-crossing 0xA9B4013F → 0xA9B40150)
/// - 1 — stuck-position hit=yes variant (door fired)
/// - 2 — stuck-position hit=no variant (door invisible — bug case)
///
///
private static ResolveCaptureRecord LoadOverPenRecord(int index)
{
var path = Path.Combine(FixtureDir, "over-penetration-capture.jsonl");
Assert.True(File.Exists(path),
$"A6.P5 over-penetration fixture missing: {path}. " +
$"Run tools/jsonl/extract-records.ps1 to rebuild.");
var lines = File.ReadAllLines(path);
Assert.True(lines.Length >= 3,
$"Expected >= 3 records in {path}; got {lines.Length}");
var raw = lines[index];
return System.Text.Json.JsonSerializer
.Deserialize(raw,
CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
}
///
/// Replays one captured ResolveWithTransition call against
/// , seeded with bodyBefore, and reports
/// the first per-field divergence between live and harness.
///
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();
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(
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));
}
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);
}
}