diff --git a/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md b/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
index afac7225..6f43a3c5 100644
--- a/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
+++ b/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md
@@ -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.
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 4cad9c02..b40dd811 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2986,6 +2986,15 @@ public sealed class Transition
///
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
///
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;
diff --git a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs b/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs
index 462b1909..4de96c49 100644
--- a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
@@ -21,7 +22,7 @@ namespace AcDream.Core.Tests.Physics;
///
///
///
-/// Consume site: BSPTREE::find_collisions @ 0x0053a496
+/// Consume site 1: BSPTREE::find_collisions @ 0x0053a496
/// (acclient_2013_pseudo_c.txt:323742): Path 1 fires when
/// insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0,
/// routing to sphere_intersects_solid (passable-ethereal) instead
@@ -29,8 +30,34 @@ namespace AcDream.Core.Tests.Physics;
///
///
///
-/// Retail divergence register: row AD-7 retired in this commit — the
-/// ETHEREAL-alone shim is replaced by the faithful port.
+/// Consume site 2: CSphere::intersects_sphere @ 0x00537ae4
+/// (acclient_2013_pseudo_c.txt:321692): 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.
+///
+///
+///
+/// Consume site 3: CCylSphere::intersects_sphere @ 0x0053b4a0
+/// (acclient_2013_pseudo_c.txt:324573): Same pattern — the ethereal
+/// branch calls collides_with_sphere for an overlap check only, all
+/// returns are void, no COLLIDED result — the player is fully passable.
+///
+///
+///
+/// Task 3 follow-up (2026-06-24): the Sphere and Cylinder consume sites
+/// are now implemented in SphereCollision and CylinderCollision
+/// in TransitionTypes.cs. The tests below verify:
+///
+/// - Ethereal-alone Cylinder target → passable (no CollisionNormalValid).
+/// - Ethereal-alone Sphere target → passable (no CollisionNormalValid).
+/// - Non-ethereal Cylinder target → still blocks (regression guard).
+/// - Non-ethereal Sphere target → still blocks (regression guard).
+///
+///
+///
+///
+/// Retail divergence register: row AD-7 retired in the original Task 3 commit —
+/// the ETHEREAL-alone shim is replaced by the faithful port.
///
///
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;
+
+ ///
+ /// Build a minimal PhysicsEngine with a synthetic flat landblock
+ /// and a single registered shadow entry at
+ /// with the given collision type and state bits.
+ /// No dat required — Cylinder and Sphere shapes need no GfxObj/BSP.
+ ///
+ 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(),
+ portals: System.Array.Empty(),
+ 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;
+ }
+
+ ///
+ /// Sweep a player sphere by each step until
+ /// CollisionNormalValid fires or elapse.
+ ///
+ 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);
+ }
}