fix(physics): #299 — port retail's mover-side IsImpenetrable exemption branch

CollisionExemption checked only the TARGET's IsImpenetrable, and the class doc
asserted "retail's pseudo-C only checks the target's IsImpenetrable(); acdream
follows retail" while blaming ACE for checking both. That was backwards: ACE was
retail-faithful and acdream was missing half the check.

Retail short-circuits on EITHER the mover's own state & IS_IMPENETRABLE (0x80)
OR the target's IsImpenetrable(); either alone exempts. Verified at the byte
level rather than from the decompiler's rendering — Binary Ninja shows the mover
test as `int16_t state_1 ... if (state_1 < 0)`, which reads like a 0x8000 test,
but decoding the PDB-paired binary at the mapped offset gives:

    8b 43 04   mov  eax,[ebx+4]     ; mover object_info.state
    f6 c4 01   test ah,1            ; 0x100  IsPlayer
    84 c0      test al,al           ; sign bit of AL = state & 0x80
    78 3d      js   ...             ; -> collide

`test al, al; js` is a byte-level sign test on AL, i.e. 0x80, not 0x8000.
Corroborated downstream in the same block (`test ah,8` = 0x800 IsPK,
`test ah,0x10` = 0x1000 IsPKLite) and by OBJECTINFO::init @0x0050cf30 setting
state |= 0x80 from the object's own IsImpenetrable().

Also corrected: ACCWeenieObject::IsImpenetrable @0x0058c8c0 returns
(_bitfield >> 0x15) & 1 — retail genuinely conflates BF_FREE_PKSTATUS with
"impenetrable", so acdream's FromPwdBitfield decode was already right.

Both retail arms set collide, so ordering between them is semantically free and
a misreading here could only ever produce spurious collisions, never a
walk-through.

Found while investigating #297; not symptom-causing on its own. No divergence
row: this retires a missing port rather than introducing a deviation, and
nothing in the register or the collision digest's DO-NOT-RETRY tables covers it.

Gates: complete Release solution 10,887 passed / 4 skipped / 0 failed
(baseline 10,867/4/0). Adversarial + retail-conformance review PASS on this
change specifically. Both new tests discrimination-verified by reverting the
branch and confirming failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 20:13:05 +02:00
parent 40f5721354
commit 88348f6791
2 changed files with 54 additions and 7 deletions

View file

@ -170,4 +170,35 @@ public class CollisionExemptionTests
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.None));
}
[Fact]
public void ImpenetrableMover_VsOrdinaryPlayer_NotSkipped()
{
// #299: retail's pseudo-C (acclient_2013_pseudo_c.txt:276824-276827)
// disqualifies the tentative PvP exemption when the MOVER itself
// carries state&0x80 (IS_IMPENETRABLE) — not just the target's
// IsImpenetrable(). Without this branch an Impenetrable ("Free" PK
// status) mover would incorrectly walk through an ordinary
// (non-PK, non-Impenetrable) player target, the mirror image of
// ImpenetrableTarget_VsAnyPlayer_NotSkipped above.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsImpenetrable));
}
[Fact]
public void ImpenetrableMover_StillCollidesEvenWhenAlsoPk()
{
// The mover-impenetrable disqualifier (4a) is checked before the
// PK/PKLite arms (4b/4c) but must not depend on them — an
// Impenetrable mover collides with a plain player target even
// though neither side is PK/PKLite.
Assert.False(CollisionExemption.ShouldSkip(
targetState: 0u,
targetFlags: EntityCollisionFlags.IsPlayer,
moverState: ObjectInfoState.IsPlayer
| ObjectInfoState.IsImpenetrable
| ObjectInfoState.IsPK));
}
}