fix(physics): #137 Task 3 — port obstruction_ethereal verbatim; retire AD-7 shim

Retail CPhysicsObj::FindObjCollisions (0x0050f050) only instant-skips when
BOTH ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) are set (pc:276782).
ETHEREAL-alone sets sphere_path.obstruction_ethereal=1 (pc:276806) and
continues to the shape dispatch. BSPTREE::find_collisions (0x0053a496) routes
Path 1 (sphere_intersects_solid) when the flag is set (pc:323742): the open
door has no solid leaf at the doorway, so the test returns OK → player passes
through. CEnvCell::find_env_collisions (0x0052c144) clears the flag first so
ENV walls are never weakened (pc:309580, "D5 clear").

Changes:
- CollisionExemption.ShouldSkip: require BOTH bits for Gate-1 early-out
  (previously ETHEREAL alone returned true — the AD-7 shim). Divergence
  register row AD-7 deleted.
- SpherePath: add ObstructionEthereal field (mirrors retail
  SPHEREPATH.obstruction_ethereal).
- FindObjCollisionsInternal loop: set sp.ObstructionEthereal=(target&0x4)!=0
  before shape dispatch; clear it after (per-object clear pc:276989).
  Also clear at the null-BSP continue site to keep flag clean.
- FindEnvCollisions: clear sp.ObstructionEthereal=false at top (D5 clear
  pc:309580) — ENV cell walls are always solid.
- BSPQuery.FindCollisions Path 1: change `obj.Ethereal` (ObjectInfo.Ethereal,
  always false — dead code) to `path.ObstructionEthereal`. Gate now correctly
  mirrors retail pc:323742: PLACEMENT_INSERT || obstruction_ethereal.

Consume site change (BSPQuery.cs before/after):
  BEFORE: if (path.InsertType == InsertType.Placement || obj.Ethereal)
  AFTER:  if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
Mirrors retail pc:323742 exactly. obj.Ethereal was dead code (ObjectInfo.Ethereal
is never set true anywhere); the correct flag is SpherePath.ObstructionEthereal.

Tests: 1580 pass / 0 fail / 2 skip (was 1576/0/2 + 4 new in ObstructionEtherealTests.
CellarUp, CornerFlood, DoorCollision, HouseExitWalk all green — no wall regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 19:20:02 +02:00
parent 78e5758185
commit 3361a8d776
7 changed files with 382 additions and 30 deletions

View file

@ -71,7 +71,6 @@ accepted-divergence entries (#96, #49, #50).
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) |
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |

View file

@ -0,0 +1,192 @@
# obstruction_ethereal — set / clear / consume contract
**Research date:** 2026-06-24
**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
**Phase:** Task 3 of the collision-inclusion verbatim-retail port
---
## 1. Gate 1 — `CPhysicsObj::FindObjCollisions` (pc:276782 / 0x0050f050)
```
// pc:276782
if (state & 4) AND (state & 0x10): // ETHEREAL_PS | IGNORE_COLLISIONS_PS
return 1 // OK_TS — instant-skip, no further work
// else fall through: ETHEREAL-alone goes into the block below
// pc:276802
int32_t var_c;
if ((state_2 & 4) != 0 // target ETHEREAL
|| (ebx->object_info.ethereal != 0 // mover is ethereal
&& (state_2 & 1) == 0)): // AND target not REPORTS_COLLISIONS
var_c = 1
if (sphere_path.step_down == 0):
goto label_50f0c9 // set obstruction_ethereal = 1 and continue
else:
var_c = 0
// label_50f0c9:
ebx->sphere_path.obstruction_ethereal = var_c // pc:276806
// ... continue with shape dispatch (FindObjCollisions body: BSP / Sphere / Cyl) ...
// At the END of the FindObjCollisions body (after all shape tests):
// pc:276989 / 0x0050f31e:
ebx->sphere_path.obstruction_ethereal = 0 // clear after each object's test
```
**Translation (C#):**
- `ShouldSkip` returns `true` ONLY when `(targetState & 0x4) != 0 && (targetState & 0x10) != 0`.
- ETHEREAL-alone falls through. Before the shape dispatch (BSP/Sphere/Cyl call), set
`sp.ObstructionEthereal = true` when `(targetState & 0x4) != 0`.
- After the shape dispatch, retail clears the flag (`= 0`). In acdream we clear it at the
END of the per-target loop iteration (mirrors the per-object clear at pc:276989).
**Note on `step_down` gate:** retail's `var_c` assignment also checks `step_down == 0`
before jumping to `label_50f0c9`. When `step_down != 0`, var_c stays 1 but hits the
`else var_c = 0` branch at pc:276804 (which sets var_c = 0). Reading the full
branching tree: the ONLY path that sets `obstruction_ethereal = 1` is when
`(state_2 & 4) != 0` (target ETHEREAL) AND `step_down == 0`. For acdream's
transitional player insert, `step_down` is false at the outer loop entry, so the flag
fires when target is ETHEREAL. We match retail exactly by gating on target ETHEREAL
(the `(obj.State & 0x4) != 0` check in the loop).
---
## 2. D5 clear — `CEnvCell::find_env_collisions` (pc:309580 / 0x0052c144)
```
// 0x0052c144:
arg2->sphere_path.obstruction_ethereal = 0;
// ... then: BSP dispatch for the ENV cell walls (not objects) ...
```
**Translation (C#):** At the top of `FindEnvCollisions`, clear `sp.ObstructionEthereal = false`
before any BSP dispatch. The ENV path clears it because ENV walls are always solid — the
flag only applies to object (CPhysicsObj) tests. This prevents a stale flag from a
prior object loop from weakening ENV wall tests.
---
## 3. Consume site 1 — `CSphere::intersects_sphere` (pc:321692 / 0x00537ae4)
```
// pc:321692:
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
// sphere_intersects_solid test — allows passage if sphere is NOT inside solid
if (collides_with_sphere(center, pos, radius_sum)):
return 2 // COLLIDED — solid containment
// else: sphere is NOT inside solid, passage allowed — no push-back
else if (step_down == 0):
// normal walkable / check_walkable / slide path (the BLOCKING path)
...
```
**What this means:** when `obstruction_ethereal` (target is passable-ethereal) or
`PLACEMENT_INSERT`, the test only fails if the sphere FULLY OVERLAPS the solid region
of the sphere target. In practice for a Sphere-type entity with no BSP this means
you can walk through it as long as your sphere center isn't inside its solid — which
is almost never true during normal movement. The blocker path (slide/push-back) is
bypassed entirely.
**acdream scope note:** `SphereCollision` in `TransitionTypes.cs` does NOT currently
implement this gate; it always does the slide path. That is a separate task (Task 5 or
equivalent). For Task 3 scope: BSP objects are the priority for opened doors; the
Sphere path is a separate port.
---
## 4. Consume site 2 — `BSPTREE::find_collisions` (pc:323742 / 0x0053a496)
```
// pc:323742:
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
// sphere_intersects_solid with bldg_check center_solid flag
ebp_4 = 1
if (bldg_check != 0):
ebp_4 = (hits_interior_cell == 0) // center_solid weakens inside buildings
if (root_node->sphere_intersects_solid(localspace_sphere, ebp_4)):
return 2 // COLLIDED
if (num_sphere > 1):
if (root_node->sphere_intersects_solid(localspace_sphere[1], ebp_4)):
return 2
else:
// check_walkable / step_down / normal blocking path
...
```
**This is the BSPQuery Path 1 at `BSPQuery.cs:1717`.** The existing code:
```csharp
if (path.InsertType == InsertType.Placement || obj.Ethereal)
```
`obj.Ethereal` is `ObjectInfo.Ethereal` (the MOVER's ethereal flag) which is NEVER
set — it's always false. The correct translation of `obstruction_ethereal` is
`path.ObstructionEthereal` (on `SpherePath`).
**Fix:** change to:
```csharp
if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
```
This is the ONLY consume site for Task 3 scope (BSP objects). For the BSP path:
when the target is ETHEREAL, `path.ObstructionEthereal` is set before calling
`BSPQuery.FindCollisions`, so Path 1 fires instead of the normal blocking path.
Path 1 uses `sphere_intersects_solid` which only returns COLLIDED if the player
is inside a BSP solid leaf — during forward movement toward a door (which has no
solid wall when open), the BSP finds no solid intersection, returns OK, player passes
through.
---
## 5. Consume site 3 — `CCylSphere::intersects_sphere` (pc:324573 / 0x0053b4a0)
```
// pc:324573:
if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0):
// collides_with_sphere test (3D overlap only)
if (CCylSphere::collides_with_sphere(this, global_sphere, ...)):
return // early-out, no Collided return — void return here
// else: no collision, return (void — passable)
else:
// step_down / check_walkable / normal blocking path
...
```
**acdream scope note:** `CylinderCollision` in `TransitionTypes.cs` does NOT implement
this gate. That is a separate task. Cylinder-type shadow entries for ETHEREAL objects
would not be blocked by the BSP path (BSP is separate), so the ETHEREAL Cyl gate
primarily matters for objects that are purely Cylinder (no BSP) — which is less common
than BSP objects. Task 3 scope covers BSP.
---
## Summary: set / clear / consume flow for Task 3
```
FindObjCollisionsLoop():
for each obj in cell.shadow_entries:
// Gate 1: instant-skip needs BOTH bits
if obj.State has ETHEREAL AND IGNORE_COLLISIONS:
continue // pass through, no obstruction_ethereal
// Set flag for ETHEREAL-alone
sp.ObstructionEthereal = (obj.State & 0x4) != 0
// Shape dispatch (BSP path):
// BSPQuery.FindCollisions → Path 1 checks ObstructionEthereal
// When ObstructionEthereal=true → sphere_intersects_solid (passable)
// When ObstructionEthereal=false → normal blocking paths
// Clear after per-object test (retail pc:276989)
sp.ObstructionEthereal = false
FindEnvCollisions():
sp.ObstructionEthereal = false // D5 clear — ENV walls are always solid
// BSP dispatch against cell walls ...
```
**Contracts:**
- ETHEREAL-only target (`state = 0x0001000C`): `ShouldSkip` returns false → flag set to
true → BSP Path 1 fires → `sphere_intersects_solid` test → player walks through open
door's BSP (no solid leaf at the opening) → OK → passable.
- Non-ethereal wall: flag = false → Path 1 DOES NOT fire → normal blocking paths → player
stopped.
- ENV cell walls: flag cleared before BSP dispatch → ENV BSP always uses the blocking path.

View file

@ -1712,9 +1712,20 @@ public static class BSPQuery
Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld);
// ----------------------------------------------------------------
// Path 1: Placement or Ethereal → sphere_intersects_solid
// Path 1: Placement or obstruction_ethereal → sphere_intersects_solid
// ----------------------------------------------------------------
if (path.InsertType == InsertType.Placement || obj.Ethereal)
// Retail BSPTREE::find_collisions pc:323742 / 0x0053a496:
// `if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0)`
// obstruction_ethereal is a per-object flag set by FindObjCollisions
// (pc:276806) when the target is ETHEREAL-alone (ETHEREAL_PS=0x4 set,
// IGNORE_COLLISIONS_PS=0x10 NOT set). When set, Path 1 fires instead of
// the blocking paths: sphere_intersects_solid only returns COLLIDED if
// the player sphere is inside a BSP solid leaf. For an open door (no
// solid wall at the doorway), no solid leaf intersects → OK → passable.
// Previously obj.Ethereal (ObjectInfo.Ethereal, always false) was here —
// the correct flag is SpherePath.ObstructionEthereal, set per target.
// Task 3 (2026-06-24) / retail divergence register AD-7 retired.
if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
{
// BR-7 / A6.P4 (2026-06-11): retail weakens the solid test
// against BUILDING shells while the path engages interior cells

View file

@ -59,23 +59,20 @@ public static class CollisionExemption
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
ObjectInfoState moverState)
{
// 1. Target ETHEREAL → walk through.
// Retail (acclient_2013_pseudo_c.txt:276782) requires BOTH
// ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) to wrap
// the entire body of FindObjCollisions and skip collision.
// ETHEREAL alone takes a different retail path (line 276795
// sets sphere_path.obstruction_ethereal = 1 and downstream
// movement allows passage despite the contact). We haven't
// ported that downstream path yet.
//
// L.2g slice 1b (2026-05-13): ACE's Door.Open() sends only
// ETHEREAL (state=0x0001000C observed live), not the
// ETHEREAL|IGNORE_COLLISIONS combo retail servers broadcast.
// Pragmatic shortcut: exempt on ETHEREAL alone so doors
// become passable when ACE flips the bit. Retail-server
// broadcasts (state=0x14+) still hit this branch correctly
// because both bits set implies ETHEREAL set.
if ((targetState & ETHEREAL_PS) != 0)
// 1. Target ETHEREAL + IGNORE_COLLISIONS → instant-skip.
// Retail (acclient_2013_pseudo_c.txt:276782):
// `if ((state & 4) AND (state & 0x10)) return 1`
// BOTH bits are required. ETHEREAL-alone takes the retail
// `obstruction_ethereal` path instead (pc:276806): the flag is
// set to 1 on the SpherePath and the shape test still runs,
// but BSP Path 1 (sphere_intersects_solid) weakens solid-
// containment so the player passes through the open door.
// ACE's Door.Open() broadcasts ETHEREAL only (0x0001000C) —
// this faithful port makes open doors passable via the BSP
// sphere_intersects_solid path (no solid leaf at the opening),
// which subsumes the former AD-7 shim. Divergence register
// row AD-7 retired in the same commit as this change.
if ((targetState & ETHEREAL_PS) != 0 && (targetState & IGNORE_COLLISIONS_PS) != 0)
return true;
// 2. Viewer mover + creature target → walk through.

View file

@ -382,6 +382,30 @@ public sealed class SpherePath
public bool CheckWalkable;
public InsertType InsertType = InsertType.Transition;
/// <summary>
/// Retail <c>SPHEREPATH.obstruction_ethereal</c>: set to <c>1</c> per-target
/// inside <c>CPhysicsObj::FindObjCollisions</c> (pc:276806 / 0x0050f0c9) when
/// the target object is ETHEREAL-alone (ETHEREAL_PS=0x4 set, IGNORE_COLLISIONS_PS
/// 0x10 NOT set). Cleared after each per-object shape test (pc:276989 / 0x0050f31e)
/// and also cleared at the top of <c>CEnvCell::find_env_collisions</c>
/// (pc:309580 / 0x0052c144 — the "D5 clear") so ENV walls are never weakened.
///
/// <para>
/// Consume site: <c>BSPTREE::find_collisions</c> pc:323742 / 0x0053a496 — Path 1
/// fires (<c>sphere_intersects_solid</c>) when
/// <c>insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0</c>.
/// The <c>sphere_intersects_solid</c> test only returns COLLIDED if the player
/// sphere is fully inside a BSP solid leaf. For an open door (no solid wall at the
/// opening) it returns OK → player passes through.
/// </para>
///
/// <para>
/// Task 3 of the collision-inclusion verbatim-retail port (2026-06-24).
/// Retires divergence register row AD-7.
/// </para>
/// </summary>
public bool ObstructionEthereal;
/// <summary>
/// BR-7 / A6.P4 (2026-06-11). Retail <c>SPHEREPATH.bldg_check</c>: set
/// around the building-shell part test by
@ -2064,6 +2088,13 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
// D5 clear (Task 3, 2026-06-24): retail CEnvCell::find_env_collisions
// pc:309580 / 0x0052c144 clears obstruction_ethereal to 0 before any
// ENV BSP dispatch. ENV walls are always solid — the flag only applies
// to per-object (CPhysicsObj) tests in FindObjCollisions. This prevents
// a stale flag from a prior object loop from weakening cell wall collision.
sp.ObstructionEthereal = false;
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
@ -2519,6 +2550,16 @@ public sealed class Transition
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
continue;
// Task 3 (2026-06-24): set obstruction_ethereal on SpherePath before
// the shape dispatch. Retail: CPhysicsObj::FindObjCollisions pc:276806 /
// 0x0050f0c9 — `ebx->sphere_path.obstruction_ethereal = var_c` where
// var_c=1 when the target has ETHEREAL_PS (0x4). The flag is consumed by
// BSPQuery Path 1 (BSPTREE::find_collisions pc:323742) to route through
// sphere_intersects_solid instead of the blocking paths, making the player
// passable through the open door's BSP (no solid leaf at the opening).
// Cleared after each per-object test below (retail pc:276989 / 0x0050f31e).
sp.ObstructionEthereal = (obj.State & 0x4u) != 0;
// L.2a slice 3 (2026-05-12): snapshot collision-normal state so
// we can tell whether THIS object's BSP/CylSphere test produced a
// new collision (BSPQuery sets the normal but may still return OK
@ -2557,7 +2598,14 @@ public sealed class Transition
$"[bsp-test] obj=0x{obj.EntityId:X8} gfx=0x{obj.GfxObjId:X8} state=0x{obj.State:X8} radius={obj.Radius:F3} pos=({obj.Position.X:F2},{obj.Position.Y:F2},{obj.Position.Z:F2}) distXY={distXY:F3} cacheHit={cacheHit}"));
}
if (physics?.BSP?.Root is null) continue;
if (physics?.BSP?.Root is null)
{
// Clear obstruction_ethereal before skipping — retail's per-object
// clear (pc:276989) fires after shape tests; we clear early here to
// leave the flag clean for the next iteration.
sp.ObstructionEthereal = false;
continue;
}
// Transform player spheres to object-local space.
// For a scaled object (scenery tree, etc.), we need to
@ -2782,6 +2830,12 @@ public sealed class Transition
Console.WriteLine(sb.ToString());
}
// Task 3 (2026-06-24): clear obstruction_ethereal after the per-object
// shape dispatch. Mirrors retail pc:276989 / 0x0050f31e:
// `ebx->sphere_path.obstruction_ethereal = 0` at the end of the
// per-object test body before the outer loop continues.
sp.ObstructionEthereal = false;
if (result != TransitionState.OK)
{
if (airborneDiag)

View file

@ -42,16 +42,16 @@ public class CollisionExemptionTests
}
[Fact]
public void EtherealOnly_Skipped()
public void EtherealOnly_NotInstantSkipped()
{
// L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision.
// Retail (acclient_2013_pseudo_c.txt:276782) required both bits,
// but ACE's Door.Open() broadcasts ETHEREAL alone — observed
// live: state=0x0001000C (HasPhysicsBSP | Ethereal | ReportCollisions).
// Pragmatic shortcut: widen the early-out to ETHEREAL alone so
// doors become passable when ACE flips the bit. Retail-server
// broadcasts (state=0x14+) still hit the same branch correctly.
Assert.True(CollisionExemption.ShouldSkip(
// Task 3 (2026-06-24): AD-7 shim retired. ETHEREAL alone (0x4) no
// longer instant-skips — retail requires BOTH ETHEREAL_PS (0x4) AND
// IGNORE_COLLISIONS_PS (0x10) for the Gate-1 early-out.
// ETHEREAL-alone takes the obstruction_ethereal path: ShouldSkip
// returns false → sp.ObstructionEthereal = true → BSP Path 1 fires
// (sphere_intersects_solid) → open door is passable because its BSP
// has no solid leaf at the opening. Retail pc:276782 / 0x0050f067.
Assert.False(CollisionExemption.ShouldSkip(
targetState: ETHEREAL_PS,
targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsPlayer));

View file

@ -0,0 +1,99 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Unit tests for the <c>obstruction_ethereal</c> mechanism ported verbatim
/// from retail in Task 3 of the collision-inclusion phase.
///
/// <para>
/// Retail oracle: <c>CPhysicsObj::FindObjCollisions @ 0x0050f050</c>
/// (<c>acclient_2013_pseudo_c.txt:276782</c>):
/// <list type="bullet">
/// <item>pc:276782 — Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND
/// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip.</item>
/// <item>pc:276806 — ETHEREAL-alone sets <c>sphere_path.obstruction_ethereal = 1</c>
/// and continues to the shape dispatch.</item>
/// <item>pc:276989 — After the per-object shape test, retail clears
/// <c>obstruction_ethereal = 0</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// Consume site: <c>BSPTREE::find_collisions @ 0x0053a496</c>
/// (<c>acclient_2013_pseudo_c.txt:323742</c>): Path 1 fires when
/// <c>insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0</c>,
/// routing to <c>sphere_intersects_solid</c> (passable-ethereal) instead
/// of the blocking paths.
/// </para>
///
/// <para>
/// Retail divergence register: row AD-7 retired in this commit — the
/// ETHEREAL-alone shim is replaced by the faithful port.
/// </para>
/// </summary>
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);
}
// ── 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.
}