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); } }