fix(physics): port retail Layer-2 ethereal override -- open doors passable (pc:276961)

CPhysicsObj::FindObjCollisions at pc:276961-276989 has a two-layer mechanism for
ethereal doors. Layer 1 (BSPQuery Path-1 sphere_intersects_solid, pc:323742) already
ported in Task 3 handles the open-gap case. Layer 2 (pc:276963-276977) is a
force-reset that catches the residual case: when the player's sphere CENTER crosses
a thin ethereal slab, sphere_intersects_solid can still return Collided via
HitsSphere (polygon contact on the slab face). Without Layer 2, the opened door
remains an invisible wall until the player's center passes ~0.48m beyond the slab.

Port: in FindObjCollisionsInCell, immediately after the shape dispatch (BSP / Sphere /
Cylinder branches), insert the Layer-2 gate:
  if (result != OK && sp.ObstructionEthereal && !sp.StepDown && (obj.State & 0x1u) == 0)
    { result = OK; ci.CollisionNormalValid = false; }
The STATIC_PS (0x1) guard (pc:276969) ensures static-ethereal env geometry still
blocks. Cylinder/Sphere shapes already return OK from their own ObstructionEthereal
early-outs so Layer 2 is effectively BSP-only in practice, but is written
unconditionally matching retail.

Tests (ObstructionEtherealTests): three new BSP Layer-2 cases using a synthetic
wall polygon in local-space coordinates. (A) ethereal non-static: passable. (B)
ethereal+static: still blocks. (C) non-ethereal: still blocks. All 11 tests pass;
DoorCollision/CellarUp/CornerFlood/HouseExitWalk regression gate: 25/25 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 21:03:08 +02:00
parent 6c7e3ef1ab
commit 1a7c5aa006
2 changed files with 248 additions and 34 deletions

View file

@ -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. // L.2a slice 3: attribute the collision (if any) to this entity.
// Two cases: // Two cases:
// - result != OK: the object stopped the transition (hard-block). // - result != OK: the object stopped the transition (hard-block).

View file

@ -1,5 +1,8 @@
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit; using Xunit;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
@ -12,11 +15,11 @@ namespace AcDream.Core.Tests.Physics;
/// Retail oracle: <c>CPhysicsObj::FindObjCollisions @ 0x0050f050</c> /// Retail oracle: <c>CPhysicsObj::FindObjCollisions @ 0x0050f050</c>
/// (<c>acclient_2013_pseudo_c.txt:276782</c>): /// (<c>acclient_2013_pseudo_c.txt:276782</c>):
/// <list type="bullet"> /// <list type="bullet">
/// <item>pc:276782 Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND /// <item>pc:276782 -- Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND
/// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip.</item> /// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip.</item>
/// <item>pc:276806 ETHEREAL-alone sets <c>sphere_path.obstruction_ethereal = 1</c> /// <item>pc:276806 -- ETHEREAL-alone sets <c>sphere_path.obstruction_ethereal = 1</c>
/// and continues to the shape dispatch.</item> /// and continues to the shape dispatch.</item>
/// <item>pc:276989 After the per-object shape test, retail clears /// <item>pc:276989 -- After the per-object shape test, retail clears
/// <c>obstruction_ethereal = 0</c>.</item> /// <c>obstruction_ethereal = 0</c>.</item>
/// </list> /// </list>
/// </para> /// </para>
@ -33,14 +36,14 @@ namespace AcDream.Core.Tests.Physics;
/// Consume site 2: <c>CSphere::intersects_sphere @ 0x00537ae4</c> /// Consume site 2: <c>CSphere::intersects_sphere @ 0x00537ae4</c>
/// (<c>acclient_2013_pseudo_c.txt:321692</c>): The ethereal branch performs /// (<c>acclient_2013_pseudo_c.txt:321692</c>): The ethereal branch performs
/// a proximity check only (no push-back, no COLLIDED return). All code /// 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.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Consume site 3: <c>CCylSphere::intersects_sphere @ 0x0053b4a0</c> /// Consume site 3: <c>CCylSphere::intersects_sphere @ 0x0053b4a0</c>
/// (<c>acclient_2013_pseudo_c.txt:324573</c>): Same pattern the ethereal /// (<c>acclient_2013_pseudo_c.txt:324573</c>): Same pattern -- the ethereal
/// branch calls <c>collides_with_sphere</c> for an overlap check only, all /// branch calls <c>collides_with_sphere</c> 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.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -48,15 +51,24 @@ namespace AcDream.Core.Tests.Physics;
/// are now implemented in <c>SphereCollision</c> and <c>CylinderCollision</c> /// are now implemented in <c>SphereCollision</c> and <c>CylinderCollision</c>
/// in <c>TransitionTypes.cs</c>. The tests below verify: /// in <c>TransitionTypes.cs</c>. The tests below verify:
/// <list type="bullet"> /// <list type="bullet">
/// <item>Ethereal-alone Cylinder target passable (no CollisionNormalValid).</item> /// <item>Ethereal-alone Cylinder target -> passable (no CollisionNormalValid).</item>
/// <item>Ethereal-alone Sphere target passable (no CollisionNormalValid).</item> /// <item>Ethereal-alone Sphere target -> passable (no CollisionNormalValid).</item>
/// <item>Non-ethereal Cylinder target still blocks (regression guard).</item> /// <item>Non-ethereal Cylinder target -> still blocks (regression guard).</item>
/// <item>Non-ethereal Sphere target still blocks (regression guard).</item> /// <item>Non-ethereal Sphere target -> still blocks (regression guard).</item>
/// </list> /// </list>
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Retail divergence register: row AD-7 retired in the original Task 3 commit — /// Layer-2 BSP override (D4+W1, 2026-06-24): retail
/// <c>CPhysicsObj::FindObjCollisions pc:276961-276989</c> 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).
/// </para>
///
/// <para>
/// Retail divergence register: row AD-7 retired in the original Task 3 commit --
/// the ETHEREAL-alone shim is replaced by the faithful port. /// the ETHEREAL-alone shim is replaced by the faithful port.
/// </para> /// </para>
/// </summary> /// </summary>
@ -65,12 +77,12 @@ public class ObstructionEtherealTests
private const uint ETHEREAL_PS = 0x4u; private const uint ETHEREAL_PS = 0x4u;
private const uint IGNORE_COLLISIONS_PS = 0x10u; private const uint IGNORE_COLLISIONS_PS = 0x10u;
// ── Gate-1 tests: ShouldSkip dual-bit contract ───────────────────────── // Gate-1 tests: ShouldSkip dual-bit contract
[Fact] [Fact]
public void ShouldSkip_BothBits_InstantSkip() 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`. // Retail pc:276782: `if ((state & 4) AND (state & 0x10)) return 1`.
Assert.True(CollisionExemption.ShouldSkip( Assert.True(CollisionExemption.ShouldSkip(
targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS, targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS,
@ -81,7 +93,7 @@ public class ObstructionEtherealTests
[Fact] [Fact]
public void ShouldSkip_EtherealAlone_NotInstantSkip() 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. // obstruction_ethereal branch instead. The old AD-7 shim is retired.
// Retail pc:276782: only the COMBINED gate returns early; ETHEREAL-alone // Retail pc:276782: only the COMBINED gate returns early; ETHEREAL-alone
// falls through to set obstruction_ethereal and run the shape test. // falls through to set obstruction_ethereal and run the shape test.
@ -94,7 +106,7 @@ public class ObstructionEtherealTests
[Fact] [Fact]
public void ShouldSkip_IgnoreCollisionsAlone_NotSkipped() 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). // falls through to shape dispatch (no obstruction_ethereal set either).
Assert.False(CollisionExemption.ShouldSkip( Assert.False(CollisionExemption.ShouldSkip(
targetState: IGNORE_COLLISIONS_PS, targetState: IGNORE_COLLISIONS_PS,
@ -102,7 +114,7 @@ public class ObstructionEtherealTests
moverState: ObjectInfoState.IsPlayer)); moverState: ObjectInfoState.IsPlayer));
} }
// ── SpherePath.ObstructionEthereal field ─────────────────────────────── // SpherePath.ObstructionEthereal field
[Fact] [Fact]
public void SpherePath_HasObstructionEtherealField() public void SpherePath_HasObstructionEtherealField()
@ -114,7 +126,7 @@ public class ObstructionEtherealTests
Assert.True(sp.ObstructionEthereal); Assert.True(sp.ObstructionEthereal);
} }
// ── Cylinder shape: ethereal-alone → passable ───────────────────────── // Cylinder shape: ethereal-alone -> passable
// //
// Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). // Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
// When obstruction_ethereal != 0, all code paths return void (no COLLIDED). // 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). // A Cylinder-type shadow with state=ETHEREAL_PS (0x4, no 0x10).
// Player sphere sweeps directly into it. Expect NO collision response // Player sphere sweeps directly into it. Expect NO collision response
// (CollisionNormalValid must stay false throughout all 20 ticks). // (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). // does collides_with_sphere checks only (void return, never COLLIDED).
var engine = BuildEngineWithSingleShadow( var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Cylinder, 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 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). // Retail oracle: CSphere::intersects_sphere @ 0x00537ae4 (pc:321692).
// When obstruction_ethereal != 0, all code paths return void (no COLLIDED). // 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). // A Sphere-type shadow with state=ETHEREAL_PS (0x4, no 0x10).
// Player sphere sweeps directly into it. Expect NO collision response. // 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). // does a proximity check only (void return, never COLLIDED / Slid).
var engine = BuildEngineWithSingleShadow( var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Sphere, collisionType: ShadowCollisionType.Sphere,
@ -222,17 +234,185 @@ public class ObstructionEtherealTests
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks.");
} }
// ── BSPQuery Path-1 gate: ObstructionEthereal routes through // Layer-2 BSP override -- retail CPhysicsObj::FindObjCollisions pc:276961-276989
// sphere_intersects_solid (not the blocking slide path) ──────────────
// //
// A full transition-level harness for the BSP path requires a real // After BSP Path-1 (sphere_intersects_solid) returns Collided for a thin
// BSP tree fixture and is deferred to the DoorCollisionApparatusTests // ethereal slab, retail's Layer-2 force-resets the result to OK for any
// integration tests. Here we verify the GATE condition in isolation // NON-STATIC ethereal target (pc:276963-276977).
// via the FindEnvCollisions D5 clear and the SpherePath property //
// round-trip. The DoorCollisionApparatusTests and CellarUpTrajectory- // Synthetic BSP fixture: a 1x2 m vertical wall polygon (normal=-Y) at
// ReplayTests are the regression guards for the consume-site change. // 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;
/// <summary>
/// Build a PhysicsEngine containing a synthetic BSP slab (vertical wall
/// polygon at y=12) registered with the given <paramref name="objectState"/>.
///
/// <para>
/// The wall polygon's normal is -Y (faces the player approaching from y less than 12).
/// The leaf has <c>Solid=0</c> (the default private-set value), so the only
/// collision path is <c>HitsSphere</c> in <c>SphereIntersectsSolidInternal</c>
/// -- the "thin slab polygon contact" case requiring Layer-2.
/// </para>
/// </summary>
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<CellSurface>(),
portals: System.Array.Empty<PortalPlane>(),
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<ushort, DatReaderWriter.Types.Polygon>(),
Vertices = new VertexArray(),
Resolved = new Dictionary<ushort, ResolvedPolygon> { [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 TestLandblockId = 0xA9B40000u;
private const uint TestCellId = TestLandblockId | 0x0001u; private const uint TestCellId = TestLandblockId | 0x0001u;
@ -248,7 +428,7 @@ public class ObstructionEtherealTests
/// Build a minimal PhysicsEngine with a synthetic flat landblock /// Build a minimal PhysicsEngine with a synthetic flat landblock
/// and a single registered shadow entry at <paramref name="objectPos"/> /// and a single registered shadow entry at <paramref name="objectPos"/>
/// with the given collision type and state bits. /// 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.
/// </summary> /// </summary>
private static PhysicsEngine BuildEngineWithSingleShadow( private static PhysicsEngine BuildEngineWithSingleShadow(
ShadowCollisionType collisionType, ShadowCollisionType collisionType,
@ -273,7 +453,7 @@ public class ObstructionEtherealTests
worldOffsetY: 0f); worldOffsetY: 0f);
// Register the target shadow. Cylinder/Sphere shapes don't need a // 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. // for BSP collision types.
engine.ShadowObjects.Register( engine.ShadowObjects.Register(
entityId: TestEntityId, entityId: TestEntityId,
@ -296,12 +476,15 @@ public class ObstructionEtherealTests
/// <summary> /// <summary>
/// Sweep a player sphere by <paramref name="perTick"/> each step until /// Sweep a player sphere by <paramref name="perTick"/> each step until
/// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/> elapse. /// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/> elapse.
/// Accepts an optional <paramref name="startCellId"/> for tests that use a
/// different landblock than the default <see cref="TestCellId"/>.
/// </summary> /// </summary>
private static (bool blocked, Vector3 finalPos, Vector3 normal) 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; Vector3 pos = start;
uint cellId = TestCellId; uint cellId = startCellId != 0 ? startCellId : TestCellId;
bool onGround = false; bool onGround = false;
for (int tick = 0; tick < maxTicks; tick++) for (int tick = 0; tick < maxTicks; tick++)