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);
}
}