diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index d3c25c4..8084231 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -413,7 +413,13 @@ public sealed class PlayerMovementController stepUpHeight: StepUpHeight, stepDownHeight: 0.04f, // retail default isOnGround: _body.OnWalkable, - body: _body); // persist ContactPlane across frames for slope tracking + body: _body, // persist ContactPlane across frames for slope tracking + // Commit C 2026-04-29 — local player is always IsPlayer. + // The PK/PKLite/Impenetrable bits come from PlayerDescription's + // PlayerKillerStatus property; not yet parsed (non-PK pair → walks + // through other non-PK players, which is retail's default for + // ACE's character creation defaults too). + moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer); // Apply resolved position. _body.Position = resolveResult.Position; diff --git a/src/AcDream.Core/Physics/CollisionExemption.cs b/src/AcDream.Core/Physics/CollisionExemption.cs new file mode 100644 index 0000000..89b368d --- /dev/null +++ b/src/AcDream.Core/Physics/CollisionExemption.cs @@ -0,0 +1,123 @@ +namespace AcDream.Core.Physics; + +/// +/// The retail-faithful exemption gate at the top of +/// CPhysicsObj::FindObjCollisions. Decides — based on the moving +/// object's bits and the target's raw +/// PhysicsState + decoded — +/// whether collision against the target should be skipped entirely +/// (return OK_TS) or proceed to broad-phase / shape dispatch. +/// +/// +/// Ported from the named retail decompilation: +/// +/// +/// acclient_2013_pseudo_c.txt:276782 — target +/// ETHEREAL_PS=0x4 & IGNORE_COLLISIONS_PS=0x10: walk through. +/// acclient_2013_pseudo_c.txt:276787 — viewer mover vs +/// creature target: walk through (camera ray ignores creatures). +/// acclient_2013_pseudo_c.txt:276971 — mover with +/// IGNORE_CREATURES (state & 0x400) vs creature target: +/// walk through. +/// acclient_2013_pseudo_c.txt:276807-276839 — PvP rule: +/// +/// If both are players: skip unless target is Impenetrable, +/// or both are PK, or both are PKLite. Mismatched PK status (PK vs +/// non-PK, PK vs PKLite) is exempted — players in different pools +/// pass through each other, matching retail muscle memory. +/// +/// +/// +/// +/// +/// Cross-checked against ACE +/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:381-405 +/// (line-for-line C# port of the same logic). Note: ACE adds +/// state.HasFlag(IsImpenetrable) (mover-impenetrable) to the +/// collide list; retail's pseudo-C only checks the target's +/// IsImpenetrable(). acdream follows retail. +/// +/// +public static class CollisionExemption +{ + private const uint ETHEREAL_PS = 0x4u; // acclient.h:2819 + private const uint IGNORE_COLLISIONS_PS = 0x10u; // acclient.h:2821 + + /// + /// Should the moving object skip collision testing against this + /// target entirely? Returns true if exempt (no further + /// shape dispatch). + /// + /// Raw retail PhysicsState bits + /// captured at CreateObject time (ETHEREAL/IGNORE/etc.). + /// Decoded + /// from the target's PWD bitfield + /// plus its ItemType-derived IsCreature bit. + /// The moving object's + /// — typically the local player's + /// IsPlayer + (PK/PKLite/Impenetrable bits if known) flags. + 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) + { + return true; + } + + // 2. Viewer mover + creature target → walk through. + // acclient_2013_pseudo_c.txt:276787-276790. + bool moverIsViewer = (moverState & ObjectInfoState.IsViewer) != 0; + bool targetIsCreature = (targetFlags & EntityCollisionFlags.IsCreature) != 0; + if (moverIsViewer && targetIsCreature) + return true; + + // 3. IGNORE_CREATURES mover + creature target → walk through. + // acclient_2013_pseudo_c.txt:276971. + bool moverIgnoresCreatures = (moverState & ObjectInfoState.IgnoreCreatures) != 0; + if (moverIgnoresCreatures && targetIsCreature) + return true; + + // 4. PvP exemption block. + // acclient_2013_pseudo_c.txt:276807-276839. + bool moverIsPlayer = (moverState & ObjectInfoState.IsPlayer) != 0; + bool targetIsPlayer = (targetFlags & EntityCollisionFlags.IsPlayer) != 0; + if (moverIsPlayer && targetIsPlayer) + { + // Tentatively exempt (retail `ebp_1 = 1`). Then disqualify + // if any of the COLLIDE conditions hold. + bool collide = false; + + // 4a. Impenetrable target → collide. + // acclient_2013_pseudo_c.txt:276826. + if ((targetFlags & EntityCollisionFlags.IsImpenetrable) != 0) + collide = true; + + // 4b. Both PK → collide. + // acclient_2013_pseudo_c.txt:276832-276836. + if (!collide + && (moverState & ObjectInfoState.IsPK) != 0 + && (targetFlags & EntityCollisionFlags.IsPK) != 0) + { + collide = true; + } + + // 4c. Both PKLite → collide. + // acclient_2013_pseudo_c.txt:276837. + if (!collide + && (moverState & ObjectInfoState.IsPKLite) != 0 + && (targetFlags & EntityCollisionFlags.IsPKLite) != 0) + { + collide = true; + } + + if (!collide) + return true; // exempt — non-PK pair walks through + } + + return false; // proceed to broad-phase + shape dispatch + } +} diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index bb26c54..4a93403 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -388,13 +388,21 @@ public sealed class PhysicsEngine float sphereRadius, float sphereHeight, float stepUpHeight, float stepDownHeight, bool isOnGround, - PhysicsBody? body = null) + PhysicsBody? body = null, + ObjectInfoState moverFlags = ObjectInfoState.None) { var transition = new Transition(); transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; transition.ObjectInfo.StepDown = true; + // Commit C 2026-04-29 — caller-supplied mover flags drive the + // retail PvP exemption block in FindObjCollisions. The local + // player passes IsPlayer (and PK/PKLite/Impenetrable when known + // from PlayerDescription); remote dead-reckoning passes None + // (matches non-player movement, all targets collide). + transition.ObjectInfo.State |= moverFlags; + if (isOnGround) transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index be451f9..57d6e6d 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -807,6 +807,17 @@ public sealed class Transition if (distToCurr > maxReach) continue; + // Commit C 2026-04-29 — retail exemption block at the top of + // CPhysicsObj::FindObjCollisions + // (acclient_2013_pseudo_c.txt:276782-276839,276971). Skips + // ethereal+ignore-collisions, viewer-vs-creature, + // IGNORE_CREATURES movers, and the player-vs-player PvP + // pass-through (non-PK pair / mismatched PK). Static + // landblock entries register with State=0 and Flags=None, + // so this is a cheap fall-through for them. + if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) + continue; + TransitionState result; if (obj.CollisionType == ShadowCollisionType.BSP) diff --git a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs new file mode 100644 index 0000000..b843165 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs @@ -0,0 +1,169 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for — Commit C of the +/// 2026-04-29 live-entity collision port. Covers retail's +/// CPhysicsObj::FindObjCollisions exemption block, ported +/// line-for-line from +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:276782-276839,276971. +/// +/// +/// Behaviour matrix (target / mover columns): +/// +/// +/// MoverTargetSkip? +/// anyETHEREAL+IGNORE_COLLISIONSYES (early-out) +/// IsViewerIsCreatureYES (camera ray-test passes through) +/// IGNORE_CREATURESIsCreatureYES (mover walks through creatures) +/// IsPlayerIsPlayer (no PK)YES (non-PK pair walks through) +/// IsPlayer + IsPKIsPlayer + IsPKNO (PK pair collides) +/// IsPlayer + IsPKLiteIsPlayer + IsPKLiteNO (PKLite pair collides) +/// IsPlayerIsPlayer + IsImpenetrableNO (Impenetrable target always collides) +/// IsPlayer + IsPKIsPlayer (no PK)YES (mismatched PK skip) +/// IsPlayerIsCreature (NPC)NO (player vs NPC always collides) +/// +/// +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_NotSkipped() + { + // 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( + 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)); + } +}