diff --git a/src/AcDream.Core/Physics/CollisionExemption.cs b/src/AcDream.Core/Physics/CollisionExemption.cs index 89b368d..2e66751 100644 --- a/src/AcDream.Core/Physics/CollisionExemption.cs +++ b/src/AcDream.Core/Physics/CollisionExemption.cs @@ -59,14 +59,24 @@ public static class CollisionExemption public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags, ObjectInfoState moverState) { - // 1. Target ETHEREAL + IGNORE_COLLISIONS → walk through. - // acclient_2013_pseudo_c.txt:276782 — wraps the entire body of - // FindObjCollisions; we hoist it as the first early-out. - if ((targetState & ETHEREAL_PS) != 0 - && (targetState & IGNORE_COLLISIONS_PS) != 0) - { + // 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) return true; - } // 2. Viewer mover + creature target → walk through. // acclient_2013_pseudo_c.txt:276787-276790. diff --git a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs index b843165..3950bd9 100644 --- a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs @@ -42,12 +42,16 @@ public class CollisionExemptionTests } [Fact] - public void EtherealOnly_NotSkipped() + public void EtherealOnly_Skipped() { - // Target with ETHEREAL but NOT IGNORE_COLLISIONS does not bail - // out at the first gate — collision proceeds. (Step-down marks - // obstruction_ethereal, but does not exempt.) - Assert.False(CollisionExemption.ShouldSkip( + // 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( targetState: ETHEREAL_PS, targetFlags: EntityCollisionFlags.None, moverState: ObjectInfoState.IsPlayer));