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>
This commit is contained in:
Erik 2026-06-24 19:20:02 +02:00
parent 78e5758185
commit 3361a8d776
7 changed files with 382 additions and 30 deletions

View file

@ -71,7 +71,6 @@ accepted-divergence entries (#96, #49, #50).
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects``recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) |
| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival |
| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) |
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |

View file

@ -0,0 +1,192 @@
# 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 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.