acdream/src/AcDream.Core/Physics/EntityCollisionFlags.cs
Erik 9b1e6fc637 fix(physics): #297 — keep the PWD bitfield live so PK status reaches the client
The user typed @pklite and then walked straight through other PKLite players.

Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the
0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is
PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and
stored into Properties.Ints[134] but never translated back into the bitfield —
and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject
has zero live callers), so that property is the ONLY signal a client can learn
from. Both sides of the collision test read the frozen value, so
CollisionExemption's "4c. both PKLite -> collide" rule could never fire.

Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0
rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) ->
(b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else
b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values
confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just
ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86.

The fix rewrites the value at its source rather than patching consumers. Two
review rounds were needed because the first pass missed that there are TWO
snapshot stores: InboundPhysicsStateController keeps its own private _snapshots
dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and
friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot.
Refreshing only the active record left the target-side shadow flags correct
until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every
one — at which point the appearance path rebuilt the registration from the
frozen spawn and dropped the bit permanently. The regression test demanded by
review is what surfaced that; it is verified discriminating (reverting gives
Actual: 8 instead of 33554440).

Five stores now hold this value, kept coherent from one source by two
ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag
writers are the same invalidation applied at the two edges that can invalidate
it, not competing authorities — review enumerated every drift path and closed
each. That coherence invariant is new as of this commit and is recorded as
register row AP-134, with AP-133 as the precedent for filing a row when the
danger is a future writer rather than current behaviour.

Also corrects TS-23's retirement narrative, which claimed every mover-flags call
site read the mover's "real" PK bits from 2026-07-30. The bits existed but their
source was frozen, so that only became true here; the site enumeration also
missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the
snapshot directly.

Unblocks #298 (melee/missile admission needs the local player's own PKLite bit).
Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same
defect class for radar blip colour and radar behaviour), #302 (a pre-existing
PortalProjection allocation-assertion flake, 1 in 6, found while verifying this
gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state).

Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline
10,887 including #299). Adversarial + retail-conformance review PASS after one
FAIL round. Every new test discrimination-verified by reverting the fix.
Connected acceptance NOT run — needs a live two-client PKLite session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:59:01 +02:00

156 lines
7.8 KiB
C#

using AcDream.Core.Items;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity flags driving the retail-faithful PvP / Player /
/// Impenetrable exemption logic in <c>FindObjCollisions</c>. Decoded
/// from <c>PublicWeenieDesc._bitfield</c> at <c>CreateObject</c> time.
///
/// <para>
/// Bit positions (verified against
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:406898-406918</c>
/// where <c>ACCWeenieObject::IsPK/IsPKLite/IsImpenetrable</c> read directly
/// from <c>this-&gt;pwd._bitfield</c>):
/// </para>
/// <list type="bullet">
/// <item><c>BF_PLAYER = 0x8</c> (bit 3) → <see cref="IsPlayer"/></item>
/// <item><c>BF_PLAYER_KILLER = 0x20</c> (bit 5) → <see cref="IsPK"/></item>
/// <item><c>BF_FREE_PKSTATUS = 0x200000</c> (bit 21) → <see cref="IsImpenetrable"/></item>
/// <item><c>BF_PKLITE_PKSTATUS = 0x2000000</c> (bit 25) → <see cref="IsPKLite"/></item>
/// </list>
///
/// <para>
/// <see cref="IsCreature"/> is NOT a PWD bit — retail derives it from
/// <c>PublicWeenieDesc._type</c> matching <c>ITEM_TYPE_CREATURE</c>
/// (acclient.h ITEM_TYPE enum). Set at registration time by callers that
/// already know the item type.
/// </para>
/// </summary>
[Flags]
public enum EntityCollisionFlags : byte
{
None = 0x00,
/// <summary>Set when <c>BF_PLAYER (0x8)</c> is set in <c>pwd._bitfield</c>.</summary>
IsPlayer = 0x01,
/// <summary>Derived from <c>ItemType.Creature</c> on the spawn payload.</summary>
IsCreature = 0x02,
/// <summary>Set when <c>BF_PLAYER_KILLER (0x20)</c> is set.</summary>
IsPK = 0x04,
/// <summary>Set when <c>BF_PKLITE_PKSTATUS (0x2000000)</c> is set.</summary>
IsPKLite = 0x08,
/// <summary>Set when <c>BF_FREE_PKSTATUS (0x200000)</c> is set (a.k.a. "Free" PK status — cannot be PKed).</summary>
IsImpenetrable = 0x10,
/// <summary>
/// Runtime collision metadata: the target has a retail
/// <c>CWeenieObject</c>. This is deliberately distinct from
/// <see cref="IsCreature"/> because doors, portals, and items also have a
/// weenie. <c>OBJECTINFO::missile_ignore</c> at <c>0x0050CEB0</c>
/// requires this distinction for ethereal targets.
/// </summary>
HasWeenie = 0x20,
/// <summary>
/// AP-71 (Campaign P Slice P4, 2026-07-30): set when BOTH
/// <c>BF_ADMIN (0x100000)</c> and <c>BF_IMMUNE_CELL_RESTRICTIONS
/// (0x400000)</c> are set in <c>pwd._bitfield</c> — the exact two-bit AND
/// retail's <c>ACCWeenieObject::CanBypassMoveRestrictions</c>
/// (0x0058c500) evaluates to let a mover through an access-restricted
/// (house-barrier) cell without an owner/guest-list check. Bit names
/// confirmed at <c>acclient.h:6452-6454</c>
/// (<c>PublicWeenieDesc::BitfieldIndex</c>); <c>BF_ADMIN</c> is
/// independently decoded the same way in
/// <see cref="AcDream.Core.Ui.RadarBlipColors"/>.
/// </summary>
CanBypassMoveRestrictions = 0x40,
}
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>
public static class EntityCollisionFlagsExt
{
/// <summary>
/// #297 (target-side refresh): exactly the subset of
/// <see cref="EntityCollisionFlags"/> that <see cref="FromPwdBitfield"/>
/// can produce — IsPlayer/IsPK/IsPKLite/IsImpenetrable/
/// CanBypassMoveRestrictions. Disjoint from <see cref="EntityCollisionFlags.IsCreature"/>
/// (derived from ItemType) and <see cref="EntityCollisionFlags.HasWeenie"/>
/// (set once at registration), so a live PWD-bitfield refresh can replace
/// exactly this mask without disturbing either.
/// </summary>
public const EntityCollisionFlags PwdBitfieldDerivedMask =
EntityCollisionFlags.IsPlayer
| EntityCollisionFlags.IsPK
| EntityCollisionFlags.IsPKLite
| EntityCollisionFlags.IsImpenetrable
| EntityCollisionFlags.CanBypassMoveRestrictions;
/// <summary>
/// Decode the player/PK/PKLite/Impenetrable bits from a
/// <c>PublicWeenieDesc._bitfield</c> value (the WeenieHeader trailer
/// field acdream's parser surfaces as <c>ObjectDescriptionFlags</c>).
///
/// <para>Bit positions per
/// <c>docs/research/named-retail/acclient.h:6431-6463</c>
/// (<c>PublicWeenieDesc::BitfieldIndex</c>) and
/// <c>acclient_2013_pseudo_c.txt:441868-441890</c>
/// (<c>PublicWeenieDesc::SetPlayerKillerStatus</c>).</para>
/// </summary>
public static EntityCollisionFlags FromPwdBitfield(uint bitfield)
{
var flags = EntityCollisionFlags.None;
if ((bitfield & 0x8u) != 0) flags |= EntityCollisionFlags.IsPlayer;
if ((bitfield & 0x20u) != 0) flags |= EntityCollisionFlags.IsPK;
if ((bitfield & 0x200000u) != 0) flags |= EntityCollisionFlags.IsImpenetrable;
if ((bitfield & 0x2000000u) != 0) flags |= EntityCollisionFlags.IsPKLite;
// AP-71: BF_ADMIN (0x100000) AND BF_IMMUNE_CELL_RESTRICTIONS (0x400000),
// matching CanBypassMoveRestrictions' own AND (not OR) of the two bits.
if ((bitfield & 0x100000u) != 0 && (bitfield & 0x400000u) != 0)
flags |= EntityCollisionFlags.CanBypassMoveRestrictions;
return flags;
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): translate the decoded
/// per-entity <see cref="EntityCollisionFlags"/> (the PWD-bitfield
/// bit-space) into the <see cref="ObjectInfoState"/> bits
/// <c>CollisionExemption.ShouldSkip</c> and the moverFlags argument to
/// <c>PhysicsEngine.ResolveWithTransition</c> actually consume — a
/// DIFFERENT bit-space (retail <c>OBJECTINFO::init</c> 0x0050cf30
/// `state |= 0x80/0x800/0x1000`, acclient.h:6190-6194) that must not be
/// confused with the PWD wire numbering. <see cref="EntityCollisionFlags.IsPlayer"/>
/// is deliberately NOT translated here — every existing mover-flags call
/// site already derives <see cref="ObjectInfoState.IsPlayer"/> from its
/// own GUID-prefix heuristic (correct per #184 Slice 2b) and this helper
/// only fills the gap that heuristic cannot: PK/PKLite/Impenetrable.
/// </summary>
public static ObjectInfoState ToMoverState(this EntityCollisionFlags flags)
{
var state = ObjectInfoState.None;
if ((flags & EntityCollisionFlags.IsPK) != 0) state |= ObjectInfoState.IsPK;
if ((flags & EntityCollisionFlags.IsPKLite) != 0) state |= ObjectInfoState.IsPKLite;
if ((flags & EntityCollisionFlags.IsImpenetrable) != 0) state |= ObjectInfoState.IsImpenetrable;
// AP-71 (Campaign P Slice P4): the mover's own house-restriction bypass,
// consumed by ObjectInfo.CheckEntryRestrictions.
if ((flags & EntityCollisionFlags.CanBypassMoveRestrictions) != 0)
state |= ObjectInfoState.CanBypassMoveRestrictions;
return state;
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): the one shared
/// <c>ClientObjectTable</c>-backed mover-flags lookup every physics
/// call site (local player world-entry, remote DR sweep + teleport,
/// ordinary movers) uses — was inlined three times (App
/// <c>GameWindow</c>/<c>LivePresentationComposition</c>/
/// <c>RemoteTeleportController</c>) before being consolidated here.
/// An entity with no row, or a row with no wire bitfield yet, resolves
/// to <see cref="ObjectInfoState.None"/> — a no-op OR into moverFlags,
/// bit-identical to every pre-P3 caller.
/// </summary>
public static ObjectInfoState ResolveMoverPvpState(this ClientObjectTable objects, uint serverGuid)
{
ArgumentNullException.ThrowIfNull(objects);
return objects.Get(serverGuid)?.PublicWeenieBitfield is { } bitfield
? FromPwdBitfield(bitfield).ToMoverState()
: ObjectInfoState.None;
}
}