From dc1e92708051409e68e4bc33b508e3f543c6765a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 19:30:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20Task=203=20follow-up=20?= =?UTF-8?q?=E2=80=94=20obstruction=5Fethereal=20consume=20for=20Cylinder+S?= =?UTF-8?q?phere=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-06-24-obstruction-ethereal-pseudocode.md | 64 +++-- src/AcDream.Core/Physics/TransitionTypes.cs | 18 ++ .../Physics/ObstructionEtherealTests.cs | 236 +++++++++++++++++- 3 files changed, 298 insertions(+), 20 deletions(-) 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); + } }