using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// Unit tests for the obstruction_ethereal mechanism ported verbatim /// from retail in Task 3 of the collision-inclusion phase. /// /// /// Retail oracle: CPhysicsObj::FindObjCollisions @ 0x0050f050 /// (acclient_2013_pseudo_c.txt:276782): /// /// pc:276782 -- Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND /// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip. /// pc:276806 -- ETHEREAL-alone sets sphere_path.obstruction_ethereal = 1 /// and continues to the shape dispatch. /// pc:276989 -- After the per-object shape test, retail clears /// obstruction_ethereal = 0. /// /// /// /// /// Consume site 1: BSPTREE::find_collisions @ 0x0053a496 /// (acclient_2013_pseudo_c.txt:323742): Path 1 fires when /// insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0, /// routing to sphere_intersects_solid (passable-ethereal) instead /// of the blocking paths. /// /// /// /// Consume site 2: CSphere::intersects_sphere @ 0x00537ae4 /// (acclient_2013_pseudo_c.txt:321692): The ethereal branch performs /// a proximity check only (no push-back, no COLLIDED return). All code /// paths in the branch are void returns -- the player is fully passable. /// /// /// /// Consume site 3: CCylSphere::intersects_sphere @ 0x0053b4a0 /// (acclient_2013_pseudo_c.txt:324573): Same pattern -- the ethereal /// branch calls collides_with_sphere for an overlap check only, all /// returns are void, no COLLIDED result -- the player is fully passable. /// /// /// /// Task 3 follow-up (2026-06-24): the Sphere and Cylinder consume sites /// are now implemented in SphereCollision and CylinderCollision /// in TransitionTypes.cs. The tests below verify: /// /// Ethereal-alone Cylinder target -> passable (no CollisionNormalValid). /// Ethereal-alone Sphere target -> passable (no CollisionNormalValid). /// Non-ethereal Cylinder target -> still blocks (regression guard). /// Non-ethereal Sphere target -> still blocks (regression guard). /// /// /// /// /// Layer-2 BSP override (D4+W1, 2026-06-24): retail /// CPhysicsObj::FindObjCollisions pc:276961-276989 force-resets /// a Collided result from BSP Path-1 to OK when the target is ETHEREAL and /// NOT STATIC. Tests for this Layer-2 path cover the three sub-cases: /// ethereal non-static (passable), ethereal static (still blocks), /// and non-ethereal (still blocks -- regression guard). /// /// /// /// Retail divergence register: row AD-7 retired in the original Task 3 commit -- /// the ETHEREAL-alone shim is replaced by the faithful port. /// /// public class ObstructionEtherealTests { private const uint ETHEREAL_PS = 0x4u; private const uint IGNORE_COLLISIONS_PS = 0x10u; // Gate-1 tests: ShouldSkip dual-bit contract [Fact] public void ShouldSkip_BothBits_InstantSkip() { // ETHEREAL | IGNORE_COLLISIONS together -> instant-skip (Gate 1 fires). // Retail pc:276782: `if ((state & 4) AND (state & 0x10)) return 1`. Assert.True(CollisionExemption.ShouldSkip( targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS, targetFlags: EntityCollisionFlags.None, moverState: ObjectInfoState.IsPlayer)); } [Fact] public void ShouldSkip_EtherealAlone_NotInstantSkip() { // ETHEREAL alone (0x4) must NOT instant-skip -- retail takes the // obstruction_ethereal branch instead. The old AD-7 shim is retired. // Retail pc:276782: only the COMBINED gate returns early; ETHEREAL-alone // falls through to set obstruction_ethereal and run the shape test. Assert.False(CollisionExemption.ShouldSkip( targetState: ETHEREAL_PS, targetFlags: EntityCollisionFlags.None, moverState: ObjectInfoState.IsPlayer)); } [Fact] public void ShouldSkip_IgnoreCollisionsAlone_NotSkipped() { // IGNORE_COLLISIONS alone (0x10) without ETHEREAL -> not instant-skipped; // falls through to shape dispatch (no obstruction_ethereal set either). Assert.False(CollisionExemption.ShouldSkip( targetState: IGNORE_COLLISIONS_PS, targetFlags: EntityCollisionFlags.None, moverState: ObjectInfoState.IsPlayer)); } // SpherePath.ObstructionEthereal field [Fact] public void SpherePath_HasObstructionEtherealField() { // Compile-time check that the field was added to SpherePath. var sp = new SpherePath(); Assert.False(sp.ObstructionEthereal); // default is false sp.ObstructionEthereal = true; Assert.True(sp.ObstructionEthereal); } // Cylinder shape: ethereal-alone -> passable // // Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). // acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the // top of CylinderCollision, before any overlap/slide logic. [Fact] public void CylinderEtherealAlone_IsPassable() { // A Cylinder-type shadow with state=ETHEREAL_PS (0x4, no 0x10). // Player sphere sweeps directly into it. Expect NO collision response // (CollisionNormalValid must stay false throughout all 20 ticks). // Retail ref: CCylSphere::intersects_sphere pc:324573 -- ethereal branch // does collides_with_sphere checks only (void return, never COLLIDED). var engine = BuildEngineWithSingleShadow( collisionType: ShadowCollisionType.Cylinder, objectState: ETHEREAL_PS, objectPos: new Vector3(12f, 12f, 0f), radius: 0.3f, cylHeight: 1.5f); // Approach dead-center from -Y, sweeping +Y through the cylinder. var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); Assert.False(blocked, $"Ethereal-alone Cylinder must be passable (no collision). " + $"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); } [Fact] public void CylinderNonEthereal_StillBlocks() { // Control: same Cylinder, state=0 (no ETHEREAL). Must still block. // This guards the regression that the ethereal gate does NOT affect // normal (non-ethereal) Cylinder objects. var engine = BuildEngineWithSingleShadow( collisionType: ShadowCollisionType.Cylinder, objectState: 0u, // no ethereal bit objectPos: new Vector3(12f, 12f, 0f), radius: 0.3f, cylHeight: 1.5f); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); Assert.True(blocked, $"Non-ethereal Cylinder must still block. " + $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); } // Sphere shape: ethereal-alone -> passable // // Retail oracle: CSphere::intersects_sphere @ 0x00537ae4 (pc:321692). // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). // acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the // top of SphereCollision. [Fact] public void SphereEtherealAlone_IsPassable() { // A Sphere-type shadow with state=ETHEREAL_PS (0x4, no 0x10). // Player sphere sweeps directly into it. Expect NO collision response. // Retail ref: CSphere::intersects_sphere pc:321692 -- ethereal branch // does a proximity check only (void return, never COLLIDED / Slid). var engine = BuildEngineWithSingleShadow( collisionType: ShadowCollisionType.Sphere, objectState: ETHEREAL_PS, objectPos: new Vector3(12f, 12f, 0.48f), radius: 0.4f, cylHeight: 0f); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); Assert.False(blocked, $"Ethereal-alone Sphere must be passable (no collision). " + $"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); } [Fact] public void SphereNonEthereal_StillBlocks() { // Control: same Sphere, state=0 (no ETHEREAL). Must still block. var engine = BuildEngineWithSingleShadow( collisionType: ShadowCollisionType.Sphere, objectState: 0u, // no ethereal bit objectPos: new Vector3(12f, 12f, 0.48f), radius: 0.4f, cylHeight: 0f); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); Assert.True(blocked, $"Non-ethereal Sphere must still block. " + $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); } // Layer-2 BSP override -- retail CPhysicsObj::FindObjCollisions pc:276961-276989 // // After BSP Path-1 (sphere_intersects_solid) returns Collided for a thin // ethereal slab, retail's Layer-2 force-resets the result to OK for any // NON-STATIC ethereal target (pc:276963-276977). // // Synthetic BSP fixture: a 1x2 m vertical wall polygon (normal=-Y) at // world y=12, blocking the player approaching from y<12. The leaf has // Solid=0 (default private-set value), so the collision comes from // HitsSphere in SphereIntersectsSolidInternal -- exactly the "thin slab // polygon contact" scenario that Layer-2 was designed to rescue. // // Sub-cases: // A. Ethereal non-static BSP wall -> passable (Layer-2 fires, pc:276973). // B. Ethereal STATIC BSP wall -> still blocks (STATIC_PS=0x1 suppresses // Layer-2 per pc:276969: `if ((this->state & 1) == 0)`). // C. Non-ethereal BSP wall -> still blocks (ObstructionEthereal=false; // Layer-2 condition false -- regression guard). private const uint Layer2LandblockId = 0xA9B50000u; private const uint Layer2CellId = Layer2LandblockId | 0x0001u; private const uint Layer2EntityId = 0xF4300u; private const uint Layer2GfxObjId = 0xF4301u; private const ushort WallPolyId = 1; /// /// Build a PhysicsEngine containing a synthetic BSP slab (vertical wall /// polygon at y=12) registered with the given . /// /// /// The wall polygon's normal is -Y (faces the player approaching from y less than 12). /// The leaf has Solid=0 (the default private-set value), so the only /// collision path is HitsSphere in SphereIntersectsSolidInternal /// -- the "thin slab polygon contact" case requiring Layer-2. /// /// private static PhysicsEngine BuildEngineWithBspSlab(uint objectState) { var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = -1000f; var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; engine.AddLandblock( landblockId: Layer2LandblockId, terrain: new TerrainSurface(heights, heightTable), cells: System.Array.Empty(), portals: System.Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Vertical wall polygon in OBJECT-LOCAL space (object is at world (12,12,0)). // Wall at local y=0, x in [-1,1], z in [-2,5]. Normal = -Y (faces the // approaching player who is at world y<12 = local y<0). // The player sphere is transformed to local space before BSP query: // localOrigin = globalSphereOrigin - objectWorldPos = (12,y,0.48)-(12,12,0) // = (0, y-12, 0.48). Starts at y-12=-1, sweeps toward 0. var wallVerts = new[] { new Vector3(-1f, 0f, -2f), new Vector3( 1f, 0f, -2f), new Vector3( 1f, 0f, 5f), new Vector3(-1f, 0f, 5f), }; var wallNormal = new Vector3(0f, -1f, 0f); float dVal = -Vector3.Dot(wallNormal, wallVerts[0]); // = 0 var wallPoly = new ResolvedPolygon { Vertices = wallVerts, Plane = new System.Numerics.Plane(wallNormal, dVal), NumPoints = 4, SidesType = CullMode.None, }; // Single leaf node, Solid=0 (private set -- default value = 0). // This means SphereIntersectsSolidInternal won't short-circuit at the // solid-leaf check; collision comes from HitsSphere on wallPoly. // Bounding sphere in LOCAL space: covers x[-1,1], y=0, z[-2,5]. var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 6f }, }; leaf.Polygons.Add(WallPolyId); var bspTree = new PhysicsBSPTree { Root = leaf }; var physics = new GfxObjPhysics { BSP = bspTree, PhysicsPolygons = new Dictionary(), Vertices = new VertexArray(), Resolved = new Dictionary { [WallPolyId] = wallPoly }, BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 6f }, }; cache.RegisterGfxObjForTest(Layer2GfxObjId, physics); engine.ShadowObjects.Register( entityId: Layer2EntityId, gfxObjId: Layer2GfxObjId, worldPos: new Vector3(12f, 12f, 0f), rotation: System.Numerics.Quaternion.Identity, radius: 10f, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: Layer2LandblockId, collisionType: ShadowCollisionType.BSP, cylHeight: 0f, scale: 1.0f, state: objectState, flags: EntityCollisionFlags.None); return engine; } [Fact] public void BspEtherealNonStatic_Layer2_IsPassable() { // Retail pc:276961-276989 Layer-2 gate: ETHEREAL (0x4) + NOT STATIC (no 0x1) // -> force result = OK regardless of what BSP Path-1 returned. // The synthetic wall polygon causes HitsSphere=true -> Path-1 Collided; // Layer-2 must rescue it so the player passes through. // Retail gate (pc:276969): `if ((this->state & 1) == 0)` -- non-static only. const uint EtherealNonStatic = 0x4u; // ETHEREAL_PS, no STATIC_PS var engine = BuildEngineWithBspSlab(EtherealNonStatic); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, startCellId: Layer2CellId); Assert.False(blocked, $"Ethereal non-static BSP slab must be passable via Layer-2 override " + $"(retail pc:276973). Sphere stopped at " + $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); } [Fact] public void BspEtherealStatic_Layer2Suppressed_StillBlocks() { // Retail pc:276969 gate: STATIC_PS (0x1) set -> Layer-2 does NOT fire. // Even though the target is ETHEREAL (0x4), the STATIC bit keeps the // Collided result intact. Static ethereal geometry (env shell nodes) // should still block per retail's `if ((this->state & 1) == 0)` check. const uint EtherealStatic = 0x4u | 0x1u; // ETHEREAL_PS | STATIC_PS var engine = BuildEngineWithBspSlab(EtherealStatic); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, startCellId: Layer2CellId); Assert.True(blocked, $"Ethereal STATIC BSP slab must still block (Layer-2 suppressed by STATIC_PS=0x1). " + $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); } [Fact] public void BspNonEthereal_NotPassable_RegressionGuard() { // Control: non-ethereal BSP wall (state=0) must block as normal. // sp.ObstructionEthereal is false -> Layer-2 condition is false -> // the Collided result from BSP Path-1 is not reset. Regression guard // that the Layer-2 insert does NOT affect ordinary walls/doors. const uint NonEthereal = 0x0u; var engine = BuildEngineWithBspSlab(NonEthereal); var start = new Vector3(12f, 11f, 0.48f); var perTick = new Vector3(0f, 0.10f, 0f); var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, startCellId: Layer2CellId); Assert.True(blocked, $"Non-ethereal BSP slab must still block (Layer-2 does not apply when " + $"sp.ObstructionEthereal=false). Sphere reached " + $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); } // Helpers private const uint TestLandblockId = 0xA9B40000u; private const uint TestCellId = TestLandblockId | 0x0001u; private const uint TestEntityId = 0xF4201u; private const uint TestGfxObjId = 0xF4202u; private const float SphereRadius = 0.48f; private const float SphereHeight = 1.20f; private const float StepUpHeight = 0.60f; private const float StepDownHeight = 0.04f; /// /// Build a minimal PhysicsEngine with a synthetic flat landblock /// and a single registered shadow entry at /// with the given collision type and state bits. /// No dat required -- Cylinder and Sphere shapes need no GfxObj/BSP. /// private static PhysicsEngine BuildEngineWithSingleShadow( ShadowCollisionType collisionType, uint objectState, Vector3 objectPos, float radius, float cylHeight) { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; // Stub landblock: flat terrain far below so it never interferes. 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: System.Array.Empty(), portals: System.Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Register the target shadow. Cylinder/Sphere shapes don't need a // GfxObj BSP in the cache -- FindObjCollisions only queries the cache // for BSP collision types. engine.ShadowObjects.Register( entityId: TestEntityId, gfxObjId: TestGfxObjId, worldPos: objectPos, rotation: System.Numerics.Quaternion.Identity, radius: radius, worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId, collisionType: collisionType, cylHeight: cylHeight, scale: 1.0f, state: objectState, flags: EntityCollisionFlags.None); return engine; } /// /// Sweep a player sphere by each step until /// CollisionNormalValid fires or elapse. /// Accepts an optional for tests that use a /// different landblock than the default . /// private static (bool blocked, Vector3 finalPos, Vector3 normal) SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks, uint startCellId = 0) { Vector3 pos = start; uint cellId = startCellId != 0 ? startCellId : TestCellId; bool onGround = false; for (int tick = 0; tick < maxTicks; tick++) { Vector3 target = pos + perTick; var result = engine.ResolveWithTransition( pos, target, cellId, SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, onGround, body: null, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0); if (result.CollisionNormalValid) return (true, result.Position, result.CollisionNormal); pos = result.Position; cellId = result.CellId; onGround = result.IsOnGround; } return (false, pos, Vector3.Zero); } }