Built three new tests to investigate the inside-out asymmetric collision that persists after the AddAllOutsideCells coord fix: 1. Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace — sphere south of door moving NORTH; expects block with cn.Y less than -0.5 2. Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace — sphere north of door moving SOUTH; expects block with cn.Y greater than +0.5 3. Geometric_DoorSlabZRange_AbovePlayerSphereTop — pins the slab Z range vs sphere top math BOTH directional tests PASS — collision is symmetric at unit-test level. The asymmetric production bug therefore comes from something the unit tests do not capture (multi-tick state, cell-tracking flicker, walkable polygon edge interactions). The geometric pin test reveals the real story: Setup 0x020019FF places the part-0 BSP slab 1.275 m ABOVE the entity origin via PlacementFrames[Default][0].Origin. With the cottage door entity at world Z=94.1, the slab world Z range is [95.375, 97.865]. Player sphere top reaches Z=95.20. The slab BOTTOM is 0.175 m ABOVE the sphere top — the slab NEVER collides with the player. The slab is a LINTEL (door frame above the doorway), not a leaf. The door's only effective collider at sphere height is the 0.10 m radius foot cylinder. The directional tests pass because the cylinder blocks, not the BSP. User-reported inside-out off-center walkthrough is the sphere walking AROUND the foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m; any sphere center over 0.58 m from cylinder center passes freely). The visual "body partially intersects door" is the character model occupying the visual door volume while the collision sphere passes beside the cylinder. Reframed handoff in docs/research/2026-05-25-door-bug-partial-fix-shipped.md points to three candidate next-step investigations: - Retail-faithfulness audit on setup.Radius / setup.Height interpretation - Re-inspect door parts 1+2 (GfxObj 0x010044B6) for missed physics shapes - Test the cottage cell BSP (cell 0x0150 walls) + door together — the COMBINED collision may be what retail relies on Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
902 lines
42 KiB
C#
902 lines
42 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using DatReaderWriter.Options;
|
||
using Xunit;
|
||
using Env = System.Environment;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// A6.P4 door bug (2026-05-24) — trajectory replay harness for the
|
||
/// Holtburg cottage door walk-through bug. User-reported: approach a
|
||
/// CLOSED door OFF-CENTER (≈50 cm from the doorway centerline), the
|
||
/// player walks through unimpeded. Live capture
|
||
/// (<c>door-walkthrough.jsonl</c>) confirms:
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item>Tick 13558 — player at (132.36, 16.81, 94) cell 0xA9B40150
|
||
/// (indoor cottage cell), targets (132.43, 17.20, 94). Engine
|
||
/// returns <c>result.Position = target</c> with
|
||
/// <c>collisionNormalValid = false</c>. Clean walkthrough.</item>
|
||
/// <item>Tick 22760 — player at (133.14, 18.02, 94) cell 0xA9B40029
|
||
/// (outdoor cell, 0.57 m EAST of door center), targets
|
||
/// (133.10, 17.60, 94). Engine BLOCKS at Y=18.018, cn=(0, +1, 0).
|
||
/// The door's BSP fires correctly for THIS approach.</item>
|
||
/// </list>
|
||
///
|
||
/// The bug is positional: the door blocks SOME approaches but not the
|
||
/// indoor-cell approach. This harness replays both representative ticks
|
||
/// against a fresh engine seeded with the door alone (registered via
|
||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> at the captured
|
||
/// BSP world transform, cellScope=0u to mirror production). The FIRST
|
||
/// per-field divergence between live and harness outputs names what
|
||
/// apparatus state production has that the harness lacks — short-
|
||
/// circuiting the speculative-fix loop that closed in handoff
|
||
/// <c>docs/research/2026-05-24-door-collision-session-end-handoff.md</c>.
|
||
///
|
||
/// <para>
|
||
/// SKIP if ACDREAM_DAT_DIR (or the default
|
||
/// <c>%USERPROFILE%\Documents\Asheron's Call</c>) is unavailable — keeps
|
||
/// CI green. Local developer runs always have it.
|
||
/// </para>
|
||
/// </summary>
|
||
public class DoorBugTrajectoryReplayTests
|
||
{
|
||
// ── Door geometry from live capture ───────────────────────────────
|
||
// [entity-source] id=0x000F4246 src=0x020019FF gfxObj=0x020019FF
|
||
// lb=0xA9B40029 shapes=cyl1+bsp1 state=0x00010008
|
||
// [bsp-test] obj=0x000F4246 gfx=0x010044B5 radius=1.975
|
||
// pos=(132.57,16.99,95.36)
|
||
// [cyl-test] obj=0x000F4246 radius=0.100 height=0.200
|
||
// pos=(132.56,17.11,94.10)
|
||
private const uint DoorEntityId = 0x000F4246u;
|
||
private const uint DoorGfxObjId = 0x010044B5u;
|
||
private const uint DoorClosedState = 0x00010008u; // PERSISTENT_PS | 0x8 (no ETHEREAL)
|
||
private const uint DoorLandblockId = 0xA9B40000u;
|
||
|
||
private static readonly Vector3 BspWorldPos = new(132.57f, 16.99f, 95.36f);
|
||
private const float BspRadius = 1.975f;
|
||
|
||
private static readonly Vector3 CylWorldPos = new(132.56f, 17.11f, 94.10f);
|
||
private const float CylRadius = 0.10f;
|
||
private const float CylHeight = 0.20f;
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Replay tick 13558 — the walkthrough. Player at (132.36, 16.81, 94)
|
||
/// indoor cell 0xA9B40150, runs NE to (132.43, 17.20, 94) crossing
|
||
/// the door's BSP Y-range [16.86, 17.12]. Live engine reports
|
||
/// <c>collisionNormalValid=false</c>, result.Position == target — sphere
|
||
/// walks through. The harness should reproduce the same null collision
|
||
/// IF the bug is upstream of BSP query (door not returned by
|
||
/// GetNearbyObjects from the indoor primary cell), OR fire a BSP
|
||
/// collision IF the harness's portal-reachable cell set includes the
|
||
/// door's outdoor cell when production's doesn't.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_DoorOffCenterWalkthrough_Tick13558()
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) return;
|
||
|
||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||
var captured = LoadCapturedRecord(r => r.Tick == 13558);
|
||
AssertCallMatchesCapture(engine, captured);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replay tick 22760 — the door BLOCKS. Player at (133.14, 18.02, 94)
|
||
/// outdoor cell 0xA9B40029, walks SW to (133.10, 17.60, 94). Live engine
|
||
/// reports collision with cn=(0, +1, 0) (+Y wall facing north, blocks
|
||
/// south motion). Sphere stopped at Y=18.018. This is the WORKING
|
||
/// case — the door's BSP correctly blocks when queried from the
|
||
/// outdoor primary cell. If the harness diverges here, the door
|
||
/// registration itself is wrong.
|
||
/// </summary>
|
||
[Fact]
|
||
public void LiveCompare_DoorBlocksFromOutside_Tick22760()
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) return;
|
||
|
||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||
var captured = LoadCapturedRecord(r => r.Tick == 22760);
|
||
AssertCallMatchesCapture(engine, captured);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Diagnostic dump: every relevant probe ON, replay tick 13558,
|
||
/// captured stdout shows what GetNearbyObjects / CellTransit /
|
||
/// BSPQuery actually did. Use this when the LiveCompare test FAILS
|
||
/// to see the engine's internal decisions on the failing tick.
|
||
/// Always passes (diagnostic-only).
|
||
/// </summary>
|
||
[Fact]
|
||
public void Diagnostic_Tick13558_DumpEngineInternals()
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) return;
|
||
|
||
PhysicsDiagnostics.ProbeResolveEnabled = true;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = true;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = true;
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
|
||
try
|
||
{
|
||
var (engine, _) = BuildEngineWithDoorFixture(datDir);
|
||
var captured = LoadCapturedRecord(r => r.Tick == 13558);
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore!);
|
||
|
||
Console.WriteLine("=== Replay tick 13558 (the walkthrough) ===");
|
||
var result = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"=== Harness: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
|
||
result.Position.X, result.Position.Y, result.Position.Z,
|
||
result.CollisionNormal.X, result.CollisionNormal.Y, result.CollisionNormal.Z,
|
||
result.CollisionNormalValid, result.IsOnGround, result.CellId));
|
||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"=== Live: pos=({0:F4},{1:F4},{2:F4}) cn=({3:F4},{4:F4},{5:F4}) cnValid={6} onGround={7} cell=0x{8:X8}",
|
||
captured.Result.Position.X, captured.Result.Position.Y, captured.Result.Position.Z,
|
||
captured.Result.CollisionNormal.X, captured.Result.CollisionNormal.Y, captured.Result.CollisionNormal.Z,
|
||
captured.Result.CollisionNormalValid, captured.Result.IsOnGround, captured.Result.CellId));
|
||
}
|
||
finally
|
||
{
|
||
PhysicsDiagnostics.ProbeResolveEnabled = false;
|
||
PhysicsDiagnostics.ProbeBuildingEnabled = false;
|
||
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
|
||
PhysicsDiagnostics.ProbePushBackEnabled = false;
|
||
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drive <see cref="CellTransit.FindTransitCellsSphere"/> directly with
|
||
/// cell 0xA9B40150 hydrated from the real dat and the sphere position
|
||
/// captured at tick 13558. Asserts <c>exitOutside</c> fires for the
|
||
/// 0xFFFF exit portal. If this PASSES, the cell-portal code is correct
|
||
/// in isolation and the production bug is upstream (cell not loaded
|
||
/// in cache, primary cell mis-classified, etc.). If it FAILS, the
|
||
/// portal traversal IS the bug.
|
||
/// </summary>
|
||
[Fact]
|
||
public void FindTransitCellsSphere_IndoorExitPortal_AddsOutsideForCapturedSpherePos()
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) return;
|
||
|
||
// ── 1. Hydrate cell 0xA9B40150 from the real dat ────────────
|
||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||
|
||
const uint CellId = 0xA9B40150u;
|
||
const uint EnvCellPrefix = 0x0D000000u;
|
||
|
||
var envCell = dats.Get<DatReaderWriter.DBObjs.EnvCell>(CellId);
|
||
Assert.NotNull(envCell);
|
||
|
||
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
|
||
EnvCellPrefix | envCell!.EnvironmentId);
|
||
Assert.NotNull(environment);
|
||
Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct));
|
||
Assert.NotNull(cellStruct);
|
||
|
||
// ── 2. Build the cell's worldTransform matching production
|
||
// (GameWindow.cs:5404-5406) ──────────────────────────────
|
||
var cellOriginWorld = envCell.Position.Origin;
|
||
var worldTransform =
|
||
Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||
Matrix4x4.CreateTranslation(cellOriginWorld);
|
||
|
||
// ── 3. Hydrate into CellPhysics via CacheCellStruct ─────────
|
||
var cache = new PhysicsDataCache();
|
||
cache.CacheCellStruct(CellId, envCell, cellStruct!, worldTransform);
|
||
|
||
var cellPhysics = cache.GetCellStruct(CellId);
|
||
Assert.NotNull(cellPhysics);
|
||
Assert.NotNull(cellPhysics!.Portals);
|
||
Assert.Contains(cellPhysics.Portals, p => p.OtherCellId == 0xFFFFu);
|
||
|
||
// ── 4. Captured sphere position at tick 13558 ───────────────
|
||
var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f);
|
||
const float sphereRadius = 0.48f;
|
||
|
||
// Confirm sphere is INSIDE cell 0x0150's BSP (sanity).
|
||
Assert.NotNull(cellPhysics.CellBSP);
|
||
var localCenter = Vector3.Transform(sphereWorld, cellPhysics.InverseWorldTransform);
|
||
|
||
// ── 5. Run FindTransitCellsSphere — does it fire exitOutside? ─
|
||
var candidates = new HashSet<uint>();
|
||
CellTransit.FindTransitCellsSphere(
|
||
cache, cellPhysics, CellId,
|
||
sphereWorld, sphereRadius,
|
||
candidates,
|
||
out bool exitOutside);
|
||
|
||
// Diagnostic — print the localCenter + each portal's
|
||
// sphere-vs-plane distance so we see what the test computed.
|
||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"localCenter=({0:F4},{1:F4},{2:F4}) radius={3:F4}",
|
||
localCenter.X, localCenter.Y, localCenter.Z, sphereRadius));
|
||
foreach (var portal in cellPhysics.Portals)
|
||
{
|
||
if (cellPhysics.PortalPolygons is null
|
||
|| !cellPhysics.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
|
||
continue;
|
||
float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
|
||
float rad = sphereRadius + 0.02f;
|
||
bool hit = dist > -rad && dist < rad;
|
||
Console.WriteLine(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
" portal otherCellId=0x{0:X4} polyId=0x{1:X4} n=({2:F4},{3:F4},{4:F4}) d={5:F4} dist={6:F4} rad={7:F4} hit={8}",
|
||
portal.OtherCellId, portal.PolygonId,
|
||
poly.Plane.Normal.X, poly.Plane.Normal.Y, poly.Plane.Normal.Z,
|
||
poly.Plane.D, dist, rad, hit));
|
||
}
|
||
|
||
Assert.True(exitOutside,
|
||
"Captured sphere at tick 13558 is straddling cell 0xA9B40150's " +
|
||
"exit portal (0xFFFF) plane. FindTransitCellsSphere should fire " +
|
||
"exitOutside, but did not. Candidates returned: " +
|
||
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
///
|
||
/// <para>
|
||
/// 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).
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// After the 180° entity rotation, the slab's local Y thickness axis
|
||
/// maps to world -Y. Slab spans world Y in approximately
|
||
/// [<see cref="DoorSpawnPos"/>.Y - 0.261, <see cref="DoorSpawnPos"/>.Y]
|
||
/// (entity Y minus 0 to entity Y minus 0.261 thickness). Two faces
|
||
/// matter:
|
||
/// <list type="bullet">
|
||
/// <item><b>Higher-Y face</b> (world Y ≈ entity.Y) has world
|
||
/// normal +Y. A sphere NORTH of the slab moving SOUTH (-Y)
|
||
/// hits this face. <c>cn</c> should be near (0, +1, 0).</item>
|
||
/// <item><b>Lower-Y face</b> (world Y ≈ entity.Y - 0.261) has world
|
||
/// normal -Y. A sphere SOUTH of the slab moving NORTH (+Y)
|
||
/// hits this face. <c>cn</c> should be near (0, -1, 0).</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Geometric finding (2026-05-25 evening) — pins the door geometry
|
||
/// math that explains why the "inside-out walkthrough" persists
|
||
/// after the cell-visibility fix.
|
||
///
|
||
/// <para>
|
||
/// The cottage door Setup 0x020019FF has:
|
||
/// <list type="bullet">
|
||
/// <item>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.</item>
|
||
/// <item>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.</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage
|
||
/// door spawn position):
|
||
/// <list type="bullet">
|
||
/// <item>Cylinder world Z range: [94.118, 94.318] — touches the
|
||
/// ground (sphere foot Z=94 to top Z=95.20 overlaps cyl Z up to 94.318).</item>
|
||
/// <item>Slab world Z range: [95.375, 97.865]. <b>The slab's BOTTOM
|
||
/// (95.375 m) is 0.175 m ABOVE the player's sphere top
|
||
/// (95.20 m).</b> The slab NEVER intersects the player's
|
||
/// body sphere vertically.</item>
|
||
/// </list>
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Implication: the door's only effective collision against a player
|
||
/// at floor level is the 0.10 m radius foot cylinder. The 1.93 m wide
|
||
/// slab is collision-irrelevant at sphere height — it's a LINTEL
|
||
/// (the door frame above), not a leaf collision. The user-reported
|
||
/// "off-center walkthrough" is the sphere walking AROUND the
|
||
/// 0.10 m foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m;
|
||
/// any sphere center >0.58 m from cylinder center passes freely).
|
||
/// The "body partially intersects door" is the rendered character model
|
||
/// occupying volume the door visual fills, but no collision body to
|
||
/// stop it because the slab is too high.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Next session: this is a DAT or registration issue, not a BSP query
|
||
/// bug. Options:
|
||
/// <list type="number">
|
||
/// <item>Verify retail actually USES this geometry — maybe the door
|
||
/// relies on the cottage CELL'S walls (cell 0x0150's
|
||
/// PhysicsPolygons) to enclose the doorway, and the door's
|
||
/// only collision is the foot cylinder + a leaf shape we're
|
||
/// missing.</item>
|
||
/// <item>Inspect parts 1+2 (the door LEAVES, GfxObj 0x010044B6) to
|
||
/// confirm they're truly visual-only or if we missed a
|
||
/// physics shape.</item>
|
||
/// <item>cdb attach to retail at a Holtburg cottage door — set a
|
||
/// breakpoint on CTransition::FindObjCollisions for the door
|
||
/// entity and inspect what shapes retail tests against.</item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public void Geometric_DoorSlabZRange_AbovePlayerSphereTop()
|
||
{
|
||
var datDir = ResolveDatDir();
|
||
if (datDir is null) return;
|
||
|
||
// Load the door setup + part 0's PlacementFrame.
|
||
DatReaderWriter.Types.Frame partFrame;
|
||
float slabLocalZMax;
|
||
using (var dats = new DatCollection(datDir, DatAccessType.Read))
|
||
{
|
||
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(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<DatReaderWriter.DBObjs.GfxObj>(DoorGfxObjId)!;
|
||
Assert.NotNull(gfx.PhysicsPolygons);
|
||
// Compute local AABB Z max from vertex array.
|
||
slabLocalZMax = float.MinValue;
|
||
foreach (var poly in gfx.PhysicsPolygons.Values)
|
||
{
|
||
foreach (ushort vid in poly.VertexIds)
|
||
{
|
||
if (gfx.VertexArray.Vertices.TryGetValue(vid, out var sv))
|
||
if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The door's entity is placed at world Z=94.1 in Holtburg (per
|
||
// captured spawn log). Slab local Z=0 origin offsets to:
|
||
float slabWorldZBottom = DoorSpawnPos.Z + partFrame.Origin.Z;
|
||
float slabWorldZTop = slabWorldZBottom + slabLocalZMax;
|
||
|
||
// The player has sphereHeight=1.20, sphereRadius=0.48. Sphere top
|
||
// in world = foot.Z + height - radius + radius = foot.Z + height
|
||
// (the top of the head sphere centered at foot.Z + height - radius).
|
||
const float SphereHeight = 1.20f;
|
||
const float PlayerFootZ = 94f; // standard Holtburg floor
|
||
float sphereTopZ = PlayerFootZ + SphereHeight;
|
||
|
||
// The crucial assertion: slab bottom is above sphere top.
|
||
Assert.True(slabWorldZBottom > sphereTopZ,
|
||
$"Door slab bottom ({slabWorldZBottom:F3}) should be ABOVE " +
|
||
$"player sphere top ({sphereTopZ:F3}). Gap = " +
|
||
$"{slabWorldZBottom - sphereTopZ:F3} m. This pins the geometric " +
|
||
$"fact that the slab does not collide with the player at floor " +
|
||
$"level — only the foot cylinder does. The inside-out 'walkthrough' " +
|
||
$"is the sphere passing around the cylinder, not through the slab.");
|
||
}
|
||
|
||
[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}.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<DatReaderWriter.DBObjs.GfxObj>(DoorGfxObjId);
|
||
Assert.NotNull(gfx);
|
||
cache.CacheGfxObj(DoorGfxObjId, gfx!);
|
||
|
||
setup = dats.Get<DatReaderWriter.DBObjs.Setup>(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<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
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<ShadowShape>(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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Run one <see cref="PhysicsEngine.ResolveWithTransition"/> call
|
||
/// against <paramref name="engine"/> at the given positions/cell,
|
||
/// returning the result + the body's post-call state.
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Direct test of <see cref="CellTransit.AddAllOutsideCells"/> with
|
||
/// the captured sphere position (132.36, 16.81, 94) and currentCellId
|
||
/// 0xA9B40150. Expects outdoor cell 0xA9B40029 (the door's cell) in
|
||
/// the result.
|
||
///
|
||
/// <para>
|
||
/// This is the suspected root cause of the bug: AddAllOutsideCells
|
||
/// computes <c>localX = worldSphereCenter.X - lbXf</c> where
|
||
/// <c>lbXf = ((cellId >> 24) & 0xFF) * 192</c>. For cellId
|
||
/// 0xA9B40150, lbXf = 0xA9 * 192 = 32448. If sphere coords are
|
||
/// LANDBLOCK-LOCAL (as the JSONL capture shows: x=132.36, NOT
|
||
/// 32580.36), the subtraction produces localX = -32316 → gridX = -1346
|
||
/// → early return → NO cells added → door invisible from indoor.
|
||
/// </para>
|
||
/// </summary>
|
||
[Fact]
|
||
public void AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell()
|
||
{
|
||
var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f);
|
||
const float sphereRadius = 0.48f;
|
||
const uint currentCellId = 0xA9B40150u;
|
||
|
||
var candidates = new HashSet<uint>();
|
||
CellTransit.AddAllOutsideCells(
|
||
sphereWorld, sphereRadius, currentCellId, candidates);
|
||
|
||
const uint expectedDoorCell = 0xA9B40029u;
|
||
Assert.True(candidates.Contains(expectedDoorCell),
|
||
$"AddAllOutsideCells with landblock-local sphere ({sphereWorld.X:F2}, " +
|
||
$"{sphereWorld.Y:F2}, {sphereWorld.Z:F2}) and indoor primary cell " +
|
||
$"0x{currentCellId:X8} should add outdoor cell 0x{expectedDoorCell:X8} " +
|
||
$"(where the door lives). Got: " +
|
||
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
|
||
}
|
||
|
||
// ── Engine + door fixture ─────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Build a fresh <see cref="PhysicsEngine"/> with:
|
||
/// <list type="bullet">
|
||
/// <item>Door GfxObj 0x010044B5 hydrated from the real dat
|
||
/// (mirrors <c>DoorSetupGfxObjInspectionTests</c>'s read pattern
|
||
/// via <see cref="DatCollection.Get{T}"/>).</item>
|
||
/// <item>A landblock 0xA9B40000 stub (terrain far below) so
|
||
/// <c>TryGetLandblockContext</c> succeeds at the door's XY.</item>
|
||
/// <item>Door registered via
|
||
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> at the
|
||
/// captured BSP world center — entity world pos = BSP world pos,
|
||
/// entity rot = identity, one BSP shape at local position zero.
|
||
/// This bypasses ShadowShapeBuilder.FromSetup; the test's
|
||
/// pure goal is to put a slab in the right world location and
|
||
/// see whether the engine sees it from the captured tick's
|
||
/// primary cell. cellScope=0u (default) mirrors production's
|
||
/// door registration at GameWindow.cs:3158-3167.</item>
|
||
/// </list>
|
||
///
|
||
/// <para>
|
||
/// No cell fixture is registered for the player's indoor cell
|
||
/// 0xA9B40150. Without one, <c>CellTransit.FindCellSet</c> can't
|
||
/// portal-walk from indoor → outdoor, so the door (in outdoor cell
|
||
/// 0xA9B40029) won't be reachable from the indoor primary. That's
|
||
/// the BUG'S ROOT IF the test reproduces the live cnValid=false at
|
||
/// tick 13558 — the indoor cell's portal graph is missing the
|
||
/// outdoor cell connection.
|
||
/// </para>
|
||
/// </summary>
|
||
private static (PhysicsEngine engine, PhysicsDataCache cache)
|
||
BuildEngineWithDoorFixture(string datDir)
|
||
{
|
||
var cache = new PhysicsDataCache();
|
||
var engine = new PhysicsEngine { DataCache = cache };
|
||
|
||
// 1. Hydrate door BSP from real dat (CacheGfxObj handles ResolvePolygons,
|
||
// BoundingSphere extraction, and visual AABB fallback).
|
||
using (var dats = new DatCollection(datDir, DatAccessType.Read))
|
||
{
|
||
var gfx = dats.Get<GfxObj>(DoorGfxObjId);
|
||
Assert.NotNull(gfx);
|
||
Assert.NotNull(gfx!.PhysicsBSP);
|
||
Assert.NotNull(gfx.PhysicsBSP!.Root);
|
||
cache.CacheGfxObj(DoorGfxObjId, gfx);
|
||
}
|
||
Assert.NotNull(cache.GetGfxObj(DoorGfxObjId));
|
||
|
||
// 2. Stub landblock so TryGetLandblockContext succeeds at the door XY.
|
||
var heights = new byte[81];
|
||
var heightTable = new float[256];
|
||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below Z=94
|
||
var stubTerrain = new TerrainSurface(heights, heightTable);
|
||
engine.AddLandblock(
|
||
landblockId: DoorLandblockId,
|
||
terrain: stubTerrain,
|
||
cells: Array.Empty<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
// 3. Register the door — BSP shape only (the captured bug attaches
|
||
// to the BSP slab; the cylinder is a small foot collider).
|
||
// entityWorldPos = BSP world pos so LocalPos=0 puts the BSP at
|
||
// the captured center. cellScope=0u mirrors production.
|
||
var bspShape = new ShadowShape(
|
||
GfxObjId: DoorGfxObjId,
|
||
LocalPosition: Vector3.Zero,
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1f,
|
||
CollisionType: ShadowCollisionType.BSP,
|
||
Radius: BspRadius,
|
||
CylHeight: 0f);
|
||
|
||
var cylShape = new ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin
|
||
LocalRotation: Quaternion.Identity,
|
||
Scale: 1f,
|
||
CollisionType: ShadowCollisionType.Cylinder,
|
||
Radius: CylRadius,
|
||
CylHeight: CylHeight);
|
||
|
||
engine.ShadowObjects.RegisterMultiPart(
|
||
entityId: DoorEntityId,
|
||
entityWorldPos: BspWorldPos,
|
||
entityWorldRot: Quaternion.Identity,
|
||
shapes: new[] { cylShape, bspShape },
|
||
state: DoorClosedState,
|
||
flags: EntityCollisionFlags.None,
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f,
|
||
landblockId: DoorLandblockId);
|
||
|
||
return (engine, cache);
|
||
}
|
||
|
||
// ── Capture loading + comparison (mirrors CellarUpTrajectoryReplayTests) ──
|
||
|
||
private static string? ResolveDatDir()
|
||
{
|
||
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
|
||
"Documents", "Asheron's Call");
|
||
return Directory.Exists(datDir) ? datDir : null;
|
||
}
|
||
|
||
private static ResolveCaptureRecord LoadCapturedRecord(
|
||
Func<ResolveCaptureRecord, bool> predicate)
|
||
{
|
||
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
|
||
Assert.True(File.Exists(path),
|
||
$"Door-bug live-capture fixture missing: {path}.");
|
||
|
||
foreach (var line in File.ReadLines(path))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(line))
|
||
continue;
|
||
|
||
var record = System.Text.Json.JsonSerializer
|
||
.Deserialize<ResolveCaptureRecord>(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
|
||
if (predicate(record))
|
||
return record;
|
||
}
|
||
|
||
throw new Xunit.Sdk.XunitException(
|
||
"No captured record matched the predicate. Update the fixture.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replays one captured ResolveWithTransition call against
|
||
/// <paramref name="engine"/>, seeded with bodyBefore, and reports
|
||
/// the first per-field divergence between live and harness.
|
||
/// </summary>
|
||
private static void AssertCallMatchesCapture(
|
||
PhysicsEngine engine,
|
||
ResolveCaptureRecord captured)
|
||
{
|
||
Assert.NotNull(captured.BodyBefore);
|
||
Assert.NotNull(captured.BodyAfter);
|
||
|
||
var body = SeedBodyFromSnapshot(captured.BodyBefore);
|
||
|
||
var harnessResult = engine.ResolveWithTransition(
|
||
currentPos: captured.Input.CurrentPos,
|
||
targetPos: captured.Input.TargetPos,
|
||
cellId: captured.Input.CellId,
|
||
sphereRadius: captured.Input.SphereRadius,
|
||
sphereHeight: captured.Input.SphereHeight,
|
||
stepUpHeight: captured.Input.StepUpHeight,
|
||
stepDownHeight: captured.Input.StepDownHeight,
|
||
isOnGround: captured.Input.IsOnGround,
|
||
body: body,
|
||
moverFlags: (ObjectInfoState)captured.Input.MoverFlags,
|
||
movingEntityId: captured.Input.MovingEntityId);
|
||
|
||
var divergences = new List<string>();
|
||
|
||
AddIfDifferent(divergences, "Result.Position",
|
||
captured.Result.Position, harnessResult.Position);
|
||
AddIfDifferent(divergences, "Result.CellId",
|
||
$"0x{captured.Result.CellId:X8}", $"0x{harnessResult.CellId:X8}");
|
||
AddIfDifferent(divergences, "Result.IsOnGround",
|
||
captured.Result.IsOnGround, harnessResult.IsOnGround);
|
||
AddIfDifferent(divergences, "Result.CollisionNormalValid",
|
||
captured.Result.CollisionNormalValid, harnessResult.CollisionNormalValid);
|
||
if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid)
|
||
{
|
||
AddIfDifferent(divergences, "Result.CollisionNormal",
|
||
captured.Result.CollisionNormal, harnessResult.CollisionNormal);
|
||
}
|
||
|
||
AddIfDifferent(divergences, "BodyAfter.Position",
|
||
captured.BodyAfter.Position, body.Position);
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid",
|
||
captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid);
|
||
if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid)
|
||
{
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal",
|
||
captured.BodyAfter.ContactPlane.Normal, body.ContactPlane.Normal);
|
||
AddIfDifferent(divergences, "BodyAfter.ContactPlane.D",
|
||
captured.BodyAfter.ContactPlane.D, body.ContactPlane.D);
|
||
}
|
||
AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid",
|
||
captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid);
|
||
AddIfDifferent(divergences, "BodyAfter.TransientState",
|
||
$"0x{captured.BodyAfter.TransientState:X}",
|
||
$"0x{(uint)body.TransientState:X}");
|
||
|
||
if (divergences.Count > 0)
|
||
{
|
||
string summary = string.Join("\n * ", divergences);
|
||
string header = string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"Door-bug harness replay of captured tick {0} diverges from live engine. " +
|
||
"Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " +
|
||
"cellId=0x{7:X8} isOnGround={8}",
|
||
captured.Tick,
|
||
captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z,
|
||
captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z,
|
||
captured.Input.CellId, captured.Input.IsOnGround);
|
||
throw new Xunit.Sdk.XunitException(
|
||
header + "\nDivergences (live -> harness):\n * " + summary);
|
||
}
|
||
}
|
||
|
||
private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new()
|
||
{
|
||
Position = snap.Position,
|
||
Orientation = snap.Orientation,
|
||
Velocity = snap.Velocity,
|
||
Acceleration = snap.Acceleration,
|
||
Omega = snap.Omega,
|
||
GroundNormal = snap.GroundNormal,
|
||
SlidingNormal = snap.SlidingNormal,
|
||
ContactPlaneValid = snap.ContactPlaneValid,
|
||
ContactPlane = snap.ContactPlane,
|
||
ContactPlaneCellId = snap.ContactPlaneCellId,
|
||
ContactPlaneIsWater = snap.ContactPlaneIsWater,
|
||
WalkablePolygonValid = snap.WalkablePolygonValid,
|
||
WalkablePlane = snap.WalkablePlane,
|
||
WalkableVertices = snap.WalkableVertices,
|
||
WalkableUp = snap.WalkableUp,
|
||
Elasticity = snap.Elasticity,
|
||
Friction = snap.Friction,
|
||
State = (PhysicsStateFlags)snap.State,
|
||
TransientState = (TransientStateFlags)snap.TransientState,
|
||
LastUpdateTime = snap.LastUpdateTime,
|
||
};
|
||
|
||
private static void AddIfDifferent<T>(
|
||
List<string> divergences, string name, T live, T harness)
|
||
{
|
||
if (EqualityComparer<T>.Default.Equals(live, harness)) return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live={1} harness={2}", name, live, harness));
|
||
}
|
||
|
||
private static void AddIfDifferent(
|
||
List<string> divergences, string name, Vector3 live, Vector3 harness)
|
||
{
|
||
if (Vector3.DistanceSquared(live, harness) < 1e-6f) return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})",
|
||
name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z));
|
||
}
|
||
|
||
private static void AddIfDifferent(
|
||
List<string> divergences, string name, float live, float harness)
|
||
{
|
||
if (MathF.Abs(live - harness) < 1e-3f) return;
|
||
divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}: live={1:F4} harness={2:F4}", name, live, harness));
|
||
}
|
||
|
||
private static string FixtureDir =>
|
||
Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
|
||
"Fixtures", "door-bug");
|
||
|
||
private static string SolutionRoot()
|
||
{
|
||
var dir = AppContext.BaseDirectory;
|
||
while (!string.IsNullOrEmpty(dir))
|
||
{
|
||
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
||
return dir;
|
||
dir = Path.GetDirectoryName(dir);
|
||
}
|
||
throw new InvalidOperationException(
|
||
"Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
|
||
}
|
||
}
|