acdream/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs
Erik 2a890e6bde test(phys): A6.P5 RED — BFS from indoor cell doesn't reach door outdoor cell
Adds CellTransitTests with two A6P5_* unit tests:
  A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell  (RED — the bug)
  A6P5_BuildCellSetFromAlcove_AlsoReachesDoorOutdoorCell   (passes today)

The RED test reproduces the over-penetration tick's cellSet build:
sphere at (132.594, 16.350) in cell 0xA9B4013F, BFS portal-walks to
0xA9B40150 (alcove) but does NOT add the door's outdoor cell 0xA9B40029.
Pre-fix cellSet: 0xA9B4013F, 0xA9B40150, 0xA9B4014C — no outdoor cells.
Sphere wasn't straddling 0xA9B40150's exit portal so exitOutside stayed
false.

Also removes the 3 A6P5_* replay tests added to
DoorBugTrajectoryReplayTests in the previous commit (3253d84's
follow-up). Those tests didn't reproduce the bug — the harness's
BuildFaithfulDoorEngine has no cell fixtures, so cellSet returned empty
and GetNearbyObjects treated it as "no filter" → door always visible
→ over-penetration test trivially passed for the wrong reason. The
CellTransitTests version pins the bug at the BFS layer directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:51:33 +02:00

1243 lines
58 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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.
///
/// <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>
/// AABB measured: <c>min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255)</c>.
/// 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:
/// </para>
///
/// <list type="bullet">
/// <item>X: [131.635, 133.560] (1.925 m wide; after 180° entity Z rot)</item>
/// <item>Y: [16.848, 17.109] (0.261 m thick)</item>
/// <item><b>Z: [94.139, 96.630]</b> (2.491 m tall, bottom JUST above floor)</item>
/// </list>
///
/// <para>
/// 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. <b>The slab DOES overlap the sphere in Z</b> over
/// world Z range [94.139, 95.20]. The slab is at sphere height,
/// not above it.
/// </para>
///
/// <para>
/// 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.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
/// <summary>
/// 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.
///
/// <para>
/// 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.
/// </para>
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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<DatReaderWriter.DBObjs.EnvCell>(AlcoveCellId);
Assert.NotNull(envCell);
var environment = dats.Get<DatReaderWriter.DBObjs.Environment>(
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.");
}
/// <summary>
/// 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.
/// </summary>
[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<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);
// 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}.");
}
/// <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 &gt;&gt; 24) &amp; 0xFF) * 192</c>. For cellId
/// 0xA9B40150, lbXf = 0xA9 * 192 = 32448. If sphere coords are
/// LANDBLOCK-LOCAL (as the JSONL capture shows: x=132.36, NOT
/// 32580.36), the subtraction produces localX = -32316 → gridX = -1346
/// → early return → NO cells added → door invisible from indoor.
/// </para>
/// </summary>
[Fact]
public void AddAllOutsideCells_LandblockLocalSphere_AddsDoorOutdoorCell()
{
var sphereWorld = new Vector3(132.3603f, 16.8113f, 94.0000f);
const float sphereRadius = 0.48f;
const uint currentCellId = 0xA9B40150u;
var candidates = new HashSet<uint>();
CellTransit.AddAllOutsideCells(
sphereWorld, sphereRadius, currentCellId, candidates);
const uint expectedDoorCell = 0xA9B40029u;
Assert.True(candidates.Contains(expectedDoorCell),
$"AddAllOutsideCells with landblock-local sphere ({sphereWorld.X:F2}, " +
$"{sphereWorld.Y:F2}, {sphereWorld.Z:F2}) and indoor primary cell " +
$"0x{currentCellId:X8} should add outdoor cell 0x{expectedDoorCell:X8} " +
$"(where the door lives). Got: " +
string.Join(",", candidates.Select(c => $"0x{c:X8}")));
}
// ── Engine + door fixture ─────────────────────────────────────────
/// <summary>
/// Build a fresh <see cref="PhysicsEngine"/> with:
/// <list type="bullet">
/// <item>Door GfxObj 0x010044B5 hydrated from the real dat
/// (mirrors <c>DoorSetupGfxObjInspectionTests</c>'s read pattern
/// via <see cref="DatCollection.Get{T}"/>).</item>
/// <item>A landblock 0xA9B40000 stub (terrain far below) so
/// <c>TryGetLandblockContext</c> succeeds at the door's XY.</item>
/// <item>Door registered via
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> at the
/// captured BSP world center — entity world pos = BSP world pos,
/// entity rot = identity, one BSP shape at local position zero.
/// This bypasses ShadowShapeBuilder.FromSetup; the test's
/// pure goal is to put a slab in the right world location and
/// see whether the engine sees it from the captured tick's
/// primary cell. cellScope=0u (default) mirrors production's
/// door registration at GameWindow.cs:3158-3167.</item>
/// </list>
///
/// <para>
/// No cell fixture is registered for the player's indoor cell
/// 0xA9B40150. Without one, <c>CellTransit.FindCellSet</c> can't
/// portal-walk from indoor → outdoor, so the door (in outdoor cell
/// 0xA9B40029) won't be reachable from the indoor primary. That's
/// the BUG'S ROOT IF the test reproduces the live cnValid=false at
/// tick 13558 — the indoor cell's portal graph is missing the
/// outdoor cell connection.
/// </para>
/// </summary>
private static (PhysicsEngine engine, PhysicsDataCache cache)
BuildEngineWithDoorFixture(string datDir)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// 1. Hydrate door BSP from real dat (CacheGfxObj handles ResolvePolygons,
// BoundingSphere extraction, and visual AABB fallback).
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var gfx = dats.Get<GfxObj>(DoorGfxObjId);
Assert.NotNull(gfx);
Assert.NotNull(gfx!.PhysicsBSP);
Assert.NotNull(gfx.PhysicsBSP!.Root);
cache.CacheGfxObj(DoorGfxObjId, gfx);
}
Assert.NotNull(cache.GetGfxObj(DoorGfxObjId));
// 2. Stub landblock so TryGetLandblockContext succeeds at the door XY.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f; // far below Z=94
var stubTerrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(
landblockId: DoorLandblockId,
terrain: stubTerrain,
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// 3. Register the door — BSP shape only (the captured bug attaches
// to the BSP slab; the cylinder is a small foot collider).
// entityWorldPos = BSP world pos so LocalPos=0 puts the BSP at
// the captured center. cellScope=0u mirrors production.
var bspShape = new ShadowShape(
GfxObjId: DoorGfxObjId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.BSP,
Radius: BspRadius,
CylHeight: 0f);
var cylShape = new ShadowShape(
GfxObjId: 0u,
LocalPosition: CylWorldPos - BspWorldPos, // express cyl relative to entity origin
LocalRotation: Quaternion.Identity,
Scale: 1f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: CylRadius,
CylHeight: CylHeight);
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,
entityWorldPos: BspWorldPos,
entityWorldRot: Quaternion.Identity,
shapes: new[] { cylShape, bspShape },
state: DoorClosedState,
flags: EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: DoorLandblockId);
return (engine, cache);
}
// ── Capture loading + comparison (mirrors CellarUpTrajectoryReplayTests) ──
private static string? ResolveDatDir()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(datDir) ? datDir : null;
}
private static ResolveCaptureRecord LoadCapturedRecord(
Func<ResolveCaptureRecord, bool> predicate)
{
var path = Path.Combine(FixtureDir, "live-capture.jsonl");
Assert.True(File.Exists(path),
$"Door-bug live-capture fixture missing: {path}.");
foreach (var line in File.ReadLines(path))
{
if (string.IsNullOrWhiteSpace(line))
continue;
var record = System.Text.Json.JsonSerializer
.Deserialize<ResolveCaptureRecord>(line, CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
if (predicate(record))
return record;
}
throw new Xunit.Sdk.XunitException(
"No captured record matched the predicate. Update the fixture.");
}
/// <summary>
/// A6.P5 (2026-05-25) — loads one of the three fixed records from
/// <c>over-penetration-capture.jsonl</c> by index:
/// <list type="bullet">
/// <item>0 — the over-penetration tick (cell-crossing 0xA9B4013F → 0xA9B40150)</item>
/// <item>1 — stuck-position hit=yes variant (door fired)</item>
/// <item>2 — stuck-position hit=no variant (door invisible — bug case)</item>
/// </list>
/// </summary>
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<ResolveCaptureRecord>(raw,
CellarUpTrajectoryReplayTests.CaptureJsonOptions)!;
}
/// <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);
}
}