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,
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.

View file

@ -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));