acdream/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
Erik 3361a8d776 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>
2026-06-24 19:20:02 +02:00

7.8 KiB

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:

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:

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.