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>
222 lines
9.3 KiB
Markdown
222 lines
9.3 KiB
Markdown
# 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 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.
|