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:
parent
78e5758185
commit
3361a8d776
7 changed files with 382 additions and 30 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
192
docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
Normal file
192
docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
Normal 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.
|
||||
|
|
@ -1712,9 +1712,20 @@ public static class BSPQuery
|
|||
Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Path 1: Placement or Ethereal → sphere_intersects_solid
|
||||
// Path 1: Placement or obstruction_ethereal → sphere_intersects_solid
|
||||
// ----------------------------------------------------------------
|
||||
if (path.InsertType == InsertType.Placement || obj.Ethereal)
|
||||
// Retail BSPTREE::find_collisions pc:323742 / 0x0053a496:
|
||||
// `if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0)`
|
||||
// obstruction_ethereal is a per-object flag set by FindObjCollisions
|
||||
// (pc:276806) when the target is ETHEREAL-alone (ETHEREAL_PS=0x4 set,
|
||||
// IGNORE_COLLISIONS_PS=0x10 NOT set). When set, Path 1 fires instead of
|
||||
// the blocking paths: sphere_intersects_solid only returns COLLIDED if
|
||||
// the player sphere is inside a BSP solid leaf. For an open door (no
|
||||
// solid wall at the doorway), no solid leaf intersects → OK → passable.
|
||||
// Previously obj.Ethereal (ObjectInfo.Ethereal, always false) was here —
|
||||
// the correct flag is SpherePath.ObstructionEthereal, set per target.
|
||||
// Task 3 (2026-06-24) / retail divergence register AD-7 retired.
|
||||
if (path.InsertType == InsertType.Placement || path.ObstructionEthereal)
|
||||
{
|
||||
// BR-7 / A6.P4 (2026-06-11): retail weakens the solid test
|
||||
// against BUILDING shells while the path engages interior cells
|
||||
|
|
|
|||
|
|
@ -59,23 +59,20 @@ public static class CollisionExemption
|
|||
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
|
||||
ObjectInfoState moverState)
|
||||
{
|
||||
// 1. Target ETHEREAL → walk through.
|
||||
// Retail (acclient_2013_pseudo_c.txt:276782) requires BOTH
|
||||
// ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) to wrap
|
||||
// the entire body of FindObjCollisions and skip collision.
|
||||
// ETHEREAL alone takes a different retail path (line 276795
|
||||
// sets sphere_path.obstruction_ethereal = 1 and downstream
|
||||
// movement allows passage despite the contact). We haven't
|
||||
// ported that downstream path yet.
|
||||
//
|
||||
// L.2g slice 1b (2026-05-13): ACE's Door.Open() sends only
|
||||
// ETHEREAL (state=0x0001000C observed live), not the
|
||||
// ETHEREAL|IGNORE_COLLISIONS combo retail servers broadcast.
|
||||
// Pragmatic shortcut: exempt on ETHEREAL alone so doors
|
||||
// become passable when ACE flips the bit. Retail-server
|
||||
// broadcasts (state=0x14+) still hit this branch correctly
|
||||
// because both bits set implies ETHEREAL set.
|
||||
if ((targetState & ETHEREAL_PS) != 0)
|
||||
// 1. Target ETHEREAL + IGNORE_COLLISIONS → instant-skip.
|
||||
// Retail (acclient_2013_pseudo_c.txt:276782):
|
||||
// `if ((state & 4) AND (state & 0x10)) return 1`
|
||||
// BOTH bits are required. ETHEREAL-alone takes the retail
|
||||
// `obstruction_ethereal` path instead (pc:276806): the flag is
|
||||
// set to 1 on the SpherePath and the shape test still runs,
|
||||
// but BSP Path 1 (sphere_intersects_solid) weakens solid-
|
||||
// containment so the player passes through the open door.
|
||||
// ACE's Door.Open() broadcasts ETHEREAL only (0x0001000C) —
|
||||
// this faithful port makes open doors passable via the BSP
|
||||
// sphere_intersects_solid path (no solid leaf at the opening),
|
||||
// which subsumes the former AD-7 shim. Divergence register
|
||||
// row AD-7 retired in the same commit as this change.
|
||||
if ((targetState & ETHEREAL_PS) != 0 && (targetState & IGNORE_COLLISIONS_PS) != 0)
|
||||
return true;
|
||||
|
||||
// 2. Viewer mover + creature target → walk through.
|
||||
|
|
|
|||
|
|
@ -382,6 +382,30 @@ public sealed class SpherePath
|
|||
public bool CheckWalkable;
|
||||
public InsertType InsertType = InsertType.Transition;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SPHEREPATH.obstruction_ethereal</c>: set to <c>1</c> per-target
|
||||
/// inside <c>CPhysicsObj::FindObjCollisions</c> (pc:276806 / 0x0050f0c9) when
|
||||
/// the target object is ETHEREAL-alone (ETHEREAL_PS=0x4 set, IGNORE_COLLISIONS_PS
|
||||
/// 0x10 NOT set). Cleared after each per-object shape test (pc:276989 / 0x0050f31e)
|
||||
/// and also cleared at the top of <c>CEnvCell::find_env_collisions</c>
|
||||
/// (pc:309580 / 0x0052c144 — the "D5 clear") so ENV walls are never weakened.
|
||||
///
|
||||
/// <para>
|
||||
/// Consume site: <c>BSPTREE::find_collisions</c> pc:323742 / 0x0053a496 — Path 1
|
||||
/// fires (<c>sphere_intersects_solid</c>) when
|
||||
/// <c>insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0</c>.
|
||||
/// The <c>sphere_intersects_solid</c> test only returns COLLIDED if the player
|
||||
/// sphere is fully inside a BSP solid leaf. For an open door (no solid wall at the
|
||||
/// opening) it returns OK → player passes through.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Task 3 of the collision-inclusion verbatim-retail port (2026-06-24).
|
||||
/// Retires divergence register row AD-7.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool ObstructionEthereal;
|
||||
|
||||
/// <summary>
|
||||
/// BR-7 / A6.P4 (2026-06-11). Retail <c>SPHEREPATH.bldg_check</c>: set
|
||||
/// around the building-shell part test by
|
||||
|
|
@ -2064,6 +2088,13 @@ public sealed class Transition
|
|||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
// D5 clear (Task 3, 2026-06-24): retail CEnvCell::find_env_collisions
|
||||
// pc:309580 / 0x0052c144 clears obstruction_ethereal to 0 before any
|
||||
// ENV BSP dispatch. ENV walls are always solid — the flag only applies
|
||||
// to per-object (CPhysicsObj) tests in FindObjCollisions. This prevents
|
||||
// a stale flag from a prior object loop from weakening cell wall collision.
|
||||
sp.ObstructionEthereal = false;
|
||||
|
||||
Vector3 footCenter = sp.GlobalSphere[0].Origin;
|
||||
float sphereRadius = sp.GlobalSphere[0].Radius;
|
||||
|
||||
|
|
@ -2519,6 +2550,16 @@ public sealed class Transition
|
|||
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
|
||||
continue;
|
||||
|
||||
// Task 3 (2026-06-24): set obstruction_ethereal on SpherePath before
|
||||
// the shape dispatch. Retail: CPhysicsObj::FindObjCollisions pc:276806 /
|
||||
// 0x0050f0c9 — `ebx->sphere_path.obstruction_ethereal = var_c` where
|
||||
// var_c=1 when the target has ETHEREAL_PS (0x4). The flag is consumed by
|
||||
// BSPQuery Path 1 (BSPTREE::find_collisions pc:323742) to route through
|
||||
// sphere_intersects_solid instead of the blocking paths, making the player
|
||||
// passable through the open door's BSP (no solid leaf at the opening).
|
||||
// Cleared after each per-object test below (retail pc:276989 / 0x0050f31e).
|
||||
sp.ObstructionEthereal = (obj.State & 0x4u) != 0;
|
||||
|
||||
// L.2a slice 3 (2026-05-12): snapshot collision-normal state so
|
||||
// we can tell whether THIS object's BSP/CylSphere test produced a
|
||||
// new collision (BSPQuery sets the normal but may still return OK
|
||||
|
|
@ -2557,7 +2598,14 @@ public sealed class Transition
|
|||
$"[bsp-test] obj=0x{obj.EntityId:X8} gfx=0x{obj.GfxObjId:X8} state=0x{obj.State:X8} radius={obj.Radius:F3} pos=({obj.Position.X:F2},{obj.Position.Y:F2},{obj.Position.Z:F2}) distXY={distXY:F3} cacheHit={cacheHit}"));
|
||||
}
|
||||
|
||||
if (physics?.BSP?.Root is null) continue;
|
||||
if (physics?.BSP?.Root is null)
|
||||
{
|
||||
// Clear obstruction_ethereal before skipping — retail's per-object
|
||||
// clear (pc:276989) fires after shape tests; we clear early here to
|
||||
// leave the flag clean for the next iteration.
|
||||
sp.ObstructionEthereal = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform player spheres to object-local space.
|
||||
// For a scaled object (scenery tree, etc.), we need to
|
||||
|
|
@ -2782,6 +2830,12 @@ public sealed class Transition
|
|||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
// Task 3 (2026-06-24): clear obstruction_ethereal after the per-object
|
||||
// shape dispatch. Mirrors retail pc:276989 / 0x0050f31e:
|
||||
// `ebx->sphere_path.obstruction_ethereal = 0` at the end of the
|
||||
// per-object test body before the outer loop continues.
|
||||
sp.ObstructionEthereal = false;
|
||||
|
||||
if (result != TransitionState.OK)
|
||||
{
|
||||
if (airborneDiag)
|
||||
|
|
|
|||
|
|
@ -42,16 +42,16 @@ public class CollisionExemptionTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void EtherealOnly_Skipped()
|
||||
public void EtherealOnly_NotInstantSkipped()
|
||||
{
|
||||
// L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision.
|
||||
// Retail (acclient_2013_pseudo_c.txt:276782) required both bits,
|
||||
// but ACE's Door.Open() broadcasts ETHEREAL alone — observed
|
||||
// live: state=0x0001000C (HasPhysicsBSP | Ethereal | ReportCollisions).
|
||||
// Pragmatic shortcut: widen the early-out to ETHEREAL alone so
|
||||
// doors become passable when ACE flips the bit. Retail-server
|
||||
// broadcasts (state=0x14+) still hit the same branch correctly.
|
||||
Assert.True(CollisionExemption.ShouldSkip(
|
||||
// Task 3 (2026-06-24): AD-7 shim retired. ETHEREAL alone (0x4) no
|
||||
// longer instant-skips — retail requires BOTH ETHEREAL_PS (0x4) AND
|
||||
// IGNORE_COLLISIONS_PS (0x10) for the Gate-1 early-out.
|
||||
// ETHEREAL-alone takes the obstruction_ethereal path: ShouldSkip
|
||||
// returns false → sp.ObstructionEthereal = true → BSP Path 1 fires
|
||||
// (sphere_intersects_solid) → open door is passable because its BSP
|
||||
// has no solid leaf at the opening. Retail pc:276782 / 0x0050f067.
|
||||
Assert.False(CollisionExemption.ShouldSkip(
|
||||
targetState: ETHEREAL_PS,
|
||||
targetFlags: EntityCollisionFlags.None,
|
||||
moverState: ObjectInfoState.IsPlayer));
|
||||
|
|
|
|||
99
tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs
Normal file
99
tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <c>obstruction_ethereal</c> mechanism ported verbatim
|
||||
/// from retail in Task 3 of the collision-inclusion phase.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail oracle: <c>CPhysicsObj::FindObjCollisions @ 0x0050f050</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:276782</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item>pc:276782 — Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND
|
||||
/// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip.</item>
|
||||
/// <item>pc:276806 — ETHEREAL-alone sets <c>sphere_path.obstruction_ethereal = 1</c>
|
||||
/// and continues to the shape dispatch.</item>
|
||||
/// <item>pc:276989 — After the per-object shape test, retail clears
|
||||
/// <c>obstruction_ethereal = 0</c>.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Consume site: <c>BSPTREE::find_collisions @ 0x0053a496</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:323742</c>): Path 1 fires when
|
||||
/// <c>insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0</c>,
|
||||
/// routing to <c>sphere_intersects_solid</c> (passable-ethereal) instead
|
||||
/// of the blocking paths.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail divergence register: row AD-7 retired in this commit — the
|
||||
/// ETHEREAL-alone shim is replaced by the faithful port.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ObstructionEtherealTests
|
||||
{
|
||||
private const uint ETHEREAL_PS = 0x4u;
|
||||
private const uint IGNORE_COLLISIONS_PS = 0x10u;
|
||||
|
||||
// ── Gate-1 tests: ShouldSkip dual-bit contract ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ShouldSkip_BothBits_InstantSkip()
|
||||
{
|
||||
// ETHEREAL | IGNORE_COLLISIONS together → instant-skip (Gate 1 fires).
|
||||
// Retail pc:276782: `if ((state & 4) AND (state & 0x10)) return 1`.
|
||||
Assert.True(CollisionExemption.ShouldSkip(
|
||||
targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS,
|
||||
targetFlags: EntityCollisionFlags.None,
|
||||
moverState: ObjectInfoState.IsPlayer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldSkip_EtherealAlone_NotInstantSkip()
|
||||
{
|
||||
// ETHEREAL alone (0x4) must NOT instant-skip — retail takes the
|
||||
// obstruction_ethereal branch instead. The old AD-7 shim is retired.
|
||||
// Retail pc:276782: only the COMBINED gate returns early; ETHEREAL-alone
|
||||
// falls through to set obstruction_ethereal and run the shape test.
|
||||
Assert.False(CollisionExemption.ShouldSkip(
|
||||
targetState: ETHEREAL_PS,
|
||||
targetFlags: EntityCollisionFlags.None,
|
||||
moverState: ObjectInfoState.IsPlayer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldSkip_IgnoreCollisionsAlone_NotSkipped()
|
||||
{
|
||||
// IGNORE_COLLISIONS alone (0x10) without ETHEREAL → not instant-skipped;
|
||||
// falls through to shape dispatch (no obstruction_ethereal set either).
|
||||
Assert.False(CollisionExemption.ShouldSkip(
|
||||
targetState: IGNORE_COLLISIONS_PS,
|
||||
targetFlags: EntityCollisionFlags.None,
|
||||
moverState: ObjectInfoState.IsPlayer));
|
||||
}
|
||||
|
||||
// ── SpherePath.ObstructionEthereal field ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SpherePath_HasObstructionEtherealField()
|
||||
{
|
||||
// Compile-time check that the field was added to SpherePath.
|
||||
var sp = new SpherePath();
|
||||
Assert.False(sp.ObstructionEthereal); // default is false
|
||||
sp.ObstructionEthereal = true;
|
||||
Assert.True(sp.ObstructionEthereal);
|
||||
}
|
||||
|
||||
// ── BSPQuery Path-1 gate: ObstructionEthereal routes through
|
||||
// sphere_intersects_solid (not the blocking slide path) ──────────────
|
||||
//
|
||||
// A full transition-level harness for the BSP path requires a real
|
||||
// BSP tree fixture and is deferred to the DoorCollisionApparatusTests
|
||||
// integration tests. Here we verify the GATE condition in isolation
|
||||
// via the FindEnvCollisions D5 clear and the SpherePath property
|
||||
// round-trip. The DoorCollisionApparatusTests and CellarUpTrajectory-
|
||||
// ReplayTests are the regression guards for the consume-site change.
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue