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>
This commit is contained in:
Erik 2026-08-03 20:59:01 +02:00
parent 88348f6791
commit 9b1e6fc637
19 changed files with 1329 additions and 5 deletions

View file

@ -530,6 +530,56 @@ it. Do #297 FIRST — #298 depends on it.
that is actually retail-faithful. Found during the #297/#298 investigation;
not symptom-causing. Fix the code and the comment together.
## Follow-ups from the #297 fix and its review — 2026-08-03
- **#300 — OPEN — `Properties.Ints[134]` and `PublicWeenieBitfield` can disagree
inside one `ClientObject`. LOW.** `ClientObjectTable.UpdateIntProperty:792-796`
is the only entry point that mirrors PropertyInt 134 (PlayerKillerStatus) into
the PWD bitfield. `UpsertProperties:750-767` (PlayerDescription 0x0013) and
`UpdateProperties:728-741` (IdentifyObjectResponse) write
`Properties.Ints[134]` **without** the mirror. An assess/appraisal bundle on a
player carrying PlayerKillerStatus would leave the raw int saying PKLite while
the bitfield still reads NPK — and `LiveSessionEventRouter.RecomputePvpStatus:426-429`
reads the raw int for the jump-stamina PK timer while everything else reads the
bitfield, so one row would drive two different answers. Benign today
(CreateObject's PWD is authoritative at login and ACE's assess bundles for
players are unlikely to carry 134) and it does not affect the #297 collision
path. Fix shape: a shared mirror helper called from all three appliers. Filed
from the #297 delta review; see register row AP-134.
- **#301 — OPEN — retail's OnStatUpdated also rewrites radar blip colour and
radar behaviour; acdream ignores both. LOW.** `ACCWeenieObject::OnStatUpdated`
@0x0058DF20 rewrites `pwd._blipColor` on `case 0x5f` (95 = RadarBlipColor) and
`pwd._radar_enum` on `case 0x85` (133 = RadarBehavior), verified at
`acclient_2013_pseudo_c.txt:408381-408391`. acdream handles neither, and
`RadarSnapshotProvider.cs:85,134` reads the frozen CreateObject spawn — so a
server-side radar-appearance change never reaches the radar. This is #297 for
the radar, same defect class, same fix shape (mirror the property into the
bitfield/snapshot at its source). Filed from the #297 delta review.
- **#302 — OPEN — `PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArray`
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of
`AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on
two immediate retries. The test asserts on
`GC.GetAllocatedBytesForCurrentThread()`
(`tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs:532`), which is
sensitive to JIT tiering and background GC regardless of the code under test.
Unrelated to the PK/collision work it surfaced during. **Do not treat a green
suite as proof this is gone** — it passes ~5 times in 6. Fix shape: warm the
path before measuring, or assert a bounded range rather than an exact
allocation count, matching how the other allocation gates in the repo are
written. Found while independently verifying the #297 gate.
- **#303 — OPEN — `LiveEntityPvpBitfieldSync` lives in App but touches only
Runtime-owned state. INFO/shape.**
`src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs` reads
`RuntimeEntityObjectLifetime.Objects` and writes
`RuntimePhysicsState.Engine.ShadowObjects` — both Runtime-owned since J5.5.
Moving it beside `RuntimeEntityPvpBitfieldSnapshotSync` would leave one
subscriber and one owner. No coverage gap today (headless has no
`LiveEntityCollisionBuilder` and therefore no live-entity target shadows), so
this is shape rather than a defect. Filed from the #297 delta review.
## C3c placement cutover — 2026-08-02
- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved

View file

@ -280,8 +280,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) |
| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 |
| AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 |
| AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` |
## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`**narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|

View file

@ -624,6 +624,16 @@ internal sealed class LivePresentationCompositionPhase
d.MotionBindings.ClearTargetForHiddenEntity,
d.WorldOrigin.GetCenter),
static value => value.Dispose());
// #297 second edge: keep every live entity's shadow-registry
// PK/PKLite/Impenetrable flags in sync with its live PWD
// bitfield, not just the mover-side read the table already
// resolves fresh on every call.
bindings.Adopt(
"live-entity pvp bitfield sync",
new LiveEntityPvpBitfieldSync(
d.EntityObjects.Objects,
liveEntities,
d.PhysicsEngine.ShadowObjects));
var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer(
d.RemotePhysicsUpdater,
d.WorldOrigin);

View file

@ -0,0 +1,91 @@
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
/// <summary>
/// #297 second edge: keeps a live entity's collision-shadow
/// <see cref="EntityCollisionFlags"/> synced with its
/// <see cref="ClientObject.PublicWeenieBitfield"/> after a live
/// PropertyInt(PlayerKillerStatus) update.
///
/// <para>
/// The MOVER side of the retail PvP exemption already refreshes for free:
/// <see cref="EntityCollisionFlagsExt.ResolveMoverPvpState"/> reads
/// <see cref="ClientObject.PublicWeenieBitfield"/> straight from the table on
/// every call, so once <c>ClientObjectTable.UpdateIntProperty</c> rewrites
/// that field (see <see cref="PlayerKillerStatusBitfield"/>) the next mover
/// query already sees it. The TARGET side is different: <c>CollisionExemption
/// .ShouldSkip</c> reads a decoded <see cref="EntityCollisionFlags"/> value
/// cached on the <see cref="ShadowObjectRegistry"/> entry at registration
/// time (<c>LiveEntityCollisionBuilder.Build</c>).
/// </para>
///
/// <para>
/// This class is the IMMEDIATE fix — it makes the shadow-registry flags
/// track a PK-status change the moment PropertyInt 134 arrives, with no wait
/// for any other event. It is NOT sufficient by itself: review round 2 (F1)
/// found that the same <c>LiveEntityCollisionBuilder.Build</c> registration
/// path re-runs on every ObjDesc/appearance change (any equip/dequip) and
/// rebuilds the shadow flags from <c>spawn.ObjectDescriptionFlags</c> — the
/// canonical Runtime snapshot, NOT this class's write target — so without a
/// companion fix a later equip would silently revert the flags this class
/// just wrote. <see cref="AcDream.Runtime.Entities.RuntimeEntityPvpBitfieldSnapshotSync"/>
/// closes that gap by keeping the snapshot itself live, so every rebuild
/// (and the placement/teleport mover-flags path, which reads the snapshot
/// directly) reproduces the same correct value instead of a stale one. The
/// two classes write to two different stores (this one: the shadow
/// registry's cached per-cell flags; that one: the canonical wire snapshot)
/// and are not a duplicate-authority pair — removing either reopens a
/// distinct symptom.
/// </para>
///
/// <para>
/// Subscribes to every <see cref="ClientObjectTable.ObjectUpdated"/> rather
/// than filtering to PropertyInt 134 alone: the recompute is a cheap
/// dictionary lookup plus a bitmask merge
/// (<see cref="ShadowObjectRegistry.UpdatePwdBitfieldFlags"/>, which
/// short-circuits on an unchanged result — see its own remarks for why that
/// guard is load-bearing), it is a no-op for any object without a live
/// shadow registration, and it stays correct for any future
/// PWD-bitfield-affecting property without another wiring change.
/// </para>
/// </summary>
internal sealed class LiveEntityPvpBitfieldSync : IDisposable
{
private readonly ClientObjectTable _objects;
private readonly LiveEntityRuntime _liveEntities;
private readonly ShadowObjectRegistry _shadows;
private bool _disposed;
public LiveEntityPvpBitfieldSync(
ClientObjectTable objects,
LiveEntityRuntime liveEntities,
ShadowObjectRegistry shadows)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
_objects.ObjectUpdated += OnObjectUpdated;
}
private void OnObjectUpdated(ClientObject item)
{
if (item.PublicWeenieBitfield is not { } bitfield)
return;
if (!_liveEntities.TryGetRecord(item.ObjectId, out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return;
}
_shadows.UpdatePwdBitfieldFlags(entity.Id, bitfield);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectUpdated -= OnObjectUpdated;
}
}

View file

@ -404,6 +404,49 @@ public readonly record struct WeenieData(
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
/// <summary>
/// #297: the write side of <c>PublicWeenieDesc._bitfield</c>'s PK/PKLite/Free
/// tri-state. Retail's only live PK-status signal is
/// <c>PropertyInt.PlayerKillerStatus</c> (134) over
/// <c>PublicUpdatePropertyInt</c>(0x02CE, remote) /
/// <c>PrivateUpdatePropertyInt</c>(0x02CD, self) — a client never receives a
/// fresh <c>PublicWeenieDesc</c> after CreateObject
/// (<c>WorldObject.EnqueueBroadcastUpdateObject</c> has zero live ACE
/// callers), so this rewrite is the ONLY way the bitfield can ever change
/// post-spawn. Ports <c>ACCWeenieObject::OnStatUpdated@0x0058DF20</c>
/// <c>case 0x86:</c> -&gt;
/// <c>PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0</c> verbatim
/// (<c>acclient_2013_pseudo_c.txt:441868-441890</c>). The three states are
/// mutually exclusive; any other wire value (including the common "NPK"
/// default) clears all three.
/// </summary>
public static class PlayerKillerStatusBitfield
{
/// <summary>ACE <c>PlayerKillerStatus.PK</c> — retail arg2 == 4.</summary>
public const int Pk = 0x04;
/// <summary>ACE <c>PlayerKillerStatus.Free</c> — retail arg2 == 0x20.</summary>
public const int Free = 0x20;
/// <summary>ACE <c>PlayerKillerStatus.PKLite</c> — retail arg2 == 0x40.</summary>
public const int PkLite = 0x40;
/// <summary>
/// Rewrites <paramref name="bitfield"/> in place for the given wire
/// <c>PlayerKillerStatus</c> value. Masks verified byte-for-byte against
/// <c>acclient_2013_pseudo_c.txt:441871-441889</c>:
/// PK -&gt; <c>(bitfield &amp; 0xfddfffff) | 0x20</c>;
/// PKLite -&gt; <c>(bitfield &amp; 0xffdfffdf) | 0x2000000</c>;
/// Free -&gt; <c>(bitfield &amp; 0xfdffffdf) | 0x200000</c>;
/// else -&gt; <c>bitfield &amp; 0xfddfffdf</c> (clears all three).
/// </summary>
public static uint Apply(uint bitfield, int pkStatus) => pkStatus switch
{
Pk => (bitfield & 0xfddfffffu) | 0x20u,
PkLite => (bitfield & 0xffdfffdfu) | 0x2000000u,
Free => (bitfield & 0xfdffffdfu) | 0x200000u,
_ => bitfield & 0xfddfffdfu,
};
}
/// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
/// Low 16 bits describe where the source may be used from; high 16 bits

View file

@ -258,6 +258,9 @@ public sealed class ClientObjectTable
public const uint HookItemTypesPropertyId = 152u;
public const uint SharedCooldownPropertyId = 280u;
public const uint CooldownDurationPropertyId = 167u;
/// <summary>PropertyInt.PlayerKillerStatus (ACE enum value 134) — the only
/// live PK/PKLite/Free signal; see <see cref="PlayerKillerStatusBitfield"/>.</summary>
public const uint PlayerKillerStatusPropertyId = 134u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
@ -765,11 +768,15 @@ public sealed class ClientObjectTable
}
/// <summary>
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE, or
/// PrivateUpdatePropertyInt 0x02CD routed to the player's own guid) to an
/// object: store it in the bundle and, for known typed ints, mirror to the typed
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>. Fires
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>;
/// PlayerKillerStatus (134) → <see cref="ClientObject.PublicWeenieBitfield"/>
/// (#297 — retail's only live PK/PKLite/Free signal, since a client never gets
/// a fresh PublicWeenieDesc after CreateObject). Fires ObjectUpdated so bound
/// widgets re-composite. Extensible hook for future typed PropertyInts
/// (StackSize, Structure, …). False if the object is unknown.
/// </summary>
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
@ -782,6 +789,11 @@ public sealed class ClientObjectTable
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == HookTypePropertyId) item.HookType = (uint)value;
if (propertyId == HookItemTypesPropertyId) item.HookItemTypes = (uint)value;
if (propertyId == PlayerKillerStatusPropertyId)
{
item.PublicWeenieBitfield = PlayerKillerStatusBitfield.Apply(
item.PublicWeenieBitfield ?? 0u, value);
}
if (propertyId == CurrentWieldedLocationPropertyId)
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
ObjectUpdated?.Invoke(item);

View file

@ -67,6 +67,22 @@ public enum EntityCollisionFlags : byte
/// <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

View file

@ -1694,6 +1694,74 @@ public sealed class ShadowObjectRegistry
}
/// <summary>
/// #297 (target-side refresh): rewrites only the PWD-bitfield-derived
/// subset of a registered entity's <see cref="EntityCollisionFlags"/>
/// (<see cref="EntityCollisionFlagsExt.PwdBitfieldDerivedMask"/>) from a
/// fresh <c>PublicWeenieDesc._bitfield</c> value, leaving
/// <see cref="EntityCollisionFlags.IsCreature"/> and
/// <see cref="EntityCollisionFlags.HasWeenie"/> untouched. Without this,
/// <c>CollisionExemption.ShouldSkip</c>'s target-side read would stay
/// frozen at whatever <c>CreateObject</c> captured, even after the
/// mover-side value refreshes live via
/// <see cref="EntityCollisionFlagsExt.ResolveMoverPvpState"/>. Mirrors
/// <see cref="UpdatePhysicsState"/>'s per-cell rewrite shape; a no-op for
/// an entity with no live registration.
/// </summary>
/// <remarks>
/// F3 (review round 2): callers are expected to fire this on every
/// <c>ObjectUpdated</c>, not only a genuine PK-status change, so an
/// equality short-circuit here is load-bearing — without it, an
/// unrelated property update (e.g. an inventory move bumping
/// EncumbranceVal) would still call <see cref="BumpOwnerVersion"/> /
/// <see cref="AdvanceMutationRevision"/>, and that revision is a commit
/// gate a prepared <c>SetPosition</c> checks before applying — an
/// unrelated bump landing between prepare and apply would invalidate an
/// otherwise-valid commit.
/// </remarks>
public void UpdatePwdBitfieldFlags(uint entityId, uint pwdBitfield)
{
EntityCollisionFlags decoded = EntityCollisionFlagsExt.FromPwdBitfield(pwdBitfield);
bool retained = _entityReg.TryGetValue(
entityId,
out RegistrationRecord? retainedRegistration);
if (retained)
{
EntityCollisionFlags merged =
(retainedRegistration!.Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
| decoded;
if (merged == retainedRegistration.Flags)
return; // idempotency guard — no real PK-status change
_entityReg[entityId] = retainedRegistration with { Flags = merged };
}
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
{
if (retained)
BumpOwnerVersion(entityId);
return; // not registered — no-op
}
foreach (var cellId in cellIds)
{
if (!_cells.TryGetValue(cellId, out var list)) continue;
for (int i = 0; i < list.Count; i++)
{
if (list[i].EntityId == entityId)
{
EntityCollisionFlags merged =
(list[i].Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
| decoded;
list[i] = list[i] with { Flags = merged };
}
}
}
if (retained)
BumpOwnerVersion(entityId);
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
=> DeregisterCore(entityId, publishMutation: true);

View file

@ -1178,6 +1178,42 @@ public sealed class InboundPhysicsStateController
return true;
}
/// <summary>
/// #297 review round 2 (F1/F2): rewrites <c>ObjectDescriptionFlags</c> on
/// THIS store's retained snapshot — the same base every ObjDesc/Pickup/
/// Parent/etc. merge in this class reads as <c>old</c>/<c>retained</c>
/// (see <see cref="ApplyAcceptedObjDesc"/>,
/// <see cref="ApplyAcceptedWeenieDescriptionSnapshot"/>). Without this, a
/// live PK-status rewrite applied only to
/// <c>RuntimeEntityRecord.Snapshot</c> (the ACTIVE record's copy) would
/// still be reverted by the NEXT untimestamped-field merge, because every
/// such merge starts from <c>_snapshots[guid]</c>, not the active
/// record — the two stores are related but distinct, exactly the
/// hazard the Round 3 A1 seam (<see cref="ApplyAcceptedObjDescSnapshot"/>)
/// was introduced to close for OTHER fields. No-op (returns false) if no
/// CreateObject has ever seeded this guid, or if the bitfield is
/// unchanged.
/// </summary>
internal bool TryRefreshObjectDescriptionFlags(
uint guid,
uint bitfield,
out WorldSession.EntitySpawn merged)
{
if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn retained))
{
merged = default;
return false;
}
if (retained.ObjectDescriptionFlags == bitfield)
{
merged = retained;
return false;
}
merged = retained with { ObjectDescriptionFlags = bitfield };
_snapshots[guid] = merged;
return true;
}
private static SameGenerationCreateObjectEvents BuildSameGenerationEvents(
WorldSession.EntitySpawn incoming)
{

View file

@ -642,6 +642,16 @@ public sealed class RuntimeEntityDirectory
out WorldSession.EntitySpawn merged) =>
_inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged);
/// <summary>
/// #297 review round 2: see
/// <see cref="InboundPhysicsStateController.TryRefreshObjectDescriptionFlags"/>.
/// </summary>
internal bool TryRefreshObjectDescriptionFlags(
uint guid,
uint bitfield,
out WorldSession.EntitySpawn merged) =>
_inbound.TryRefreshObjectDescriptionFlags(guid, bitfield, out merged);
private bool IsKnown(RuntimeEntityRecord record)
{
if (IsCurrent(record))

View file

@ -163,6 +163,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
/// <summary>
/// #297 (review round 2, preferred fix): keeps every canonical
/// snapshot's <c>ObjectDescriptionFlags</c> live against
/// <c>ClientObjectTable.PublicWeenieBitfield</c> — see
/// <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/> for the full
/// rationale.
/// </summary>
private readonly RuntimeEntityPvpBitfieldSnapshotSync _pvpBitfieldSync;
public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
@ -175,6 +183,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
timeProvider: timeProvider,
gameClock: gameClock);
Objects = new ClientObjectTable();
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics
// entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves
// a restricted cell's owner/guest list through the SAME live
@ -263,6 +272,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
timeProvider,
gameClock);
Objects = new ClientObjectTable();
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
@ -345,6 +355,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
timeProvider,
gameClock);
Objects = new ClientObjectTable();
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
Physics.Engine.Objects = Objects;
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
@ -2009,6 +2020,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
finally
{
_disposed = true;
_pvpBitfieldSync.Dispose();
Events.Dispose();
Physics.Dispose();
}

View file

@ -0,0 +1,96 @@
using AcDream.Core.Items;
using AcDream.Core.Net;
namespace AcDream.Runtime.Entities;
/// <summary>
/// #297 (preferred fix, review round 2): rewrites the canonical Runtime
/// snapshot's <c>ObjectDescriptionFlags</c> (retail
/// <c>PublicWeenieDesc._bitfield</c>) in place whenever a live
/// <c>PropertyInt.PlayerKillerStatus</c> update lands on
/// <see cref="ClientObjectTable"/>, mirroring the same
/// <c>PlayerKillerStatusBitfield</c> rewrite
/// <c>ClientObjectTable.UpdateIntProperty</c> already applies to
/// <see cref="ClientObject.PublicWeenieBitfield"/>.
///
/// <para>
/// Fixing it at the SOURCE — the one <see cref="RuntimeEntityRecord.Snapshot"/>
/// every subsystem reads — means every downstream consumer inherits the live
/// value by construction, with no second writer:
/// </para>
/// <list type="bullet">
/// <item>ObjDesc/appearance rebuild
/// (<c>LiveEntityCollisionBuilder.Build</c> reads
/// <c>spawn.ObjectDescriptionFlags</c> from the very snapshot this class
/// keeps live) no longer reverts a live PK-status change on the next
/// equip/dequip — the F1 defect from review round 2.</item>
/// <item>Placement/teleport/spawn-settle mover-flags resolution
/// (<c>RuntimeSetPositionMoverPreparation</c> reads
/// <c>record.Snapshot.ObjectDescriptionFlags</c> directly, not the live
/// table) sees the live value too — F2.</item>
/// <item>The vivid target indicator
/// (<c>WorldSelectionQuery.ResolveVividTargetInfo</c> reads
/// <c>LiveEntityRuntime.TryGetSnapshot</c>, the App-side mirror of this
/// same Runtime snapshot) is fixed for free — F4.</item>
/// </list>
///
/// <para>
/// Every ClientObjectTable update — not just PropertyInt 134 — fires
/// <see cref="ClientObjectTable.ObjectUpdated"/>, so the idempotency guard
/// inside <see cref="RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags"/>
/// is load-bearing: without it, an unrelated property change (e.g. an
/// inventory EncumbranceVal move) would still allocate a new snapshot record
/// for no reason. This mirrors the review's F3 guidance for the target-side
/// shadow-registry sync.
/// </para>
///
/// <para>
/// There are TWO related-but-distinct snapshot stores in Runtime: the ACTIVE
/// <see cref="RuntimeEntityRecord.Snapshot"/> every subsystem reads, and
/// <c>InboundPhysicsStateController</c>'s own retained copy that every
/// untimestamped-field merge (ObjDesc, same-generation CreateObject, ...)
/// uses as its <c>old</c>/<c>retained</c> base. Rewriting only the active
/// record's copy is not enough — the NEXT such merge would revert it, because
/// it starts from the OTHER store. This is exactly the hazard the Round 3 A1
/// seam (<c>ApplyAcceptedObjDescSnapshot</c>) was introduced to close for
/// other fields; <see cref="RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags"/>
/// keeps both stores in lockstep for this one.
/// </para>
/// </summary>
internal sealed class RuntimeEntityPvpBitfieldSnapshotSync : IDisposable
{
private readonly RuntimeEntityDirectory _entities;
private readonly ClientObjectTable _objects;
private bool _disposed;
public RuntimeEntityPvpBitfieldSnapshotSync(
RuntimeEntityDirectory entities,
ClientObjectTable objects)
{
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_objects.ObjectUpdated += OnObjectUpdated;
}
private void OnObjectUpdated(ClientObject item)
{
if (item.PublicWeenieBitfield is not { } bitfield)
return;
if (!_entities.TryRefreshObjectDescriptionFlags(
item.ObjectId,
bitfield,
out WorldSession.EntitySpawn merged))
{
return; // unchanged, or no CreateObject snapshot exists for this guid
}
if (_entities.TryGetActive(item.ObjectId, out RuntimeEntityRecord record))
_entities.RefreshSnapshot(record, merged, refreshPosition: false);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectUpdated -= OnObjectUpdated;
}
}

View file

@ -0,0 +1,208 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// #297 second edge: <see cref="LiveEntityPvpBitfieldSync"/> keeps a live
/// entity's <see cref="ShadowObjectRegistry"/> collision flags synced with
/// the <see cref="ClientObjectTable"/>'s live
/// <see cref="ClientObject.PublicWeenieBitfield"/> after a
/// PropertyInt(PlayerKillerStatus) update, instead of staying frozen at
/// whatever the spawn-time CreateObject captured.
/// </summary>
public sealed class LiveEntityPvpBitfieldSyncTests
{
private sealed class RecordingResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
// Mirrors LiveEntityRuntimeTests.Spawn's proven Register+Materialize
// shape verbatim: a PhysicsSpawnData block whose timestamps/position
// agree exactly with the flattened EntitySpawn fields is required by
// RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent.
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{
var position = new CreateObject.ServerPosition(cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
/// <summary>Registers and materializes one live entity, mirroring
/// <c>LiveEntityRuntimeTests.RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate</c>'s
/// proven Register+Materialize sequence.</summary>
private static (LiveEntityRuntime Runtime, WorldEntity Entity) MaterializedEntity(uint guid)
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu, new LandBlock(), Array.Empty<WorldEntity>()));
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, cell: 0x01010001u);
runtime.RegisterLiveEntity(spawn);
WorldEntity? entity = runtime.MaterializeLiveEntity(
spawn.Guid,
spawn.Position!.Value.LandblockId,
id => Entity(id, spawn.Guid));
return (runtime, entity!);
}
[Fact]
public void ObjectUpdated_WithLiveBitfield_RefreshesRegisteredTargetFlags()
{
(LiveEntityRuntime runtime, WorldEntity entity) = MaterializedEntity(0x70000010u);
var shadows = new ShadowObjectRegistry();
shadows.Register(
entity.Id,
0x01000005u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B40000u,
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
seedCellId: 0xA9B40001u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x70000010u,
PublicWeenieBitfield = 0x8u, // BF_PLAYER only — spawn-time snapshot
});
using var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
// Live PropertyInt(PlayerKillerStatus) = PKLite arrives.
objects.UpdateIntProperty(
0x70000010u,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
ShadowEntry after = Assert.Single(shadows.GetObjectsInCell(0xA9B40001u));
Assert.Equal(
EntityCollisionFlags.HasWeenie
| EntityCollisionFlags.IsPlayer
| EntityCollisionFlags.IsPKLite,
after.Flags);
}
[Fact]
public void ObjectUpdated_UnrelatedProperty_NoBitfield_DoesNotThrowOrRegisterUnknownEntity()
{
var spatial = new GpuWorldState();
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
var shadows = new ShadowObjectRegistry();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 0x70000011u });
using var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
// No PublicWeenieBitfield yet, and no matching live entity — must
// no-op harmlessly rather than throw.
objects.UpdateIntProperty(0x70000011u, propertyId: 18u, value: 1);
Assert.Equal(0, shadows.TotalRegistered);
}
[Fact]
public void Dispose_UnsubscribesFromObjectUpdated()
{
(LiveEntityRuntime runtime, WorldEntity entity) = MaterializedEntity(0x70000012u);
var shadows = new ShadowObjectRegistry();
shadows.Register(
entity.Id,
0x01000005u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0xA9B40000u,
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
seedCellId: 0xA9B40001u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x70000012u,
PublicWeenieBitfield = 0x8u,
});
var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
sync.Dispose();
objects.UpdateIntProperty(
0x70000012u,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
// Disposed sync must not have reacted — flags stay exactly as
// Register captured (no IsPKLite bit picked up).
ShadowEntry after = Assert.Single(shadows.GetObjectsInCell(0xA9B40001u));
Assert.Equal(
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
after.Flags);
}
}

View file

@ -0,0 +1,193 @@
using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Physics;
/// <summary>
/// #297 F1 (review round 2): <c>LiveEntityCollisionBuilder.Build</c> (invoked
/// from <c>LiveEntityHydrationController.OnAppearance</c> on every
/// equip/dequip ObjDesc) rebuilds a live entity's shadow-registry collision
/// flags from <c>spawn.ObjectDescriptionFlags</c>. Before
/// <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/> existed, that spawn was
/// the FROZEN CreateObject-time value — a plain equip/dequip after a live
/// PropertyInt(PlayerKillerStatus) update would silently revert the
/// shadow-registry PKLite flag, restoring the exact #297 symptom
/// (walk-through) even though <see cref="LiveEntityPvpBitfieldSync"/> had
/// already fixed it once. This test drives the REAL production sequence —
/// register, apply the live PK update, apply an ObjDesc through the gated
/// pipeline, rebuild collision, reconcile into the registry — and asserts
/// the shadow flags survive the rebuild.
/// </summary>
public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
{
private const uint Guid = 0x70000030u;
private const uint Cell = 0x01010001u;
private sealed class RecordingResources : ILiveEntityResourceLifecycle
{
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity) { }
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private static WorldEntity EntityFactory(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = Cell,
};
private static WorldSession.EntitySpawn Spawn(
uint? objectDescriptionFlags,
ushort objDescSequence)
{
var position = new CreateObject.ServerPosition(Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, objDescSequence, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
Guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
ObjectDescriptionFlags: objectDescriptionFlags,
Physics: physics);
}
[Fact]
public void ObjDescAfterPkUpdate_RebuildKeepsLivePkLiteFlag()
{
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu, new LandBlock(), Array.Empty<WorldEntity>()));
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(
static () => new RuntimeGenerationToken(1UL),
static () => 1UL);
var runtime = new LiveEntityRuntime(spatial, new RecordingResources(), lifetime);
WorldSession.EntitySpawn spawn = Spawn(objectDescriptionFlags: 0x8u, objDescSequence: 1);
runtime.RegisterLiveEntity(spawn);
WorldEntity entity = runtime.MaterializeLiveEntity(
spawn.Guid, Cell, id => EntityFactory(id, spawn.Guid))!;
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
// Seed the object table exactly like CreateObject would.
lifetime.Objects.AddOrUpdate(new ClientObject
{
ObjectId = Guid,
PublicWeenieBitfield = 0x8u, // BF_PLAYER only
});
// Live PropertyInt(PlayerKillerStatus) = PKLite arrives.
lifetime.Objects.UpdateIntProperty(
Guid,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
// Sanity: RuntimeEntityPvpBitfieldSnapshotSync already keeps the
// canonical snapshot live — this is the source-of-truth fix.
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
var setup = new Setup();
setup.Parts.Add(0x0100AB01u);
var builder = new LiveEntityCollisionBuilder(
id => id == 0x0100AB01u,
id => id == 0x0100AB01u ? 1f : null,
new LiveEntityDefaultPoseResolver(
_ => null,
new NullAnimationLoader(),
dumpMotion: false));
var registry = new ShadowObjectRegistry();
// Initial shadow registration, mirroring CreateObject-time behavior.
LiveEntityCollisionRegistration initial = Assert.IsType<LiveEntityCollisionRegistration>(
builder.Build(entity, setup, [], record.Snapshot, record, Vector3.Zero));
LiveEntityCollisionBuilder.Register(registry, initial);
ShadowEntry beforeObjDesc = Assert.Single(registry.GetObjectsInCell(Cell));
Assert.True(beforeObjDesc.Flags.HasFlag(EntityCollisionFlags.IsPKLite));
// F1 regression: an ObjDesc (equip/dequip) arrives AFTER the PK
// update. InboundPhysicsStateController.ApplyAcceptedObjDesc merges
// only ModelData/Physics-timestamp fields onto the OLD snapshot;
// ObjectDescriptionFlags carries through from whatever `old` was —
// which must already be live thanks to the snapshot-sync fix.
var update = new ObjDescEvent.Parsed(
Guid,
new CreateObject.ModelData(
0x04000001u,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
InstanceSequence: 1,
ObjDescSequence: 2);
Assert.True(runtime.TryApplyObjDesc(update, out WorldSession.EntitySpawn accepted));
Assert.Equal(0x2000008u, accepted.ObjectDescriptionFlags);
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
// The appearance-rebuild path: LiveEntityCollisionBuilder.Build runs
// again with the freshly-accepted spawn and the rebuilt collision
// replaces the shadow registration (mirrors
// LiveEntityAppearanceBinding.PrepareCollision/CommitCollision ->
// LiveEntityCollisionBuilder.ReconcileAppearance).
LiveEntityCollisionRegistration rebuilt = Assert.IsType<LiveEntityCollisionRegistration>(
builder.Build(entity, setup, [], accepted, record, Vector3.Zero));
LiveEntityCollisionBuilder.ReconcileAppearance(
registry, entity.Id, rebuilt, suspendIfNew: false);
ShadowEntry afterObjDesc = Assert.Single(registry.GetObjectsInCell(Cell));
Assert.True(
afterObjDesc.Flags.HasFlag(EntityCollisionFlags.IsPKLite),
"the ObjDesc-triggered appearance rebuild must not revert a live PK-status change");
}
}

View file

@ -337,6 +337,55 @@ public sealed class ClientObjectTableTests
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
}
[Fact]
public void UpdateIntProperty_playerKillerStatus_setsPublicWeenieBitfield()
{
// #297: PropertyInt.PlayerKillerStatus (134) is retail's ONLY live
// PK-status signal — arriving over either PublicUpdatePropertyInt
// (0x02CE, remote guid) or PrivateUpdatePropertyInt (0x02CD, self,
// routed to the player's own guid by ObjectTableWiring). Both routes
// funnel through this one UpdateIntProperty call, so this pins the
// shared translation both routes rely on.
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x500000AFu,
PublicWeenieBitfield = 0x8u, // BF_PLAYER only
});
ClientObject? fired = null;
repo.ObjectUpdated += i => fired = i;
bool ok = repo.UpdateIntProperty(
0x500000AFu,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
Assert.True(ok);
Assert.Equal(0x2000008u, repo.Get(0x500000AFu)!.PublicWeenieBitfield);
Assert.Equal(
PlayerKillerStatusBitfield.PkLite,
repo.Get(0x500000AFu)!.Properties.Ints[ClientObjectTable.PlayerKillerStatusPropertyId]);
Assert.NotNull(fired);
}
[Fact]
public void UpdateIntProperty_playerKillerStatus_nullBitfield_treatsMissingAsZero()
{
// An object whose CreateObject omitted the PWD bitfield entirely
// (WeenieHeaderFlag absent) must not throw/no-op on the first live
// PK-status update — retail's own PublicWeenieDesc bitfield starts
// zero-initialized.
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000B4u });
Assert.True(repo.UpdateIntProperty(
0x500000B4u,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.Pk));
Assert.Equal(0x20u, repo.Get(0x500000B4u)!.PublicWeenieBitfield);
}
[Fact]
public void ClientObject_NewFields_DefaultAndSettable()
{

View file

@ -0,0 +1,89 @@
using AcDream.Core.Items;
using Xunit;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// #297: pins <see cref="PlayerKillerStatusBitfield.Apply"/> — the ported
/// retail <c>PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0</c> bit
/// rewrite — byte-for-byte against
/// <c>acclient_2013_pseudo_c.txt:441868-441890</c>. Covers all four arms
/// (PK / PKLite / Free / else-clears-all) and mutual exclusivity.
/// </summary>
public sealed class PlayerKillerStatusBitfieldTests
{
[Fact]
public void Pk_SetsPkBit_ClearsFreeAndPkLite()
{
// Starting bitfield already has Free(0x200000) and PKLite(0x2000000)
// set (shouldn't happen in practice, but the mask must still clear
// them — mutual exclusivity is enforced by the rewrite, not by the
// caller).
uint result = PlayerKillerStatusBitfield.Apply(
bitfield: 0x2200000u,
pkStatus: PlayerKillerStatusBitfield.Pk);
Assert.Equal(0x20u, result);
}
[Fact]
public void PkLite_SetsPkLiteBit_ClearsPkAndFree()
{
uint result = PlayerKillerStatusBitfield.Apply(
bitfield: 0x200020u,
pkStatus: PlayerKillerStatusBitfield.PkLite);
Assert.Equal(0x2000000u, result);
}
[Fact]
public void Free_SetsFreeBit_ClearsPkAndPkLite()
{
uint result = PlayerKillerStatusBitfield.Apply(
bitfield: 0x2000020u,
pkStatus: PlayerKillerStatusBitfield.Free);
Assert.Equal(0x200000u, result);
}
[Fact]
public void OtherValue_ClearsAllThreeBits()
{
// Retail's implicit "NPK" default (any arg2 not in {4, 0x20, 0x40})
// clears PK/Free/PKLite and sets nothing.
uint result = PlayerKillerStatusBitfield.Apply(
bitfield: 0x2200020u,
pkStatus: 0);
Assert.Equal(0u, result);
}
[Theory]
[InlineData(PlayerKillerStatusBitfield.Pk)]
[InlineData(PlayerKillerStatusBitfield.PkLite)]
[InlineData(PlayerKillerStatusBitfield.Free)]
[InlineData(0)]
public void NeverTouchesUnrelatedBits(int pkStatus)
{
// BF_PLAYER (0x8) and every other PWD bit must survive every arm —
// the retail masks only ever touch bits 5/21/25.
const uint unrelatedBits = 0x8u | 0x100u | 0x400000u;
uint result = PlayerKillerStatusBitfield.Apply(
bitfield: unrelatedBits,
pkStatus: pkStatus);
Assert.Equal(unrelatedBits, result & unrelatedBits);
}
[Fact]
public void MutualExclusivity_TransitioningPkToFreeToPkLite()
{
uint bitfield = 0u;
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.Pk);
Assert.Equal(0x20u, bitfield);
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.Free);
Assert.Equal(0x200000u, bitfield);
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.PkLite);
Assert.Equal(0x2000000u, bitfield);
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, pkStatus: -1);
Assert.Equal(0u, bitfield);
}
}

View file

@ -202,6 +202,116 @@ public class ShadowObjectRegistryTests
Assert.Equal(0x44u, restored.State);
}
// -----------------------------------------------------------------------
// UpdatePwdBitfieldFlags (#297 target-side refresh)
// -----------------------------------------------------------------------
[Fact]
public void UpdatePwdBitfieldFlags_ReplacesOnlyPwdDerivedBits()
{
// Simulates CreateObject-time registration (IsPlayer decoded from the
// spawn bitfield, IsCreature derived from ItemType, HasWeenie set by
// the builder) followed by a live PropertyInt(PlayerKillerStatus)
// arrival that goes PKLite. IsCreature/HasWeenie must survive
// untouched; IsPlayer must survive because the new bitfield still
// carries BF_PLAYER.
var reg = new ShadowObjectRegistry();
const uint entityId = 60u;
reg.Register(
entityId,
0x01000005u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
flags: EntityCollisionFlags.HasWeenie
| EntityCollisionFlags.IsCreature
| EntityCollisionFlags.IsPlayer,
seedCellId: LbId | 1u);
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x2000008u); // BF_PLAYER | PKLite
ShadowEntry entry = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
Assert.Equal(
EntityCollisionFlags.HasWeenie
| EntityCollisionFlags.IsCreature
| EntityCollisionFlags.IsPlayer
| EntityCollisionFlags.IsPKLite,
entry.Flags);
}
[Fact]
public void UpdatePwdBitfieldFlags_ClearsStalePkStateWhenBitfieldNoLongerCarriesIt()
{
var reg = new ShadowObjectRegistry();
const uint entityId = 61u;
reg.Register(
entityId,
0x01000005u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
flags: EntityCollisionFlags.HasWeenie
| EntityCollisionFlags.IsPlayer
| EntityCollisionFlags.IsPKLite,
seedCellId: LbId | 1u);
// Target reverts to NPK — bitfield no longer carries the PKLite bit.
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x8u); // BF_PLAYER only
ShadowEntry entry = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
Assert.Equal(
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
entry.Flags);
}
[Fact]
public void UpdatePwdBitfieldFlags_UnregisteredEntity_NoOp()
{
var reg = new ShadowObjectRegistry();
// Must not throw for an entity with no live shadow registration
// (e.g. an off-screen/never-materialized object).
reg.UpdatePwdBitfieldFlags(999u, pwdBitfield: 0x2000000u);
Assert.Equal(0, reg.TotalRegistered);
}
[Fact]
public void UpdatePwdBitfieldFlags_ThenCollisionExemption_BothPkLiteNowCollide()
{
// End-to-end #297 payoff: a target registered at spawn as a plain
// (non-PK) player, then a live PropertyInt(PlayerKillerStatus)
// update makes it PKLite. Before the target-side refresh existed,
// CollisionExemption would keep reading the frozen spawn-time
// flags and the pair would walk through each other forever.
var reg = new ShadowObjectRegistry();
const uint entityId = 62u;
reg.Register(
entityId,
0x01000005u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
seedCellId: LbId | 1u);
var moverState = ObjectInfoState.IsPlayer | ObjectInfoState.IsPKLite;
ShadowEntry before = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
Assert.True(CollisionExemption.ShouldSkip(before.State, before.Flags, moverState));
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x2000008u); // BF_PLAYER | PKLite
ShadowEntry after = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
Assert.False(CollisionExemption.ShouldSkip(after.State, after.Flags, moverState));
}
[Fact]
public void ReplaceMultiPartPayload_VisibleOwnerKeepsExistingCellMembership()
{

View file

@ -0,0 +1,189 @@
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Tests.Entities;
/// <summary>
/// #297 review round 2 (preferred fix): <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/>
/// keeps <c>RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags</c> live
/// against <see cref="ClientObject.PublicWeenieBitfield"/>, so every
/// downstream reader of the canonical snapshot (ObjDesc/appearance rebuild,
/// placement/teleport mover-flags resolution, the vivid target indicator)
/// inherits the live PK/PKLite/Free state instead of the value frozen at
/// CreateObject.
/// </summary>
public sealed class RuntimeEntityPvpBitfieldSnapshotSyncTests
{
private const uint Guid = 0x70000020u;
private static WorldSession.EntitySpawn Spawn(uint? objectDescriptionFlags) => new(
Guid,
new CreateObject.ServerPosition(0x0101FFFFu, 10f, 10f, 5f, 1f, 0f, 0f, 0f),
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
ObjectDescriptionFlags: objectDescriptionFlags);
[Fact]
public void ObjectUpdated_WithLiveBitfield_RewritesSnapshotObjectDescriptionFlags()
{
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
WorldSession.EntitySpawn spawn = Spawn(0x8u); // BF_PLAYER only
entities.AcceptCreate(spawn); // seeds InboundPhysicsStateController's own retained copy
RuntimeEntityRecord record = entities.AddActive(spawn);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Guid,
PublicWeenieBitfield = 0x8u,
});
objects.UpdateIntProperty(
Guid,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
}
[Fact]
public void ObjectUpdated_UnrelatedProperty_DoesNotRewriteSnapshot()
{
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
WorldSession.EntitySpawn spawn = Spawn(0x8u);
entities.AcceptCreate(spawn);
RuntimeEntityRecord record = entities.AddActive(spawn);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Guid,
PublicWeenieBitfield = 0x8u,
});
// Effects (18) is unrelated to PK status; PublicWeenieBitfield is
// untouched, so the idempotency guard must skip the rewrite.
objects.UpdateIntProperty(Guid, ClientObjectTable.UiEffectsPropertyId, value: 4);
Assert.Equal(0x8u, record.Snapshot.ObjectDescriptionFlags);
}
[Fact]
public void ObjectUpdated_NoPublicWeenieBitfieldYet_NoOp()
{
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
RuntimeEntityRecord record = entities.AddActive(Spawn(null));
objects.AddOrUpdate(new ClientObject { ObjectId = Guid });
// No PublicWeenieBitfield yet (CreateObject omitted it) — the
// handler must no-op rather than throw or write a bogus 0.
objects.UpdateIntProperty(Guid, ClientObjectTable.UiEffectsPropertyId, value: 1);
Assert.Null(record.Snapshot.ObjectDescriptionFlags);
}
[Fact]
public void ObjectUpdated_NoMatchingActiveEntity_NoOp()
{
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
// Item exists in the object table but has no active Runtime entity
// (e.g. an inventory item) — must not throw.
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x80000001u,
PublicWeenieBitfield = 0x8u,
});
objects.UpdateIntProperty(
0x80000001u,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.Pk);
Assert.Equal(0, entities.Count);
}
[Fact]
public void ObjDescAfterPkUpdate_MergedSnapshotKeepsLiveBitfield()
{
// #297 F1: InboundPhysicsStateController.ApplyAcceptedObjDesc merges
// a new ObjDesc onto _snapshots[guid] — a DIFFERENT store than the
// active RuntimeEntityRecord.Snapshot this class also rewrites.
// Without RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags
// keeping BOTH stores in lockstep, this ObjDesc would silently
// revert the live PK-status bits back to whatever CreateObject
// originally carried.
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
WorldSession.EntitySpawn spawn = Spawn(0x8u);
entities.AcceptCreate(spawn);
entities.AddActive(spawn);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Guid,
PublicWeenieBitfield = 0x8u,
});
objects.UpdateIntProperty(
Guid,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
var update = new ObjDescEvent.Parsed(
Guid,
new CreateObject.ModelData(
0x04000001u,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
InstanceSequence: 0,
ObjDescSequence: 1);
Assert.True(entities.TryApplyObjDesc(update, out WorldSession.EntitySpawn accepted));
Assert.Equal(0x2000008u, accepted.ObjectDescriptionFlags);
}
[Fact]
public void Dispose_UnsubscribesFromObjectUpdated()
{
var entities = new RuntimeEntityDirectory();
var objects = new ClientObjectTable();
var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
WorldSession.EntitySpawn spawn = Spawn(0x8u);
entities.AcceptCreate(spawn);
RuntimeEntityRecord record = entities.AddActive(spawn);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Guid,
PublicWeenieBitfield = 0x8u,
});
sync.Dispose();
objects.UpdateIntProperty(
Guid,
ClientObjectTable.PlayerKillerStatusPropertyId,
value: PlayerKillerStatusBitfield.PkLite);
Assert.Equal(0x8u, record.Snapshot.ObjectDescriptionFlags);
}
}

View file

@ -113,6 +113,47 @@ public sealed class RuntimeSetPositionMoverPreparationTests
command.ExpectedVelocityAuthorityVersion);
}
[Fact]
public void LivePkStatusUpdate_ReachesMoverFlagsOnNextPlacement()
{
// #297 F2 (review round 2): RuntimeSetPositionMoverPreparer.TryBuild
// reads record.Snapshot.ObjectDescriptionFlags directly — a
// PLACEMENT/teleport/spawn-settle mover-flags resolve, distinct from
// the per-tick sweep path that already read the live table via
// EntityCollisionFlagsExt.ResolveMoverPvpState. Before
// RuntimeEntityPvpBitfieldSnapshotSync existed, this record's
// snapshot stayed frozen at whatever CreateObject captured, so a
// PKLite remote teleporting or landing next to the local player
// de-overlapped under the wrong (stale, non-PKLite) exemption.
using var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record = CreateRecord(
lifetime,
objectDescriptionFlags: 0x8u); // BF_PLAYER only, no PK/PKLite yet
lifetime.Objects.AddOrUpdate(new AcDream.Core.Items.ClientObject
{
ObjectId = record.ServerGuid,
PublicWeenieBitfield = 0x8u,
});
lifetime.Objects.UpdateIntProperty(
record.ServerGuid,
AcDream.Core.Items.ClientObjectTable.PlayerKillerStatusPropertyId,
value: AcDream.Core.Items.PlayerKillerStatusBitfield.PkLite);
RuntimeEntityPlacementToken token = Begin(lifetime, record);
ImmutableArray<FlatCollisionSphere> spheres =
[new(new Vector3(1f, 2f, 3f), 0.1f)];
FlatSetupCollision setup = Setup(spheres, 0f, 0f);
RuntimeSetPositionMoverPreparation input =
Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, setup));
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
.SetPosition.PrepareMover(token, input, out var command);
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
Assert.True(command.Physics.MoverFlags.HasFlag(ObjectInfoState.IsPKLite));
}
[Fact]
public void LocalPortalKindAndAuthorityArePreservedWithoutInference()
{