using System.Numerics; using AcDream.Core.Physics; 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). /// /// /// /// /// 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."); } // ── BSPQuery Path-1 gate: ObstructionEthereal routes through // sphere_intersects_solid (not the blocking slide path) ────────────── // // A full transition-level harness for the BSP path requires a real // BSP tree fixture and is deferred to the DoorCollisionApparatusTests // integration tests. Here we verify the GATE condition in isolation // via the FindEnvCollisions D5 clear and the SpherePath property // round-trip. The DoorCollisionApparatusTests and CellarUpTrajectory- // ReplayTests are the regression guards for the consume-site change. // ── 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. /// private static (bool blocked, Vector3 finalPos, Vector3 normal) SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks) { Vector3 pos = start; uint cellId = 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); } }