diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index e48e6ab3..7b0aeafb 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2775,6 +2775,37 @@ public sealed class Transition } } + // Layer-2 ethereal override — retail CPhysicsObj::FindObjCollisions + // pc:276961-276989 (0x0050f1ac-0x0050f31e). After the shape dispatch + // (Layer 1), retail force-resets the result to OK for a NON-STATIC + // ethereal target when step_down is NOT set. Layer 1 (BSPQuery Path-1 + // sphere_intersects_solid) handles the no-solid-leaf case (open door + // gap) already, but a thin ethereal slab (closed-but-ethereal / the + // player's sphere CENTER crosses the slab) can still return Collided. + // Layer 2 suppresses that so the door is FULLY passable once ethereal. + // + // Gate conditions (pc:276963-276973): + // 276963: state_3 != OK_TS ← result is a block + // 276967: sphere_path.step_down == 0 ← not a step-down query + // 276969: (target->state & 1) == 0 ← NOT STATIC_PS (0x1) + // 276971: var_c != 0 || ... ← obstruction_ethereal set + // (the || ... is the IGNORE_CREATURES+creature case — handled + // upstream in CollisionExemption; Layer 2 only needs the var_c term) + // Effect (pc:276973-276977): + // state_3 = OK_TS ← force passable + // collision_normal_valid = 0 ← clear stale slide normal + // Note: Cylinder and Sphere shapes already return OK from their own + // obstruction_ethereal early-out, so this clause only fires in practice + // for the BSP branch, but is written unconditionally as retail does. + if (result != TransitionState.OK + && sp.ObstructionEthereal + && !sp.StepDown + && (obj.State & 0x1u) == 0) // STATIC_PS=0x1 (acclient.h:2815): static ethereal keeps Layer-1 only + { + result = TransitionState.OK; + ci.CollisionNormalValid = false; // collision_normal_valid = 0 (pc:276975) + } + // L.2a slice 3: attribute the collision (if any) to this entity. // Two cases: // - result != OK: the object stopped the transition (hard-block). diff --git a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs b/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs index 4de96c49..f824f73e 100644 --- a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; @@ -12,11 +15,11 @@ namespace AcDream.Core.Tests.Physics; /// Retail oracle: CPhysicsObj::FindObjCollisions @ 0x0050f050 /// (acclient_2013_pseudo_c.txt:276782): /// -/// pc:276782 — Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND +/// 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 +/// 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 +/// pc:276989 -- After the per-object shape test, retail clears /// obstruction_ethereal = 0. /// /// @@ -33,14 +36,14 @@ namespace AcDream.Core.Tests.Physics; /// 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. +/// 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 +/// (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. +/// returns are void, no COLLIDED result -- the player is fully passable. /// /// /// @@ -48,15 +51,24 @@ namespace AcDream.Core.Tests.Physics; /// 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). +/// 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 — +/// 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. /// /// @@ -65,12 +77,12 @@ public class ObstructionEtherealTests private const uint ETHEREAL_PS = 0x4u; private const uint IGNORE_COLLISIONS_PS = 0x10u; - // ── Gate-1 tests: ShouldSkip dual-bit contract ───────────────────────── + // Gate-1 tests: ShouldSkip dual-bit contract [Fact] public void ShouldSkip_BothBits_InstantSkip() { - // ETHEREAL | IGNORE_COLLISIONS together → instant-skip (Gate 1 fires). + // 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, @@ -81,7 +93,7 @@ public class ObstructionEtherealTests [Fact] public void ShouldSkip_EtherealAlone_NotInstantSkip() { - // ETHEREAL alone (0x4) must NOT instant-skip — retail takes the + // 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. @@ -94,7 +106,7 @@ public class ObstructionEtherealTests [Fact] public void ShouldSkip_IgnoreCollisionsAlone_NotSkipped() { - // IGNORE_COLLISIONS alone (0x10) without ETHEREAL → not instant-skipped; + // 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, @@ -102,7 +114,7 @@ public class ObstructionEtherealTests moverState: ObjectInfoState.IsPlayer)); } - // ── SpherePath.ObstructionEthereal field ─────────────────────────────── + // SpherePath.ObstructionEthereal field [Fact] public void SpherePath_HasObstructionEtherealField() @@ -114,7 +126,7 @@ public class ObstructionEtherealTests Assert.True(sp.ObstructionEthereal); } - // ── Cylinder shape: ethereal-alone → passable ───────────────────────── + // Cylinder shape: ethereal-alone -> passable // // Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). @@ -127,7 +139,7 @@ public class ObstructionEtherealTests // 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 + // 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, @@ -170,7 +182,7 @@ public class ObstructionEtherealTests $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); } - // ── Sphere shape: ethereal-alone → passable ─────────────────────────── + // Sphere shape: ethereal-alone -> passable // // Retail oracle: CSphere::intersects_sphere @ 0x00537ae4 (pc:321692). // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). @@ -182,7 +194,7 @@ public class ObstructionEtherealTests { // 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 + // 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, @@ -222,17 +234,185 @@ public class ObstructionEtherealTests $"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) ────────────── + // Layer-2 BSP override -- retail CPhysicsObj::FindObjCollisions pc:276961-276989 // - // 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. + // 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). - // ── Helpers ─────────────────────────────────────────────────────────── + 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; @@ -248,7 +428,7 @@ public class ObstructionEtherealTests /// 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. + /// No dat required -- Cylinder and Sphere shapes need no GfxObj/BSP. /// private static PhysicsEngine BuildEngineWithSingleShadow( ShadowCollisionType collisionType, @@ -273,7 +453,7 @@ public class ObstructionEtherealTests worldOffsetY: 0f); // Register the target shadow. Cylinder/Sphere shapes don't need a - // GfxObj BSP in the cache — FindObjCollisions only queries the cache + // GfxObj BSP in the cache -- FindObjCollisions only queries the cache // for BSP collision types. engine.ShadowObjects.Register( entityId: TestEntityId, @@ -296,12 +476,15 @@ public class ObstructionEtherealTests /// /// 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) + SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks, + uint startCellId = 0) { - Vector3 pos = start; - uint cellId = TestCellId; + Vector3 pos = start; + uint cellId = startCellId != 0 ? startCellId : TestCellId; bool onGround = false; for (int tick = 0; tick < maxTicks; tick++)