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>
204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="CollisionExemption"/> — Commit C of the
|
|
/// 2026-04-29 live-entity collision port. Covers retail's
|
|
/// <c>CPhysicsObj::FindObjCollisions</c> exemption block, ported
|
|
/// line-for-line from
|
|
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:276782-276839,276971</c>.
|
|
///
|
|
/// <para>
|
|
/// Behaviour matrix (target / mover columns):
|
|
/// </para>
|
|
/// <list type="table">
|
|
/// <listheader><term>Mover</term><term>Target</term><description>Skip?</description></listheader>
|
|
/// <item><term>any</term><term>ETHEREAL+IGNORE_COLLISIONS</term><description>YES (early-out)</description></item>
|
|
/// <item><term>IsViewer</term><term>IsCreature</term><description>YES (camera ray-test passes through)</description></item>
|
|
/// <item><term>IGNORE_CREATURES</term><term>IsCreature</term><description>YES (mover walks through creatures)</description></item>
|
|
/// <item><term>IsPlayer</term><term>IsPlayer (no PK)</term><description>YES (non-PK pair walks through)</description></item>
|
|
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer + IsPK</term><description>NO (PK pair collides)</description></item>
|
|
/// <item><term>IsPlayer + IsPKLite</term><term>IsPlayer + IsPKLite</term><description>NO (PKLite pair collides)</description></item>
|
|
/// <item><term>IsPlayer</term><term>IsPlayer + IsImpenetrable</term><description>NO (Impenetrable target always collides)</description></item>
|
|
/// <item><term>IsPlayer + IsPK</term><term>IsPlayer (no PK)</term><description>YES (mismatched PK skip)</description></item>
|
|
/// <item><term>IsPlayer</term><term>IsCreature (NPC)</term><description>NO (player vs NPC always collides)</description></item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class CollisionExemptionTests
|
|
{
|
|
private const uint ETHEREAL_PS = 0x4u;
|
|
private const uint IGNORE_COLLISIONS_PS = 0x10u;
|
|
|
|
[Fact]
|
|
public void EtherealAndIgnoreCollisions_AlwaysSkipped()
|
|
{
|
|
// Target with both bits set is exempted from any mover.
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS,
|
|
targetFlags: EntityCollisionFlags.None,
|
|
moverState: ObjectInfoState.IsPlayer));
|
|
}
|
|
|
|
[Fact]
|
|
public void EtherealOnly_NotInstantSkipped()
|
|
{
|
|
// Task 3 (2026-06-24): AD-7 shim retired. ETHEREAL alone (0x4) no
|
|
// longer instant-skips — retail requires BOTH ETHEREAL_PS (0x4) AND
|
|
// IGNORE_COLLISIONS_PS (0x10) for the Gate-1 early-out.
|
|
// ETHEREAL-alone takes the obstruction_ethereal path: ShouldSkip
|
|
// returns false → sp.ObstructionEthereal = true → BSP Path 1 fires
|
|
// (sphere_intersects_solid) → open door is passable because its BSP
|
|
// has no solid leaf at the opening. Retail pc:276782 / 0x0050f067.
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: ETHEREAL_PS,
|
|
targetFlags: EntityCollisionFlags.None,
|
|
moverState: ObjectInfoState.IsPlayer));
|
|
}
|
|
|
|
[Fact]
|
|
public void Viewer_VsCreature_Skipped()
|
|
{
|
|
// Camera-ray viewer transitions through creatures.
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsCreature,
|
|
moverState: ObjectInfoState.IsViewer));
|
|
}
|
|
|
|
[Fact]
|
|
public void Viewer_VsNonCreature_NotSkipped()
|
|
{
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.None,
|
|
moverState: ObjectInfoState.IsViewer));
|
|
}
|
|
|
|
[Fact]
|
|
public void IgnoreCreatures_VsCreature_Skipped()
|
|
{
|
|
// Per acclient_2013_pseudo_c.txt:276971 — an arrow with
|
|
// IGNORE_CREATURES doesn't get blocked by the very monster it's
|
|
// tracking towards (until missile_ignore filters its target).
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsCreature,
|
|
moverState: ObjectInfoState.IgnoreCreatures));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonPkPlayer_VsNonPkPlayer_Skipped()
|
|
{
|
|
// The user-visible payoff: two ordinary players walk through each
|
|
// other instead of blocking.
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer,
|
|
moverState: ObjectInfoState.IsPlayer));
|
|
}
|
|
|
|
[Fact]
|
|
public void Pk_VsPk_NotSkipped()
|
|
{
|
|
// Two PK players collide.
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPK,
|
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
|
}
|
|
|
|
[Fact]
|
|
public void PkLite_VsPkLite_NotSkipped()
|
|
{
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
|
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPKLite));
|
|
}
|
|
|
|
[Fact]
|
|
public void Pk_VsNonPk_Skipped()
|
|
{
|
|
// Mismatched PK status: still exempt — only matching pair collides.
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer,
|
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
|
}
|
|
|
|
[Fact]
|
|
public void Pk_VsPkLite_Skipped()
|
|
{
|
|
// PK and PKLite are different pools — pair doesn't match.
|
|
Assert.True(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsPKLite,
|
|
moverState: ObjectInfoState.IsPlayer | ObjectInfoState.IsPK));
|
|
}
|
|
|
|
[Fact]
|
|
public void ImpenetrableTarget_VsAnyPlayer_NotSkipped()
|
|
{
|
|
// Impenetrable target ("Free" PK status) always collides with
|
|
// any player mover — regardless of mover's PK state.
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsImpenetrable,
|
|
moverState: ObjectInfoState.IsPlayer));
|
|
}
|
|
|
|
[Fact]
|
|
public void Player_VsCreature_NotSkipped()
|
|
{
|
|
// PvP exemption only applies player-on-player. Player vs creature
|
|
// (NPC, monster) is the normal blocking case.
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
targetFlags: EntityCollisionFlags.IsCreature,
|
|
moverState: ObjectInfoState.IsPlayer));
|
|
}
|
|
|
|
[Fact]
|
|
public void NonPlayerMover_VsPlayer_NotSkipped()
|
|
{
|
|
// PvP rule requires BOTH to be players. Mover is not a player
|
|
// (e.g., dead-reckoned remote NPC) → no exemption applies.
|
|
Assert.False(CollisionExemption.ShouldSkip(
|
|
targetState: 0u,
|
|
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));
|
|
}
|
|
}
|