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

View file

@ -150,15 +150,43 @@ else:
...
```
**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.
**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.
---
## Summary: set / clear / consume flow for Task 3
## 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():
@ -170,10 +198,10 @@ FindObjCollisionsLoop():
// 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
// 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
@ -183,10 +211,12 @@ FindEnvCollisions():
// 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.
**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.

View file

@ -2986,6 +2986,15 @@ public sealed class Transition
/// </summary>
private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp)
{
// Consume site 1 — CSphere::intersects_sphere @ 0x00537ae4 (pc:321692).
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4),
// the retail function is void and all paths in the ethereal branch return
// without producing a COLLIDED/Slid result — the player is fully passable.
// We mirror this by returning OK immediately, skipping all blocking paths.
// Retail ref: acclient_2013_pseudo_c.txt:321692.
if (sp.ObstructionEthereal)
return TransitionState.OK;
var ci = CollisionInfo;
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
@ -3090,6 +3099,15 @@ public sealed class Transition
/// </summary>
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
{
// Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4),
// the retail function is void and all paths in the ethereal branch return
// without producing a COLLIDED/Slid result — the player is fully passable.
// We mirror this by returning OK immediately, skipping all blocking paths.
// Retail ref: acclient_2013_pseudo_c.txt:324573.
if (sp.ObstructionEthereal)
return TransitionState.OK;
var ci = CollisionInfo;
var oi = ObjectInfo;
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
@ -21,7 +22,7 @@ namespace AcDream.Core.Tests.Physics;
/// </para>
///
/// <para>
/// Consume site: <c>BSPTREE::find_collisions @ 0x0053a496</c>
/// Consume site 1: <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
@ -29,8 +30,34 @@ namespace AcDream.Core.Tests.Physics;
/// </para>
///
/// <para>
/// Retail divergence register: row AD-7 retired in this commit — the
/// ETHEREAL-alone shim is replaced by the faithful port.
/// Consume site 2: <c>CSphere::intersects_sphere @ 0x00537ae4</c>
/// (<c>acclient_2013_pseudo_c.txt:321692</c>): The ethereal branch performs
/// a proximity check only (no push-back, no COLLIDED return). All code
/// paths in the branch are void returns — the player is fully passable.
/// </para>
///
/// <para>
/// Consume site 3: <c>CCylSphere::intersects_sphere @ 0x0053b4a0</c>
/// (<c>acclient_2013_pseudo_c.txt:324573</c>): Same pattern — the ethereal
/// branch calls <c>collides_with_sphere</c> for an overlap check only, all
/// returns are void, no COLLIDED result — the player is fully passable.
/// </para>
///
/// <para>
/// Task 3 follow-up (2026-06-24): the Sphere and Cylinder consume sites
/// are now implemented in <c>SphereCollision</c> and <c>CylinderCollision</c>
/// in <c>TransitionTypes.cs</c>. The tests below verify:
/// <list type="bullet">
/// <item>Ethereal-alone Cylinder target → passable (no CollisionNormalValid).</item>
/// <item>Ethereal-alone Sphere target → passable (no CollisionNormalValid).</item>
/// <item>Non-ethereal Cylinder target → still blocks (regression guard).</item>
/// <item>Non-ethereal Sphere target → still blocks (regression guard).</item>
/// </list>
/// </para>
///
/// <para>
/// Retail divergence register: row AD-7 retired in the original Task 3 commit —
/// the ETHEREAL-alone shim is replaced by the faithful port.
/// </para>
/// </summary>
public class ObstructionEtherealTests
@ -87,6 +114,114 @@ public class ObstructionEtherealTests
Assert.True(sp.ObstructionEthereal);
}
// ── Cylinder shape: ethereal-alone → passable ─────────────────────────
//
// Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
// When obstruction_ethereal != 0, all code paths return void (no COLLIDED).
// acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the
// top of CylinderCollision, before any overlap/slide logic.
[Fact]
public void CylinderEtherealAlone_IsPassable()
{
// A Cylinder-type shadow with state=ETHEREAL_PS (0x4, no 0x10).
// Player sphere sweeps directly into it. Expect NO collision response
// (CollisionNormalValid must stay false throughout all 20 ticks).
// Retail ref: CCylSphere::intersects_sphere pc:324573 — ethereal branch
// does collides_with_sphere checks only (void return, never COLLIDED).
var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Cylinder,
objectState: ETHEREAL_PS,
objectPos: new Vector3(12f, 12f, 0f),
radius: 0.3f,
cylHeight: 1.5f);
// Approach dead-center from -Y, sweeping +Y through the cylinder.
var start = new Vector3(12f, 11f, 0.48f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20);
Assert.False(blocked,
$"Ethereal-alone Cylinder must be passable (no collision). " +
$"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}).");
}
[Fact]
public void CylinderNonEthereal_StillBlocks()
{
// Control: same Cylinder, state=0 (no ETHEREAL). Must still block.
// This guards the regression that the ethereal gate does NOT affect
// normal (non-ethereal) Cylinder objects.
var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Cylinder,
objectState: 0u, // no ethereal bit
objectPos: new Vector3(12f, 12f, 0f),
radius: 0.3f,
cylHeight: 1.5f);
var start = new Vector3(12f, 11f, 0.48f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20);
Assert.True(blocked,
$"Non-ethereal Cylinder must still block. " +
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks.");
}
// ── Sphere shape: ethereal-alone → passable ───────────────────────────
//
// Retail oracle: CSphere::intersects_sphere @ 0x00537ae4 (pc:321692).
// When obstruction_ethereal != 0, all code paths return void (no COLLIDED).
// acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the
// top of SphereCollision.
[Fact]
public void SphereEtherealAlone_IsPassable()
{
// A Sphere-type shadow with state=ETHEREAL_PS (0x4, no 0x10).
// Player sphere sweeps directly into it. Expect NO collision response.
// Retail ref: CSphere::intersects_sphere pc:321692 — ethereal branch
// does a proximity check only (void return, never COLLIDED / Slid).
var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Sphere,
objectState: ETHEREAL_PS,
objectPos: new Vector3(12f, 12f, 0.48f),
radius: 0.4f,
cylHeight: 0f);
var start = new Vector3(12f, 11f, 0.48f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20);
Assert.False(blocked,
$"Ethereal-alone Sphere must be passable (no collision). " +
$"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}).");
}
[Fact]
public void SphereNonEthereal_StillBlocks()
{
// Control: same Sphere, state=0 (no ETHEREAL). Must still block.
var engine = BuildEngineWithSingleShadow(
collisionType: ShadowCollisionType.Sphere,
objectState: 0u, // no ethereal bit
objectPos: new Vector3(12f, 12f, 0.48f),
radius: 0.4f,
cylHeight: 0f);
var start = new Vector3(12f, 11f, 0.48f);
var perTick = new Vector3(0f, 0.10f, 0f);
var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20);
Assert.True(blocked,
$"Non-ethereal Sphere must still block. " +
$"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks.");
}
// ── BSPQuery Path-1 gate: ObstructionEthereal routes through
// sphere_intersects_solid (not the blocking slide path) ──────────────
//
@ -96,4 +231,99 @@ public class ObstructionEtherealTests
// 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.
// ── Helpers ───────────────────────────────────────────────────────────
private const uint TestLandblockId = 0xA9B40000u;
private const uint TestCellId = TestLandblockId | 0x0001u;
private const uint TestEntityId = 0xF4201u;
private const uint TestGfxObjId = 0xF4202u;
private const float SphereRadius = 0.48f;
private const float SphereHeight = 1.20f;
private const float StepUpHeight = 0.60f;
private const float StepDownHeight = 0.04f;
/// <summary>
/// Build a minimal PhysicsEngine with a synthetic flat landblock
/// and a single registered shadow entry at <paramref name="objectPos"/>
/// with the given collision type and state bits.
/// No dat required — Cylinder and Sphere shapes need no GfxObj/BSP.
/// </summary>
private static PhysicsEngine BuildEngineWithSingleShadow(
ShadowCollisionType collisionType,
uint objectState,
Vector3 objectPos,
float radius,
float cylHeight)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// Stub landblock: flat terrain far below so it never interferes.
var heights = new byte[81];
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: System.Array.Empty<CellSurface>(),
portals: System.Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// Register the target shadow. Cylinder/Sphere shapes don't need a
// GfxObj BSP in the cache — FindObjCollisions only queries the cache
// for BSP collision types.
engine.ShadowObjects.Register(
entityId: TestEntityId,
gfxObjId: TestGfxObjId,
worldPos: objectPos,
rotation: System.Numerics.Quaternion.Identity,
radius: radius,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: TestLandblockId,
collisionType: collisionType,
cylHeight: cylHeight,
scale: 1.0f,
state: objectState,
flags: EntityCollisionFlags.None);
return engine;
}
/// <summary>
/// Sweep a player sphere by <paramref name="perTick"/> each step until
/// <c>CollisionNormalValid</c> fires or <paramref name="maxTicks"/> elapse.
/// </summary>
private static (bool blocked, Vector3 finalPos, Vector3 normal)
SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks)
{
Vector3 pos = start;
uint cellId = TestCellId;
bool onGround = false;
for (int tick = 0; tick < maxTicks; tick++)
{
Vector3 target = pos + perTick;
var result = engine.ResolveWithTransition(
pos, target, cellId,
SphereRadius, SphereHeight,
StepUpHeight, StepDownHeight,
onGround,
body: null,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0);
if (result.CollisionNormalValid)
return (true, result.Position, result.CollisionNormal);
pos = result.Position;
cellId = result.CellId;
onGround = result.IsOnGround;
}
return (false, pos, Vector3.Zero);
}
}