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++)