From 9b1e6fc6378c7f17f7a32808303d9bedb686e0ad Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 20:59:01 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20#297=20=E2=80=94=20keep=20the?= =?UTF-8?q?=20PWD=20bitfield=20live=20so=20PK=20status=20reaches=20the=20c?= =?UTF-8?q?lient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user typed @pklite and then walked straight through other PKLite players. Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the 0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and stored into Properties.Ints[134] but never translated back into the bitfield — and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject has zero live callers), so that property is the ONLY signal a client can learn from. Both sides of the collision test read the frozen value, so CollisionExemption's "4c. both PKLite -> collide" rule could never fire. Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0 rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) -> (b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86. The fix rewrites the value at its source rather than patching consumers. Two review rounds were needed because the first pass missed that there are TWO snapshot stores: InboundPhysicsStateController keeps its own private _snapshots dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot. Refreshing only the active record left the target-side shadow flags correct until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every one — at which point the appearance path rebuilt the registration from the frozen spawn and dropped the bit permanently. The regression test demanded by review is what surfaced that; it is verified discriminating (reverting gives Actual: 8 instead of 33554440). Five stores now hold this value, kept coherent from one source by two ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag writers are the same invalidation applied at the two edges that can invalidate it, not competing authorities — review enumerated every drift path and closed each. That coherence invariant is new as of this commit and is recorded as register row AP-134, with AP-133 as the precedent for filing a row when the danger is a future writer rather than current behaviour. Also corrects TS-23's retirement narrative, which claimed every mover-flags call site read the mover's "real" PK bits from 2026-07-30. The bits existed but their source was frozen, so that only became true here; the site enumeration also missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the snapshot directly. Unblocks #298 (melee/missile admission needs the local player's own PKLite bit). Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same defect class for radar blip colour and radar behaviour), #302 (a pre-existing PortalProjection allocation-assertion flake, 1 in 6, found while verifying this gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state). Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline 10,887 including #299). Adversarial + retail-conformance review PASS after one FAIL round. Every new test discrimination-verified by reverting the fix. Connected acceptance NOT run — needs a live two-client PKLite session. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 50 +++++ .../retail-divergence-register.md | 3 +- .../LivePresentationComposition.cs | 10 + .../Physics/LiveEntityPvpBitfieldSync.cs | 91 ++++++++ src/AcDream.Core/Items/ClientObject.cs | 43 ++++ src/AcDream.Core/Items/ClientObjectTable.cs | 20 +- .../Physics/EntityCollisionFlags.cs | 16 ++ .../Physics/ShadowObjectRegistry.cs | 68 ++++++ .../Entities/InboundPhysicsStateController.cs | 36 +++ .../Entities/RuntimeEntityDirectory.cs | 10 + .../Entities/RuntimeEntityObjectLifetime.cs | 12 + .../RuntimeEntityPvpBitfieldSnapshotSync.cs | 96 ++++++++ .../Physics/LiveEntityPvpBitfieldSyncTests.cs | 208 ++++++++++++++++++ ...pBitfieldSurvivesAppearanceRebuildTests.cs | 193 ++++++++++++++++ .../Items/ClientObjectTableTests.cs | 49 +++++ .../Items/PlayerKillerStatusBitfieldTests.cs | 89 ++++++++ .../Physics/ShadowObjectRegistryTests.cs | 110 +++++++++ ...ntimeEntityPvpBitfieldSnapshotSyncTests.cs | 189 ++++++++++++++++ ...RuntimeSetPositionMoverPreparationTests.cs | 41 ++++ 19 files changed, 1329 insertions(+), 5 deletions(-) create mode 100644 src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveEntityPvpBitfieldSyncTests.cs create mode 100644 tests/AcDream.App.Tests/Physics/PvpBitfieldSurvivesAppearanceRebuildTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/PlayerKillerStatusBitfieldTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeEntityPvpBitfieldSnapshotSyncTests.cs 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() {