fix(phys L.2g slice 1b): widen CollisionExemption to ETHEREAL alone

B.4b visual test confirmed the L.2g slice 1 handoff's open question:
ACE's Door.Open() broadcasts state=0x0001000C (HasPhysicsBSP |
Ethereal | ReportCollisions), NOT the state=0x14+ that retail servers
send (Ethereal | IgnoreCollisions). The L.2g pipeline correctly
mutates ShadowObjectRegistry with the new state, but
CollisionExemption.ShouldSkip required both bits and the door stayed
solid.

Retail (acclient_2013_pseudo_c.txt:276782) wraps FindObjCollisions in
`if NOT (state & ETHEREAL && state & IGNORE_COLLISIONS)`. ETHEREAL
alone takes a different retail path at line 276795 that sets
sphere_path.obstruction_ethereal = 1 and lets downstream movement
allow passage despite the contact. We haven't ported that downstream
path yet.

Pragmatic shortcut: widen the early-out to ETHEREAL alone so doors
become passable when ACE flips the bit. Retail-server broadcasts
still hit the same branch correctly (both bits set implies ETHEREAL).
Compatible with both server styles.

Renames test EtherealOnly_NotSkipped -> EtherealOnly_Skipped and
flips its assertion. 13 CollisionExemption tests pass; full suite
1046 pass / 8 pre-existing baseline fail (unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-13 18:27:06 +02:00
parent 58b95bc0c5
commit a6e4b5709f
2 changed files with 26 additions and 12 deletions

View file

@ -59,14 +59,24 @@ public static class CollisionExemption
public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags, public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags,
ObjectInfoState moverState) ObjectInfoState moverState)
{ {
// 1. Target ETHEREAL + IGNORE_COLLISIONS → walk through. // 1. Target ETHEREAL → walk through.
// acclient_2013_pseudo_c.txt:276782 — wraps the entire body of // Retail (acclient_2013_pseudo_c.txt:276782) requires BOTH
// FindObjCollisions; we hoist it as the first early-out. // ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) to wrap
if ((targetState & ETHEREAL_PS) != 0 // the entire body of FindObjCollisions and skip collision.
&& (targetState & IGNORE_COLLISIONS_PS) != 0) // 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; return true;
}
// 2. Viewer mover + creature target → walk through. // 2. Viewer mover + creature target → walk through.
// acclient_2013_pseudo_c.txt:276787-276790. // acclient_2013_pseudo_c.txt:276787-276790.

View file

@ -42,12 +42,16 @@ public class CollisionExemptionTests
} }
[Fact] [Fact]
public void EtherealOnly_NotSkipped() public void EtherealOnly_Skipped()
{ {
// Target with ETHEREAL but NOT IGNORE_COLLISIONS does not bail // L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision.
// out at the first gate — collision proceeds. (Step-down marks // Retail (acclient_2013_pseudo_c.txt:276782) required both bits,
// obstruction_ethereal, but does not exempt.) // but ACE's Door.Open() broadcasts ETHEREAL alone — observed
Assert.False(CollisionExemption.ShouldSkip( // 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, targetState: ETHEREAL_PS,
targetFlags: EntityCollisionFlags.None, targetFlags: EntityCollisionFlags.None,
moverState: ObjectInfoState.IsPlayer)); moverState: ObjectInfoState.IsPlayer));