acdream/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
Erik dc1e927080 fix(physics): Task 3 follow-up — obstruction_ethereal consume for Cylinder+Sphere shapes
Retail oracle greps confirmed:
- CSphere::intersects_sphere @ 0x00537ae4 (pc:321692): the ethereal branch
  is `void __thiscall` — all paths return void (no COLLIDED). The function
  performs a proximity check only; no blocking result is produced.
- CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573): same void-return
  pattern — ethereal branch calls collides_with_sphere (check only, no slide),
  all returns are void = passable.

Change: added `if (sp.ObstructionEthereal) return TransitionState.OK` at the
top of SphereCollision and CylinderCollision in TransitionTypes.cs, mirroring
the void-return semantics of both retail functions. The existing per-object
clear at pc:276989 (line 2837) still fires after the early OK return.

Before this fix: an ethereal-alone NPC/ghost with a Cylinder or Sphere shadow
shape would BLOCK the player (regression introduced when Task 3 made ETHEREAL-
alone fall through ShouldSkip instead of instant-skipping). After: all three
shape types — BSP (via BSPQuery Path 1), Sphere, and Cylinder — correctly pass
through when obstruction_ethereal is set.

Tests: added 4 tests to ObstructionEtherealTests.cs verifying:
- Ethereal Cylinder → passable (sweep passes through, no CollisionNormalValid)
- Ethereal Sphere → passable (same)
- Non-ethereal Cylinder → still blocks (regression guard)
- Non-ethereal Sphere → still blocks (regression guard)
Full Core suite: 1584 pass, 0 fail, 2 skip (pre-existing dat skips).

Pseudocode doc updated with confirmed cyl/sphere ethereal contracts and the
complete set/clear/consume flow summary.

Retail refs:
- CSphere::intersects_sphere @ 0x00537ae4 / acclient_2013_pseudo_c.txt:321692
- CCylSphere::intersects_sphere @ 0x0053b4a0 / acclient_2013_pseudo_c.txt:324573

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:30:59 +02:00

9.3 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 implementation (Task 3 follow-up, 2026-06-24): CylinderCollision in TransitionTypes.cs now implements this gate as if (sp.ObstructionEthereal) return OK at the top of the method — mirroring exactly how SphereCollision (consume site 1) is handled. The retail function is void __thiscall, so all returns in the ethereal branch are void (= OK). An ethereal Cylinder (e.g. an NPC ghost) is now fully passable.


3b. Consume site 1 — CSphere::intersects_sphere (pc:321692) — full contract

The full decomp at pc:321692 / 0x537ae4 confirms:

// pc:321692:
if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT):
    // distSq check — if sphere NOT overlapping: return (void = passable)
    if (distSq < radiusSumSq):   // overlapping:
        // calls collides_with_sphere on sphere[1] if num_sphere > 1
        // returns void — no COLLIDED
    return  // void = passable in all cases
else:
    // step_down / check_walkable / slide blocking paths

Key: the CSphere::intersects_sphere function is void __thiscall — there is NO return 2 (COLLIDED) from the ethereal branch. The inner collides_with_sphere call (for the penetrating case) also produces no blocking result (it may contribute to a side-channel contact; it does NOT stop the player). The player is passable in ALL sub-cases of the ethereal branch.

acdream implementation (Task 3 follow-up, 2026-06-24): SphereCollision implements if (sp.ObstructionEthereal) return OK at the top. The inner overlap/slide path is bypassed — matches the void-return semantics of the retail ethereal branch.


Summary: set / clear / consume flow (complete after Task 3 follow-up)

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 — ALL three shapes now consume the flag:
        //   BSP:      BSPQuery.FindCollisions Path 1 → sphere_intersects_solid (passable)
        //   Sphere:   SphereCollision → if (ObstructionEthereal) return OK
        //   Cylinder: CylinderCollision → if (ObstructionEthereal) return OK

        // 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 (complete):

  • ETHEREAL-alone BSP target (e.g. open door): ShouldSkip = false → flag set → BSP Path 1 fires → sphere_intersects_solid → player walks through open door. Passable.
  • ETHEREAL-alone Sphere target (e.g. ghost NPC with sphere shape): flag set → SphereCollision returns OK immediately. Passable.
  • ETHEREAL-alone Cylinder target (e.g. ghost NPC with cyl shape): flag set → CylinderCollision returns OK immediately. Passable.
  • Non-ethereal wall/object: flag = false → normal blocking paths. Blocked.
  • ENV cell walls: flag cleared before BSP dispatch → ENV BSP always blocking. Blocked.