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:
parent
6c7e3ef1ab
commit
1a7c5aa006
2 changed files with 248 additions and 34 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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++)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue