diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 160df6c4..4fd57d2c 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b07848de..84ec328f 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
|---|---|---|---|---|---|
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index f9244fde..fa3a106c 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -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);
diff --git a/src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs b/src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs
new file mode 100644
index 00000000..91a67d85
--- /dev/null
+++ b/src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs
@@ -0,0 +1,91 @@
+using AcDream.App.World;
+using AcDream.Core.Items;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Physics;
+
+///
+/// #297 second edge: keeps a live entity's collision-shadow
+/// synced with its
+/// after a live
+/// PropertyInt(PlayerKillerStatus) update.
+///
+///
+/// The MOVER side of the retail PvP exemption already refreshes for free:
+/// reads
+/// straight from the table on
+/// every call, so once ClientObjectTable.UpdateIntProperty rewrites
+/// that field (see ) the next mover
+/// query already sees it. The TARGET side is different: CollisionExemption
+/// .ShouldSkip reads a decoded value
+/// cached on the entry at registration
+/// time (LiveEntityCollisionBuilder.Build).
+///
+///
+///
+/// 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 LiveEntityCollisionBuilder.Build registration
+/// path re-runs on every ObjDesc/appearance change (any equip/dequip) and
+/// rebuilds the shadow flags from spawn.ObjectDescriptionFlags — 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.
+/// 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.
+///
+///
+///
+/// Subscribes to every rather
+/// than filtering to PropertyInt 134 alone: the recompute is a cheap
+/// dictionary lookup plus a bitmask merge
+/// (, 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.
+///
+///
+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;
+ }
+}
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index c7ee7060..5f4611d0 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -404,6 +404,49 @@ public readonly record struct WeenieData(
uint? MonarchId = null,
HouseRestrictionRecord? Restrictions = null);
+///
+/// #297: the write side of PublicWeenieDesc._bitfield's PK/PKLite/Free
+/// tri-state. Retail's only live PK-status signal is
+/// PropertyInt.PlayerKillerStatus (134) over
+/// PublicUpdatePropertyInt(0x02CE, remote) /
+/// PrivateUpdatePropertyInt(0x02CD, self) — a client never receives a
+/// fresh PublicWeenieDesc after CreateObject
+/// (WorldObject.EnqueueBroadcastUpdateObject has zero live ACE
+/// callers), so this rewrite is the ONLY way the bitfield can ever change
+/// post-spawn. Ports ACCWeenieObject::OnStatUpdated@0x0058DF20
+/// case 0x86: ->
+/// PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0 verbatim
+/// (acclient_2013_pseudo_c.txt:441868-441890). The three states are
+/// mutually exclusive; any other wire value (including the common "NPK"
+/// default) clears all three.
+///
+public static class PlayerKillerStatusBitfield
+{
+ /// ACE PlayerKillerStatus.PK — retail arg2 == 4.
+ public const int Pk = 0x04;
+ /// ACE PlayerKillerStatus.Free — retail arg2 == 0x20.
+ public const int Free = 0x20;
+ /// ACE PlayerKillerStatus.PKLite — retail arg2 == 0x40.
+ public const int PkLite = 0x40;
+
+ ///
+ /// Rewrites in place for the given wire
+ /// PlayerKillerStatus value. Masks verified byte-for-byte against
+ /// acclient_2013_pseudo_c.txt:441871-441889:
+ /// PK -> (bitfield & 0xfddfffff) | 0x20;
+ /// PKLite -> (bitfield & 0xffdfffdf) | 0x2000000;
+ /// Free -> (bitfield & 0xfdffffdf) | 0x200000;
+ /// else -> bitfield & 0xfddfffdf (clears all three).
+ ///
+ public static uint Apply(uint bitfield, int pkStatus) => pkStatus switch
+ {
+ Pk => (bitfield & 0xfddfffffu) | 0x20u,
+ PkLite => (bitfield & 0xffdfffdfu) | 0x2000000u,
+ Free => (bitfield & 0xfdffffdfu) | 0x200000u,
+ _ => bitfield & 0xfddfffdfu,
+ };
+}
+
///
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
/// Low 16 bits describe where the source may be used from; high 16 bits
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 97058b2a..f71f5c1b 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -258,6 +258,9 @@ public sealed class ClientObjectTable
public const uint HookItemTypesPropertyId = 152u;
public const uint SharedCooldownPropertyId = 280u;
public const uint CooldownDurationPropertyId = 167u;
+ /// PropertyInt.PlayerKillerStatus (ACE enum value 134) — the only
+ /// live PK/PKLite/Free signal; see .
+ public const uint PlayerKillerStatusPropertyId = 134u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
@@ -765,11 +768,15 @@ public sealed class ClientObjectTable
}
///
- /// 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) → . 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) → ;
+ /// PlayerKillerStatus (134) →
+ /// (#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.
///
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);
diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
index 79f0a4d9..f1e9a8f6 100644
--- a/src/AcDream.Core/Physics/EntityCollisionFlags.cs
+++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs
@@ -67,6 +67,22 @@ public enum EntityCollisionFlags : byte
/// Helpers to convert raw retail bitfields into .
public static class EntityCollisionFlagsExt
{
+ ///
+ /// #297 (target-side refresh): exactly the subset of
+ /// that
+ /// can produce — IsPlayer/IsPK/IsPKLite/IsImpenetrable/
+ /// CanBypassMoveRestrictions. Disjoint from
+ /// (derived from ItemType) and
+ /// (set once at registration), so a live PWD-bitfield refresh can replace
+ /// exactly this mask without disturbing either.
+ ///
+ public const EntityCollisionFlags PwdBitfieldDerivedMask =
+ EntityCollisionFlags.IsPlayer
+ | EntityCollisionFlags.IsPK
+ | EntityCollisionFlags.IsPKLite
+ | EntityCollisionFlags.IsImpenetrable
+ | EntityCollisionFlags.CanBypassMoveRestrictions;
+
///
/// Decode the player/PK/PKLite/Impenetrable bits from a
/// PublicWeenieDesc._bitfield value (the WeenieHeader trailer
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index 52a2006a..53e064ae 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -1694,6 +1694,74 @@ public sealed class ShadowObjectRegistry
}
+ ///
+ /// #297 (target-side refresh): rewrites only the PWD-bitfield-derived
+ /// subset of a registered entity's
+ /// () from a
+ /// fresh PublicWeenieDesc._bitfield value, leaving
+ /// and
+ /// untouched. Without this,
+ /// CollisionExemption.ShouldSkip's target-side read would stay
+ /// frozen at whatever CreateObject captured, even after the
+ /// mover-side value refreshes live via
+ /// . Mirrors
+ /// 's per-cell rewrite shape; a no-op for
+ /// an entity with no live registration.
+ ///
+ ///
+ /// F3 (review round 2): callers are expected to fire this on every
+ /// ObjectUpdated, 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 /
+ /// , and that revision is a commit
+ /// gate a prepared SetPosition checks before applying — an
+ /// unrelated bump landing between prepare and apply would invalidate an
+ /// otherwise-valid commit.
+ ///
+ 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);
+ }
+
/// Remove an entity from all cells it was registered in.
public void Deregister(uint entityId)
=> DeregisterCore(entityId, publishMutation: true);
diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs
index ac548d0e..f00d59d8 100644
--- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs
+++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs
@@ -1178,6 +1178,42 @@ public sealed class InboundPhysicsStateController
return true;
}
+ ///
+ /// #297 review round 2 (F1/F2): rewrites ObjectDescriptionFlags on
+ /// THIS store's retained snapshot — the same base every ObjDesc/Pickup/
+ /// Parent/etc. merge in this class reads as old/retained
+ /// (see ,
+ /// ). Without this, a
+ /// live PK-status rewrite applied only to
+ /// RuntimeEntityRecord.Snapshot (the ACTIVE record's copy) would
+ /// still be reverted by the NEXT untimestamped-field merge, because every
+ /// such merge starts from _snapshots[guid], not the active
+ /// record — the two stores are related but distinct, exactly the
+ /// hazard the Round 3 A1 seam ()
+ /// 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.
+ ///
+ 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)
{
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
index a9ac7b05..2b9ca433 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs
@@ -642,6 +642,16 @@ public sealed class RuntimeEntityDirectory
out WorldSession.EntitySpawn merged) =>
_inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged);
+ ///
+ /// #297 review round 2: see
+ /// .
+ ///
+ 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))
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
index 73ea5968..5e030f47 100644
--- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs
@@ -163,6 +163,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
private readonly List> _firstEntryDriveOwnership = [];
/// C4 route 2: see .
private readonly List> _acceptedPositionDriveOwnership = [];
+ ///
+ /// #297 (review round 2, preferred fix): keeps every canonical
+ /// snapshot's ObjectDescriptionFlags live against
+ /// ClientObjectTable.PublicWeenieBitfield — see
+ /// for the full
+ /// rationale.
+ ///
+ 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();
}
diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs b/src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs
new file mode 100644
index 00000000..78d1efaa
--- /dev/null
+++ b/src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs
@@ -0,0 +1,96 @@
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+
+namespace AcDream.Runtime.Entities;
+
+///
+/// #297 (preferred fix, review round 2): rewrites the canonical Runtime
+/// snapshot's ObjectDescriptionFlags (retail
+/// PublicWeenieDesc._bitfield) in place whenever a live
+/// PropertyInt.PlayerKillerStatus update lands on
+/// , mirroring the same
+/// PlayerKillerStatusBitfield rewrite
+/// ClientObjectTable.UpdateIntProperty already applies to
+/// .
+///
+///
+/// Fixing it at the SOURCE — the one
+/// every subsystem reads — means every downstream consumer inherits the live
+/// value by construction, with no second writer:
+///
+///
+/// - ObjDesc/appearance rebuild
+/// (LiveEntityCollisionBuilder.Build reads
+/// spawn.ObjectDescriptionFlags 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.
+/// - Placement/teleport/spawn-settle mover-flags resolution
+/// (RuntimeSetPositionMoverPreparation reads
+/// record.Snapshot.ObjectDescriptionFlags directly, not the live
+/// table) sees the live value too — F2.
+/// - The vivid target indicator
+/// (WorldSelectionQuery.ResolveVividTargetInfo reads
+/// LiveEntityRuntime.TryGetSnapshot, the App-side mirror of this
+/// same Runtime snapshot) is fixed for free — F4.
+///
+///
+///
+/// Every ClientObjectTable update — not just PropertyInt 134 — fires
+/// , so the idempotency guard
+/// inside
+/// 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.
+///
+///
+///
+/// There are TWO related-but-distinct snapshot stores in Runtime: the ACTIVE
+/// every subsystem reads, and
+/// InboundPhysicsStateController's own retained copy that every
+/// untimestamped-field merge (ObjDesc, same-generation CreateObject, ...)
+/// uses as its old/retained 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 (ApplyAcceptedObjDescSnapshot) was introduced to close for
+/// other fields;
+/// keeps both stores in lockstep for this one.
+///
+///
+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;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityPvpBitfieldSyncTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityPvpBitfieldSyncTests.cs
new file mode 100644
index 00000000..520c0c93
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/LiveEntityPvpBitfieldSyncTests.cs
@@ -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;
+
+///
+/// #297 second edge: keeps a live
+/// entity's collision flags synced with
+/// the 's live
+/// after a
+/// PropertyInt(PlayerKillerStatus) update, instead of staying frozen at
+/// whatever the spawn-time CreateObject captured.
+///
+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(),
+ };
+
+ // 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(),
+ Array.Empty(),
+ Array.Empty(),
+ null,
+ null,
+ "fixture",
+ (uint)ItemType.Creature,
+ null,
+ 0x09000001u,
+ PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
+ InstanceSequence: 1,
+ MovementSequence: 1,
+ ServerControlSequence: 1,
+ PositionSequence: 1,
+ Physics: physics);
+ }
+
+ /// Registers and materializes one live entity, mirroring
+ /// LiveEntityRuntimeTests.RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate's
+ /// proven Register+Materialize sequence.
+ private static (LiveEntityRuntime Runtime, WorldEntity Entity) MaterializedEntity(uint guid)
+ {
+ var spatial = new GpuWorldState();
+ spatial.AddLandblock(new LoadedLandblock(
+ 0x0101FFFFu, new LandBlock(), Array.Empty()));
+ 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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs
new file mode 100644
index 00000000..d46af473
--- /dev/null
+++ b/tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs
@@ -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;
+
+///
+/// #297 F1 (review round 2): LiveEntityCollisionBuilder.Build (invoked
+/// from LiveEntityHydrationController.OnAppearance on every
+/// equip/dequip ObjDesc) rebuilds a live entity's shadow-registry collision
+/// flags from spawn.ObjectDescriptionFlags. Before
+/// 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 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.
+///
+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(),
+ 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(),
+ Array.Empty(),
+ Array.Empty(),
+ 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()));
+ 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(
+ 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(),
+ Array.Empty(),
+ Array.Empty()),
+ 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(
+ 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");
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
index ad0ae1ee..b87ae287 100644
--- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
@@ -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()
{
diff --git a/tests/AcDream.Core.Tests/Items/PlayerKillerStatusBitfieldTests.cs b/tests/AcDream.Core.Tests/Items/PlayerKillerStatusBitfieldTests.cs
new file mode 100644
index 00000000..92d0b7e0
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/PlayerKillerStatusBitfieldTests.cs
@@ -0,0 +1,89 @@
+using AcDream.Core.Items;
+using Xunit;
+
+namespace AcDream.Core.Tests.Items;
+
+///
+/// #297: pins — the ported
+/// retail PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0 bit
+/// rewrite — byte-for-byte against
+/// acclient_2013_pseudo_c.txt:441868-441890. Covers all four arms
+/// (PK / PKLite / Free / else-clears-all) and mutual exclusivity.
+///
+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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
index 1b7939a5..5a461ca8 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
@@ -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()
{
diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityPvpBitfieldSnapshotSyncTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityPvpBitfieldSnapshotSyncTests.cs
new file mode 100644
index 00000000..2d126ca7
--- /dev/null
+++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityPvpBitfieldSnapshotSyncTests.cs
@@ -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;
+
+///
+/// #297 review round 2 (preferred fix):
+/// keeps RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags live
+/// against , 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.
+///
+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(),
+ Array.Empty(),
+ Array.Empty(),
+ 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(),
+ Array.Empty(),
+ Array.Empty()),
+ 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);
+ }
+}
diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs
index 808f699e..70eb228f 100644
--- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs
+++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionMoverPreparationTests.cs
@@ -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 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()
{