fix(physics): #297 — keep the PWD bitfield live so PK status reaches the client
The user typed @pklite and then walked straight through other PKLite players. Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the 0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and stored into Properties.Ints[134] but never translated back into the bitfield — and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject has zero live callers), so that property is the ONLY signal a client can learn from. Both sides of the collision test read the frozen value, so CollisionExemption's "4c. both PKLite -> collide" rule could never fire. Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0 rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) -> (b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86. The fix rewrites the value at its source rather than patching consumers. Two review rounds were needed because the first pass missed that there are TWO snapshot stores: InboundPhysicsStateController keeps its own private _snapshots dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot. Refreshing only the active record left the target-side shadow flags correct until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every one — at which point the appearance path rebuilt the registration from the frozen spawn and dropped the bit permanently. The regression test demanded by review is what surfaced that; it is verified discriminating (reverting gives Actual: 8 instead of 33554440). Five stores now hold this value, kept coherent from one source by two ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag writers are the same invalidation applied at the two edges that can invalidate it, not competing authorities — review enumerated every drift path and closed each. That coherence invariant is new as of this commit and is recorded as register row AP-134, with AP-133 as the precedent for filing a row when the danger is a future writer rather than current behaviour. Also corrects TS-23's retirement narrative, which claimed every mover-flags call site read the mover's "real" PK bits from 2026-07-30. The bits existed but their source was frozen, so that only became true here; the site enumeration also missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the snapshot directly. Unblocks #298 (melee/missile admission needs the local player's own PKLite bit). Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same defect class for radar blip colour and radar behaviour), #302 (a pre-existing PortalProjection allocation-assertion flake, 1 in 6, found while verifying this gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state). Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline 10,887 including #299). Adversarial + retail-conformance review PASS after one FAIL round. Every new test discrimination-verified by reverting the fix. Connected acceptance NOT run — needs a live two-client PKLite session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
88348f6791
commit
9b1e6fc637
19 changed files with 1329 additions and 5 deletions
|
|
@ -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);
|
||||
|
|
|
|||
91
src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs
Normal file
91
src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #297 second edge: keeps a live entity's collision-shadow
|
||||
/// <see cref="EntityCollisionFlags"/> synced with its
|
||||
/// <see cref="ClientObject.PublicWeenieBitfield"/> after a live
|
||||
/// PropertyInt(PlayerKillerStatus) update.
|
||||
///
|
||||
/// <para>
|
||||
/// The MOVER side of the retail PvP exemption already refreshes for free:
|
||||
/// <see cref="EntityCollisionFlagsExt.ResolveMoverPvpState"/> reads
|
||||
/// <see cref="ClientObject.PublicWeenieBitfield"/> straight from the table on
|
||||
/// every call, so once <c>ClientObjectTable.UpdateIntProperty</c> rewrites
|
||||
/// that field (see <see cref="PlayerKillerStatusBitfield"/>) the next mover
|
||||
/// query already sees it. The TARGET side is different: <c>CollisionExemption
|
||||
/// .ShouldSkip</c> reads a decoded <see cref="EntityCollisionFlags"/> value
|
||||
/// cached on the <see cref="ShadowObjectRegistry"/> entry at registration
|
||||
/// time (<c>LiveEntityCollisionBuilder.Build</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This class is the IMMEDIATE fix — it makes the shadow-registry flags
|
||||
/// track a PK-status change the moment PropertyInt 134 arrives, with no wait
|
||||
/// for any other event. It is NOT sufficient by itself: review round 2 (F1)
|
||||
/// found that the same <c>LiveEntityCollisionBuilder.Build</c> registration
|
||||
/// path re-runs on every ObjDesc/appearance change (any equip/dequip) and
|
||||
/// rebuilds the shadow flags from <c>spawn.ObjectDescriptionFlags</c> — the
|
||||
/// canonical Runtime snapshot, NOT this class's write target — so without a
|
||||
/// companion fix a later equip would silently revert the flags this class
|
||||
/// just wrote. <see cref="AcDream.Runtime.Entities.RuntimeEntityPvpBitfieldSnapshotSync"/>
|
||||
/// closes that gap by keeping the snapshot itself live, so every rebuild
|
||||
/// (and the placement/teleport mover-flags path, which reads the snapshot
|
||||
/// directly) reproduces the same correct value instead of a stale one. The
|
||||
/// two classes write to two different stores (this one: the shadow
|
||||
/// registry's cached per-cell flags; that one: the canonical wire snapshot)
|
||||
/// and are not a duplicate-authority pair — removing either reopens a
|
||||
/// distinct symptom.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Subscribes to every <see cref="ClientObjectTable.ObjectUpdated"/> rather
|
||||
/// than filtering to PropertyInt 134 alone: the recompute is a cheap
|
||||
/// dictionary lookup plus a bitmask merge
|
||||
/// (<see cref="ShadowObjectRegistry.UpdatePwdBitfieldFlags"/>, which
|
||||
/// short-circuits on an unchanged result — see its own remarks for why that
|
||||
/// guard is load-bearing), it is a no-op for any object without a live
|
||||
/// shadow registration, and it stays correct for any future
|
||||
/// PWD-bitfield-affecting property without another wiring change.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityPvpBitfieldSync : IDisposable
|
||||
{
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private bool _disposed;
|
||||
|
||||
public LiveEntityPvpBitfieldSync(
|
||||
ClientObjectTable objects,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ShadowObjectRegistry shadows)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_shadows = shadows ?? throw new ArgumentNullException(nameof(shadows));
|
||||
_objects.ObjectUpdated += OnObjectUpdated;
|
||||
}
|
||||
|
||||
private void OnObjectUpdated(ClientObject item)
|
||||
{
|
||||
if (item.PublicWeenieBitfield is not { } bitfield)
|
||||
return;
|
||||
if (!_liveEntities.TryGetRecord(item.ObjectId, out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_shadows.UpdatePwdBitfieldFlags(entity.Id, bitfield);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_objects.ObjectUpdated -= OnObjectUpdated;
|
||||
}
|
||||
}
|
||||
|
|
@ -404,6 +404,49 @@ public readonly record struct WeenieData(
|
|||
uint? MonarchId = null,
|
||||
HouseRestrictionRecord? Restrictions = null);
|
||||
|
||||
/// <summary>
|
||||
/// #297: the write side of <c>PublicWeenieDesc._bitfield</c>'s PK/PKLite/Free
|
||||
/// tri-state. Retail's only live PK-status signal is
|
||||
/// <c>PropertyInt.PlayerKillerStatus</c> (134) over
|
||||
/// <c>PublicUpdatePropertyInt</c>(0x02CE, remote) /
|
||||
/// <c>PrivateUpdatePropertyInt</c>(0x02CD, self) — a client never receives a
|
||||
/// fresh <c>PublicWeenieDesc</c> after CreateObject
|
||||
/// (<c>WorldObject.EnqueueBroadcastUpdateObject</c> has zero live ACE
|
||||
/// callers), so this rewrite is the ONLY way the bitfield can ever change
|
||||
/// post-spawn. Ports <c>ACCWeenieObject::OnStatUpdated@0x0058DF20</c>
|
||||
/// <c>case 0x86:</c> ->
|
||||
/// <c>PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0</c> verbatim
|
||||
/// (<c>acclient_2013_pseudo_c.txt:441868-441890</c>). The three states are
|
||||
/// mutually exclusive; any other wire value (including the common "NPK"
|
||||
/// default) clears all three.
|
||||
/// </summary>
|
||||
public static class PlayerKillerStatusBitfield
|
||||
{
|
||||
/// <summary>ACE <c>PlayerKillerStatus.PK</c> — retail arg2 == 4.</summary>
|
||||
public const int Pk = 0x04;
|
||||
/// <summary>ACE <c>PlayerKillerStatus.Free</c> — retail arg2 == 0x20.</summary>
|
||||
public const int Free = 0x20;
|
||||
/// <summary>ACE <c>PlayerKillerStatus.PKLite</c> — retail arg2 == 0x40.</summary>
|
||||
public const int PkLite = 0x40;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites <paramref name="bitfield"/> in place for the given wire
|
||||
/// <c>PlayerKillerStatus</c> value. Masks verified byte-for-byte against
|
||||
/// <c>acclient_2013_pseudo_c.txt:441871-441889</c>:
|
||||
/// PK -> <c>(bitfield & 0xfddfffff) | 0x20</c>;
|
||||
/// PKLite -> <c>(bitfield & 0xffdfffdf) | 0x2000000</c>;
|
||||
/// Free -> <c>(bitfield & 0xfdffffdf) | 0x200000</c>;
|
||||
/// else -> <c>bitfield & 0xfddfffdf</c> (clears all three).
|
||||
/// </summary>
|
||||
public static uint Apply(uint bitfield, int pkStatus) => pkStatus switch
|
||||
{
|
||||
Pk => (bitfield & 0xfddfffffu) | 0x20u,
|
||||
PkLite => (bitfield & 0xffdfffdfu) | 0x2000000u,
|
||||
Free => (bitfield & 0xfdffffdfu) | 0x200000u,
|
||||
_ => bitfield & 0xfddfffdfu,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
/// Low 16 bits describe where the source may be used from; high 16 bits
|
||||
|
|
|
|||
|
|
@ -258,6 +258,9 @@ public sealed class ClientObjectTable
|
|||
public const uint HookItemTypesPropertyId = 152u;
|
||||
public const uint SharedCooldownPropertyId = 280u;
|
||||
public const uint CooldownDurationPropertyId = 167u;
|
||||
/// <summary>PropertyInt.PlayerKillerStatus (ACE enum value 134) — the only
|
||||
/// live PK/PKLite/Free signal; see <see cref="PlayerKillerStatusBitfield"/>.</summary>
|
||||
public const uint PlayerKillerStatusPropertyId = 134u;
|
||||
|
||||
public int ObjectCount => _objects.Count;
|
||||
public int ContainerCount => _containers.Count;
|
||||
|
|
@ -765,11 +768,15 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
|
||||
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE, or
|
||||
/// PrivateUpdatePropertyInt 0x02CD routed to the player's own guid) to an
|
||||
/// object: store it in the bundle and, for known typed ints, mirror to the typed
|
||||
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>. Fires
|
||||
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
|
||||
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
|
||||
/// field. Today: UiEffects (18) → <see cref="ClientObject.Effects"/>;
|
||||
/// PlayerKillerStatus (134) → <see cref="ClientObject.PublicWeenieBitfield"/>
|
||||
/// (#297 — retail's only live PK/PKLite/Free signal, since a client never gets
|
||||
/// a fresh PublicWeenieDesc after CreateObject). Fires ObjectUpdated so bound
|
||||
/// widgets re-composite. Extensible hook for future typed PropertyInts
|
||||
/// (StackSize, Structure, …). False if the object is unknown.
|
||||
/// </summary>
|
||||
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
|
||||
{
|
||||
|
|
@ -782,6 +789,11 @@ public sealed class ClientObjectTable
|
|||
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
|
||||
if (propertyId == HookTypePropertyId) item.HookType = (uint)value;
|
||||
if (propertyId == HookItemTypesPropertyId) item.HookItemTypes = (uint)value;
|
||||
if (propertyId == PlayerKillerStatusPropertyId)
|
||||
{
|
||||
item.PublicWeenieBitfield = PlayerKillerStatusBitfield.Apply(
|
||||
item.PublicWeenieBitfield ?? 0u, value);
|
||||
}
|
||||
if (propertyId == CurrentWieldedLocationPropertyId)
|
||||
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
|
||||
ObjectUpdated?.Invoke(item);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,22 @@ public enum EntityCollisionFlags : byte
|
|||
/// <summary>Helpers to convert raw retail bitfields into <see cref="EntityCollisionFlags"/>.</summary>
|
||||
public static class EntityCollisionFlagsExt
|
||||
{
|
||||
/// <summary>
|
||||
/// #297 (target-side refresh): exactly the subset of
|
||||
/// <see cref="EntityCollisionFlags"/> that <see cref="FromPwdBitfield"/>
|
||||
/// can produce — IsPlayer/IsPK/IsPKLite/IsImpenetrable/
|
||||
/// CanBypassMoveRestrictions. Disjoint from <see cref="EntityCollisionFlags.IsCreature"/>
|
||||
/// (derived from ItemType) and <see cref="EntityCollisionFlags.HasWeenie"/>
|
||||
/// (set once at registration), so a live PWD-bitfield refresh can replace
|
||||
/// exactly this mask without disturbing either.
|
||||
/// </summary>
|
||||
public const EntityCollisionFlags PwdBitfieldDerivedMask =
|
||||
EntityCollisionFlags.IsPlayer
|
||||
| EntityCollisionFlags.IsPK
|
||||
| EntityCollisionFlags.IsPKLite
|
||||
| EntityCollisionFlags.IsImpenetrable
|
||||
| EntityCollisionFlags.CanBypassMoveRestrictions;
|
||||
|
||||
/// <summary>
|
||||
/// Decode the player/PK/PKLite/Impenetrable bits from a
|
||||
/// <c>PublicWeenieDesc._bitfield</c> value (the WeenieHeader trailer
|
||||
|
|
|
|||
|
|
@ -1694,6 +1694,74 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #297 (target-side refresh): rewrites only the PWD-bitfield-derived
|
||||
/// subset of a registered entity's <see cref="EntityCollisionFlags"/>
|
||||
/// (<see cref="EntityCollisionFlagsExt.PwdBitfieldDerivedMask"/>) from a
|
||||
/// fresh <c>PublicWeenieDesc._bitfield</c> value, leaving
|
||||
/// <see cref="EntityCollisionFlags.IsCreature"/> and
|
||||
/// <see cref="EntityCollisionFlags.HasWeenie"/> untouched. Without this,
|
||||
/// <c>CollisionExemption.ShouldSkip</c>'s target-side read would stay
|
||||
/// frozen at whatever <c>CreateObject</c> captured, even after the
|
||||
/// mover-side value refreshes live via
|
||||
/// <see cref="EntityCollisionFlagsExt.ResolveMoverPvpState"/>. Mirrors
|
||||
/// <see cref="UpdatePhysicsState"/>'s per-cell rewrite shape; a no-op for
|
||||
/// an entity with no live registration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// F3 (review round 2): callers are expected to fire this on every
|
||||
/// <c>ObjectUpdated</c>, not only a genuine PK-status change, so an
|
||||
/// equality short-circuit here is load-bearing — without it, an
|
||||
/// unrelated property update (e.g. an inventory move bumping
|
||||
/// EncumbranceVal) would still call <see cref="BumpOwnerVersion"/> /
|
||||
/// <see cref="AdvanceMutationRevision"/>, and that revision is a commit
|
||||
/// gate a prepared <c>SetPosition</c> checks before applying — an
|
||||
/// unrelated bump landing between prepare and apply would invalidate an
|
||||
/// otherwise-valid commit.
|
||||
/// </remarks>
|
||||
public void UpdatePwdBitfieldFlags(uint entityId, uint pwdBitfield)
|
||||
{
|
||||
EntityCollisionFlags decoded = EntityCollisionFlagsExt.FromPwdBitfield(pwdBitfield);
|
||||
|
||||
bool retained = _entityReg.TryGetValue(
|
||||
entityId,
|
||||
out RegistrationRecord? retainedRegistration);
|
||||
if (retained)
|
||||
{
|
||||
EntityCollisionFlags merged =
|
||||
(retainedRegistration!.Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
|
||||
| decoded;
|
||||
if (merged == retainedRegistration.Flags)
|
||||
return; // idempotency guard — no real PK-status change
|
||||
_entityReg[entityId] = retainedRegistration with { Flags = merged };
|
||||
}
|
||||
|
||||
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
|
||||
{
|
||||
if (retained)
|
||||
BumpOwnerVersion(entityId);
|
||||
return; // not registered — no-op
|
||||
}
|
||||
|
||||
foreach (var cellId in cellIds)
|
||||
{
|
||||
if (!_cells.TryGetValue(cellId, out var list)) continue;
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (list[i].EntityId == entityId)
|
||||
{
|
||||
EntityCollisionFlags merged =
|
||||
(list[i].Flags & ~EntityCollisionFlagsExt.PwdBitfieldDerivedMask)
|
||||
| decoded;
|
||||
list[i] = list[i] with { Flags = merged };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retained)
|
||||
BumpOwnerVersion(entityId);
|
||||
}
|
||||
|
||||
/// <summary>Remove an entity from all cells it was registered in.</summary>
|
||||
public void Deregister(uint entityId)
|
||||
=> DeregisterCore(entityId, publishMutation: true);
|
||||
|
|
|
|||
|
|
@ -1178,6 +1178,42 @@ public sealed class InboundPhysicsStateController
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #297 review round 2 (F1/F2): rewrites <c>ObjectDescriptionFlags</c> on
|
||||
/// THIS store's retained snapshot — the same base every ObjDesc/Pickup/
|
||||
/// Parent/etc. merge in this class reads as <c>old</c>/<c>retained</c>
|
||||
/// (see <see cref="ApplyAcceptedObjDesc"/>,
|
||||
/// <see cref="ApplyAcceptedWeenieDescriptionSnapshot"/>). Without this, a
|
||||
/// live PK-status rewrite applied only to
|
||||
/// <c>RuntimeEntityRecord.Snapshot</c> (the ACTIVE record's copy) would
|
||||
/// still be reverted by the NEXT untimestamped-field merge, because every
|
||||
/// such merge starts from <c>_snapshots[guid]</c>, not the active
|
||||
/// record — the two stores are related but distinct, exactly the
|
||||
/// hazard the Round 3 A1 seam (<see cref="ApplyAcceptedObjDescSnapshot"/>)
|
||||
/// was introduced to close for OTHER fields. No-op (returns false) if no
|
||||
/// CreateObject has ever seeded this guid, or if the bitfield is
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
internal bool TryRefreshObjectDescriptionFlags(
|
||||
uint guid,
|
||||
uint bitfield,
|
||||
out WorldSession.EntitySpawn merged)
|
||||
{
|
||||
if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn retained))
|
||||
{
|
||||
merged = default;
|
||||
return false;
|
||||
}
|
||||
if (retained.ObjectDescriptionFlags == bitfield)
|
||||
{
|
||||
merged = retained;
|
||||
return false;
|
||||
}
|
||||
merged = retained with { ObjectDescriptionFlags = bitfield };
|
||||
_snapshots[guid] = merged;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SameGenerationCreateObjectEvents BuildSameGenerationEvents(
|
||||
WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -642,6 +642,16 @@ public sealed class RuntimeEntityDirectory
|
|||
out WorldSession.EntitySpawn merged) =>
|
||||
_inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged);
|
||||
|
||||
/// <summary>
|
||||
/// #297 review round 2: see
|
||||
/// <see cref="InboundPhysicsStateController.TryRefreshObjectDescriptionFlags"/>.
|
||||
/// </summary>
|
||||
internal bool TryRefreshObjectDescriptionFlags(
|
||||
uint guid,
|
||||
uint bitfield,
|
||||
out WorldSession.EntitySpawn merged) =>
|
||||
_inbound.TryRefreshObjectDescriptionFlags(guid, bitfield, out merged);
|
||||
|
||||
private bool IsKnown(RuntimeEntityRecord record)
|
||||
{
|
||||
if (IsCurrent(record))
|
||||
|
|
|
|||
|
|
@ -163,6 +163,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
private readonly List<Func<int>> _firstEntryDriveOwnership = [];
|
||||
/// <summary>C4 route 2: see <see cref="RegisterAcceptedPositionDriveOwnership"/>.</summary>
|
||||
private readonly List<Func<int>> _acceptedPositionDriveOwnership = [];
|
||||
/// <summary>
|
||||
/// #297 (review round 2, preferred fix): keeps every canonical
|
||||
/// snapshot's <c>ObjectDescriptionFlags</c> live against
|
||||
/// <c>ClientObjectTable.PublicWeenieBitfield</c> — see
|
||||
/// <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/> for the full
|
||||
/// rationale.
|
||||
/// </summary>
|
||||
private readonly RuntimeEntityPvpBitfieldSnapshotSync _pvpBitfieldSync;
|
||||
|
||||
public RuntimeEntityObjectLifetime(
|
||||
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId,
|
||||
|
|
@ -175,6 +183,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
timeProvider: timeProvider,
|
||||
gameClock: gameClock);
|
||||
Objects = new ClientObjectTable();
|
||||
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
|
||||
// AP-129 (Campaign P Slice P4 review fix, 2026-07-30): the physics
|
||||
// entry-restriction gate (ObjectInfo.CheckEntryRestrictions) resolves
|
||||
// a restricted cell's owner/guest list through the SAME live
|
||||
|
|
@ -263,6 +272,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
timeProvider,
|
||||
gameClock);
|
||||
Objects = new ClientObjectTable();
|
||||
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
|
||||
Physics.Engine.Objects = Objects;
|
||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||
EntityView = views.Entities;
|
||||
|
|
@ -345,6 +355,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
timeProvider,
|
||||
gameClock);
|
||||
Objects = new ClientObjectTable();
|
||||
_pvpBitfieldSync = new RuntimeEntityPvpBitfieldSnapshotSync(Entities, Objects);
|
||||
Physics.Engine.Objects = Objects;
|
||||
var views = new RuntimeEntityObjectViews(Entities, Objects);
|
||||
EntityView = views.Entities;
|
||||
|
|
@ -2009,6 +2020,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
finally
|
||||
{
|
||||
_disposed = true;
|
||||
_pvpBitfieldSync.Dispose();
|
||||
Events.Dispose();
|
||||
Physics.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.Runtime.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// #297 (preferred fix, review round 2): rewrites the canonical Runtime
|
||||
/// snapshot's <c>ObjectDescriptionFlags</c> (retail
|
||||
/// <c>PublicWeenieDesc._bitfield</c>) in place whenever a live
|
||||
/// <c>PropertyInt.PlayerKillerStatus</c> update lands on
|
||||
/// <see cref="ClientObjectTable"/>, mirroring the same
|
||||
/// <c>PlayerKillerStatusBitfield</c> rewrite
|
||||
/// <c>ClientObjectTable.UpdateIntProperty</c> already applies to
|
||||
/// <see cref="ClientObject.PublicWeenieBitfield"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Fixing it at the SOURCE — the one <see cref="RuntimeEntityRecord.Snapshot"/>
|
||||
/// every subsystem reads — means every downstream consumer inherits the live
|
||||
/// value by construction, with no second writer:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>ObjDesc/appearance rebuild
|
||||
/// (<c>LiveEntityCollisionBuilder.Build</c> reads
|
||||
/// <c>spawn.ObjectDescriptionFlags</c> from the very snapshot this class
|
||||
/// keeps live) no longer reverts a live PK-status change on the next
|
||||
/// equip/dequip — the F1 defect from review round 2.</item>
|
||||
/// <item>Placement/teleport/spawn-settle mover-flags resolution
|
||||
/// (<c>RuntimeSetPositionMoverPreparation</c> reads
|
||||
/// <c>record.Snapshot.ObjectDescriptionFlags</c> directly, not the live
|
||||
/// table) sees the live value too — F2.</item>
|
||||
/// <item>The vivid target indicator
|
||||
/// (<c>WorldSelectionQuery.ResolveVividTargetInfo</c> reads
|
||||
/// <c>LiveEntityRuntime.TryGetSnapshot</c>, the App-side mirror of this
|
||||
/// same Runtime snapshot) is fixed for free — F4.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Every ClientObjectTable update — not just PropertyInt 134 — fires
|
||||
/// <see cref="ClientObjectTable.ObjectUpdated"/>, so the idempotency guard
|
||||
/// inside <see cref="RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags"/>
|
||||
/// is load-bearing: without it, an unrelated property change (e.g. an
|
||||
/// inventory EncumbranceVal move) would still allocate a new snapshot record
|
||||
/// for no reason. This mirrors the review's F3 guidance for the target-side
|
||||
/// shadow-registry sync.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// There are TWO related-but-distinct snapshot stores in Runtime: the ACTIVE
|
||||
/// <see cref="RuntimeEntityRecord.Snapshot"/> every subsystem reads, and
|
||||
/// <c>InboundPhysicsStateController</c>'s own retained copy that every
|
||||
/// untimestamped-field merge (ObjDesc, same-generation CreateObject, ...)
|
||||
/// uses as its <c>old</c>/<c>retained</c> base. Rewriting only the active
|
||||
/// record's copy is not enough — the NEXT such merge would revert it, because
|
||||
/// it starts from the OTHER store. This is exactly the hazard the Round 3 A1
|
||||
/// seam (<c>ApplyAcceptedObjDescSnapshot</c>) was introduced to close for
|
||||
/// other fields; <see cref="RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags"/>
|
||||
/// keeps both stores in lockstep for this one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RuntimeEntityPvpBitfieldSnapshotSync : IDisposable
|
||||
{
|
||||
private readonly RuntimeEntityDirectory _entities;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeEntityPvpBitfieldSnapshotSync(
|
||||
RuntimeEntityDirectory entities,
|
||||
ClientObjectTable objects)
|
||||
{
|
||||
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_objects.ObjectUpdated += OnObjectUpdated;
|
||||
}
|
||||
|
||||
private void OnObjectUpdated(ClientObject item)
|
||||
{
|
||||
if (item.PublicWeenieBitfield is not { } bitfield)
|
||||
return;
|
||||
if (!_entities.TryRefreshObjectDescriptionFlags(
|
||||
item.ObjectId,
|
||||
bitfield,
|
||||
out WorldSession.EntitySpawn merged))
|
||||
{
|
||||
return; // unchanged, or no CreateObject snapshot exists for this guid
|
||||
}
|
||||
if (_entities.TryGetActive(item.ObjectId, out RuntimeEntityRecord record))
|
||||
_entities.RefreshSnapshot(record, merged, refreshPosition: false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_objects.ObjectUpdated -= OnObjectUpdated;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue