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

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