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

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.