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>
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#):
ShouldSkipreturnstrueONLY when(targetState & 0x4) != 0 && (targetState & 0x10) != 0.- ETHEREAL-alone falls through. Before the shape dispatch (BSP/Sphere/Cyl call), set
sp.ObstructionEthereal = truewhen(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 →
SphereCollisionreturns OK immediately. Passable. - ETHEREAL-alone Cylinder target (e.g. ghost NPC with cyl shape): flag set →
CylinderCollisionreturns 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.