using System; using System.Collections.Generic; using System.IO; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Options; using Xunit; using Xunit.Abstractions; using Env = System.Environment; using Plane = System.Numerics.Plane; namespace AcDream.Core.Tests.Physics; /// /// A6.P4 door fix (2026-05-24) — apparatus test. Loads door Setup /// 0x020019FF + GfxObj 0x010044B5 (the collision-bearing /// frame/slab part — see /// ) from the real dat, /// registers a synthetic door entity at a known world position via /// , and sweeps a /// player sphere into the door from multiple angles. /// /// /// Purpose: with the dat inspection having confirmed that /// 0x010044B5 has a complete 1.9 × 0.26 × 2.5 m door-slab /// PhysicsBSP, this test isolates whether our /// + the /// registration path actually fires /// collisions against that BSP. If it does, the live Holtburg door /// bug is somewhere DOWNSTREAM (live registration race, entity /// rotation, cell scoping). If it doesn't, the bug is here in /// the integration we can fix with confidence. /// /// /// /// The test skips gracefully when the dat directory isn't present so /// CI stays green. Local developer machines always have it. /// /// public class DoorCollisionApparatusTests { private readonly ITestOutputHelper _out; public DoorCollisionApparatusTests(ITestOutputHelper output) => _out = output; private const uint DoorSetupId = 0x020019FFu; private const uint DoorFrameGfxObjId = 0x010044B5u; private const uint DoorEntityId = 0xF424Fu; private const uint TestLandblockId = 0xA9B40000u; // Cell (0, 0) in landblock 0xA9B40000 — outdoor 24-m cell, low byte // = (0 * 8) + 0 + 1 = 1. private const uint TestCellId = TestLandblockId | 0x0001u; private const float SphereRadius = 0.48f; // ~retail player capsule radius private const float SphereHeight = 1.20f; private const float StepUpHeight = 0.60f; private const float StepDownHeight = 0.04f; /// /// Walk the sphere toward the closed door, dead-center, from /// the door's -Y front face approach. Expect a CollisionNormalValid /// = true AND the sphere stops before passing through the door. /// [Fact] public void Apparatus_DeadCenter_FrontApproach_BlocksOnBSP() { if (!TryBuildScenario(out var ctx)) return; // Door at world (12, 12, 0). The frame's BSP slab extends // entity-local Y∈[-0.009, 0.252] (poly Y range plus frame[0] offset). // Approach from -Y at sphere Y=11 (1 m before the front face), // walk +Y at 0.10 m/tick. var start = new Vector3(12f, 11f, 0.5f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, normal, ticks) = SweepUntilBlocked( ctx.engine, start, perTick, maxTicks: 30); _out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " + $"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})"); Assert.True(blocked, $"Expected collision before passing through the door. Sphere reached " + $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " + $"without firing CollisionNormalValid."); Assert.True(finalPos.Y < 12.0f, $"Sphere should stop before the door's front face (Y ≈ 11.99). " + $"Got Y={finalPos.Y:F3}"); } /// /// 50 cm off-center: the small Sphere shape (r=0.10) can't catch /// this, but the BSP slab (1.9 m wide) MUST. This is the live /// regression — pre-fix the user can walk around the cylinder. /// [Fact] public void Apparatus_50cmOffCenter_FrontApproach_BlocksOnBSP() { if (!TryBuildScenario(out var ctx)) return; // Same setup, but start 0.5 m off-center in X. Slab X range // is approximately [11.05, 12.97] (frame[0].X offset = -0.006). var start = new Vector3(12.5f, 11f, 0.5f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, normal, ticks) = SweepUntilBlocked( ctx.engine, start, perTick, maxTicks: 30); _out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " + $"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})"); Assert.True(blocked, $"Expected BSP collision off-center. Sphere reached " + $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after {ticks} ticks " + $"without firing CollisionNormalValid."); Assert.True(finalPos.Y < 12.0f, $"Sphere should stop before door front face. Got Y={finalPos.Y:F3}"); } /// /// Reverse approach: walk from the door's +Y back side toward -Y. /// SidesType=Landblock polys are two-sided, so this must also block. /// This pins the live "walking out from inside passes through" bug /// in its simplest geometric form. /// [Fact] public void Apparatus_DeadCenter_BackApproach_BlocksOnBSP() { if (!TryBuildScenario(out var ctx)) return; // Start past the door at Y=13, walk back toward -Y. var start = new Vector3(12f, 13f, 0.5f); var perTick = new Vector3(0f, -0.10f, 0f); var (blocked, finalPos, normal, ticks) = SweepUntilBlocked( ctx.engine, start, perTick, maxTicks: 30); _out.WriteLine($"Final pos = ({finalPos.X:F3}, {finalPos.Y:F3}, {finalPos.Z:F3}) " + $"after {ticks} ticks; blocked={blocked} normal=({normal.X:F3},{normal.Y:F3},{normal.Z:F3})"); Assert.True(blocked, $"Expected BSP collision from back side (two-sided Landblock poly). " + $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) " + $"after {ticks} ticks."); Assert.True(finalPos.Y > 12.30f, $"Sphere should stop after the door's back face (Y ≈ 12.25). " + $"Got Y={finalPos.Y:F3}"); } /// /// Diagnostic dump: drive 5 ticks with probes on, just to see /// what the engine reports per-tick. Useful when the assertion /// tests above fail — the diagnostic test shows the FULL log /// without yielding a green/red verdict. /// [Fact] public void Apparatus_DiagnosticDump_FrontApproach() { if (!TryBuildScenario(out var ctx)) return; PhysicsDiagnostics.ProbeResolveEnabled = true; PhysicsDiagnostics.ProbeBuildingEnabled = true; try { var pos = new Vector3(12f, 11f, 0.5f); var perTick = new Vector3(0f, 0.10f, 0f); uint cellId = TestCellId; bool isOnGround = false; for (int tick = 0; tick < 8; tick++) { Vector3 target = pos + perTick; var result = ctx.engine.ResolveWithTransition( pos, target, cellId, SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, isOnGround, body: null, moverFlags: ObjectInfoState.IsPlayer, movingEntityId: 0); _out.WriteLine($"tick={tick} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) → " + $"({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) " + $"hit={result.CollisionNormalValid} " + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})"); pos = result.Position; cellId = result.CellId; isOnGround = result.IsOnGround; } Assert.True(true, "Diagnostic test — always passes; check stdout."); } finally { PhysicsDiagnostics.ProbeResolveEnabled = false; PhysicsDiagnostics.ProbeBuildingEnabled = false; } } /// /// Reproduces the LIVE bug: a grounded player (isOnGround=true with /// seeded ContactPlane) walking off-center toward a closed door /// passes through. The path-difference test: this hits Path 5 /// (Contact branch + StepSphereUp), while the other apparatus /// tests above hit Path 6 (Default). If Path 5 incorrectly /// declares step-up success the BSP collision returns OK and the /// sphere walks through — exactly what the user reports in the /// live Holtburg session 2026-05-24. /// [Fact] public void Apparatus_Grounded_50cmOffCenter_FrontApproach_DocumentsBug() { if (!TryBuildScenario(out var ctx)) return; PhysicsDiagnostics.ProbeResolveEnabled = true; PhysicsDiagnostics.ProbeBuildingEnabled = true; Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); // Synthetic floor plane at Z = 0 so the grounded sphere has a // walkable plane to rest on. Sphere foot center starts at Z=radius // = 0.48, head at 0.48 + 1.20 = 1.68. var floorPlane = new Plane(0f, 0f, 1f, 0f); // z = 0 plane var floorVerts = new[] { new Vector3( 0f, 0f, 0f), new Vector3(24f, 0f, 0f), new Vector3(24f, 24f, 0f), new Vector3( 0f, 24f, 0f), }; var body = new PhysicsBody { Position = new Vector3(12.5f, 11f, 0.48f), Orientation = Quaternion.Identity, ContactPlaneValid = true, ContactPlane = floorPlane, ContactPlaneCellId = TestCellId, WalkablePolygonValid = true, WalkablePlane = floorPlane, WalkableVertices = floorVerts, WalkableUp = Vector3.UnitZ, TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, }; var perTick = new Vector3(0f, 0.10f, 0f); Vector3 pos = body.Position; uint cellId = TestCellId; bool isOnGround = true; bool blocked = false; Vector3 lastNormal = Vector3.Zero; int ticks = 0; for (int tick = 0; tick < 30; tick++) { Vector3 target = pos + perTick; var result = ctx.engine.ResolveWithTransition( pos, target, cellId, SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, isOnGround, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0); ticks = tick; body.Position = result.Position; pos = result.Position; cellId = result.CellId; isOnGround = result.IsOnGround; lastNormal = result.CollisionNormal; if (result.CollisionNormalValid) { blocked = true; _out.WriteLine($"Tick {tick}: BLOCKED at pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) normal=({lastNormal.X:F3},{lastNormal.Y:F3},{lastNormal.Z:F3})"); break; } } _out.WriteLine($"Final pos = ({pos.X:F3}, {pos.Y:F3}, {pos.Z:F3}) after {ticks + 1} ticks; blocked={blocked}"); _out.WriteLine($"Grounded={isOnGround}"); // EXPECTED FAILURE (documents-the-bug): the grounded sphere walks // straight through, reaching the far side at Y > 12.30. When the // fix lands, flip this to Assert.True(blocked) — same shape as // the Path-6 apparatus tests above. PhysicsDiagnostics.ProbeResolveEnabled = false; PhysicsDiagnostics.ProbeBuildingEnabled = false; Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); Assert.True(pos.Y > 12.30f, $"This test documents the production bug. If this is failing " + $"because the sphere now blocks, the door fix worked — flip " + $"the assertion to Assert.True(blocked) and Assert.True(pos.Y < 12.0f). " + $"Current pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) blocked={blocked}"); } // ─────────────────────────────────────────────────────────────── // Apparatus setup // ─────────────────────────────────────────────────────────────── private sealed record Context( PhysicsEngine engine, PhysicsDataCache cache, Setup setup, IReadOnlyList registeredShapes); /// /// Build the engine + cache + landblock + registered door entity. /// Returns false if the dat directory is missing (test skips). /// private bool TryBuildScenario(out Context ctx) { ctx = default!; var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: dat directory not found at {datDir}"); return false; } using var dats = new DatCollection(datDir, DatAccessType.Read); // Load + cache the door's collision-bearing part. var doorFrameGfx = dats.Get(DoorFrameGfxObjId); Assert.NotNull(doorFrameGfx); var cache = new PhysicsDataCache(); cache.CacheGfxObj(DoorFrameGfxObjId, doorFrameGfx!); Assert.NotNull(cache.GetGfxObj(DoorFrameGfxObjId)); var engine = new PhysicsEngine { DataCache = cache }; // Stub landblock at world (0, 0) so TryGetLandblockContext succeeds // for player XY in the [0, 192) range. Flat far-below terrain so it // doesn't interact with the door collision. var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = -1000f; engine.AddLandblock( landblockId: TestLandblockId, terrain: new TerrainSurface(heights, heightTable), cells: Array.Empty(), portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Load door setup, build shapes via the production builder. var setup = dats.Get(DoorSetupId); Assert.NotNull(setup); var shapes = ShadowShapeBuilder.FromSetup( setup!, entScale: 1.0f, hasPhysicsBsp: id => cache.GetGfxObj(id)?.BSP?.Root is not null); _out.WriteLine($"Shapes from FromSetup: {shapes.Count} entries"); foreach (var s in shapes) { _out.WriteLine($" type={s.CollisionType} gfxObj=0x{s.GfxObjId:X8} " + $"localPos=({s.LocalPosition.X:F3},{s.LocalPosition.Y:F3},{s.LocalPosition.Z:F3}) " + $"radius={s.Radius:F3} cylH={s.CylHeight:F3}"); } // Register the door at world (12, 12, 0). That's cell (0,0) of // landblock 0xA9B40000. Vector3 doorWorldPos = new(12f, 12f, 0f); Quaternion doorWorldRot = Quaternion.Identity; const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | HasDefaultScript (typical) engine.ShadowObjects.RegisterMultiPart( entityId: DoorEntityId, entityWorldPos: doorWorldPos, entityWorldRot: doorWorldRot, shapes: shapes, state: doorState, flags: EntityCollisionFlags.None, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId); ctx = new Context(engine, cache, setup!, shapes); return true; } /// /// Drive a sphere from by /// each tick until either /// CollisionNormalValid fires or /// elapse. Returns the outcome + final position. /// private (bool blocked, Vector3 finalPos, Vector3 normal, int ticks) SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks) { Vector3 pos = start; uint cellId = TestCellId; bool isOnGround = false; Vector3 lastNormal = Vector3.Zero; for (int tick = 0; tick < maxTicks; tick++) { Vector3 target = pos + perTick; var result = engine.ResolveWithTransition( pos, target, cellId, SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, isOnGround, body: null, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0); if (result.CollisionNormalValid) return (true, result.Position, result.CollisionNormal, tick); pos = result.Position; cellId = result.CellId; isOnGround = result.IsOnGround; lastNormal = result.CollisionNormal; } return (false, pos, lastNormal, maxTicks); } }