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() {