acdream/src/AcDream.Core/Items/ClientObjectTable.cs
Erik 9b1e6fc637 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>
2026-08-03 20:59:01 +02:00

1511 lines
62 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Ordered container snapshot entry from retail ContentProfile:
/// guid plus m_uContainerProperties.
/// </summary>
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
/// <summary>One login-time PlayerDescription equipment placement.</summary>
public readonly record struct EquipmentManifestEntry(
uint Guid,
EquipMask EquipLocation,
uint Priority);
/// <summary>
/// Server rejection of an inventory move request. <paramref name="RolledBack"/>
/// is true when the table restored a locally optimistic move; false for
/// server-confirmed transactions which deliberately left the local projection
/// unchanged while waiting.
/// </summary>
public readonly record struct MoveRequestFailure(
uint ItemId,
uint WeenieError,
bool RolledBack);
/// <summary>
/// One side of retail's <c>ServerSaysMoveItem_s</c> placement transition.
/// Container, slot, wielder, and equip location are captured before listeners
/// run so they never have to infer the old state from the already-mutated item.
/// </summary>
public readonly record struct ClientObjectPlacement(
uint ContainerId,
int ContainerSlot,
uint WielderId,
EquipMask EquipLocation)
{
public static ClientObjectPlacement From(ClientObject item) => new(
item.ContainerId,
item.ContainerSlot,
item.WielderId,
item.CurrentlyEquippedLocation);
}
/// <summary>
/// Retail-shaped inventory placement notice. Named retail
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
/// publishes both complete placements after applying the new state.
/// </summary>
public enum ClientObjectMoveOrigin
{
LocalProjection,
ServerRollbackProjection,
AuthoritativeResponse,
}
public readonly record struct ClientObjectMove(
uint ItemId,
ClientObject? Item,
ClientObjectPlacement Previous,
ClientObjectPlacement Current,
ClientObjectMoveOrigin Origin);
public enum ClientObjectRemovalReason
{
Ordinary,
LogicalDelete,
GenerationReplacement,
}
public readonly record struct ClientObjectRemoval(
ClientObject Object,
ClientObjectRemovalReason Reason,
ushort Generation);
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
///
/// <para>
/// Retail semantics (r06):
/// <list type="bullet">
/// <item><description>
/// Every object is a <see cref="ClientObject"/> with a unique
/// <c>ObjectId</c>. CreateObject seeds it when the server tells us
/// the item exists (in our inventory, on the ground, in a
/// vendor's list, etc).
/// </description></item>
/// <item><description>
/// Moves happen via <see cref="GameEventType"/>-carrying messages:
/// <c>WieldObject</c>, <c>InventoryPutObjInContainer</c>,
/// <c>InventoryPutObjectIn3D</c>, <c>ViewContents</c>,
/// <c>CloseGroundContainer</c>.
/// </description></item>
/// <item><description>
/// <c>InventoryServerSaveFailed</c> reverts a speculative local
/// state change (e.g. when a drag-drop was rejected server-side).
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Thread safety: designed for single-threaded use from the render
/// thread; the event delegates run synchronously on the caller's
/// thread. A <see cref="ConcurrentDictionary{TKey,TValue}"/> backs the
/// map so plugin code can look up items from any thread without
/// corrupting state.
/// </para>
/// </summary>
public sealed class ClientObjectTable
{
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = new();
private readonly HashSet<ClientObject> _restrictionObservedObjects =
new(ReferenceEqualityComparer.Instance);
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
private ulong _mutationRevision;
public ClientObjectTable()
{
// Keep one conservative authority over every object mutation that can
// affect physics entry restrictions. These handlers are registered
// before any consumer can subscribe, so re-entrant observers see the
// advanced revision before they can evaluate or publish a receipt.
ObjectAdded += _ => AdvanceMutationRevision();
ObjectMoved += _ => AdvanceMutationRevision();
ObjectRemoved += _ => AdvanceMutationRevision();
ObjectUpdated += _ => AdvanceMutationRevision();
Cleared += AdvanceMutationRevision;
}
/// <summary>
/// Monotonic authority for live object-table qualities used by physics,
/// including house ownership/restrictions and the mover's monarch.
/// </summary>
internal ulong MutationRevision => _mutationRevision;
private void AdvanceMutationRevision() =>
_mutationRevision = checked(_mutationRevision + 1UL);
private void RetainObject(ClientObject item)
{
if (_objects.TryGetValue(item.ObjectId, out ClientObject? prior)
&& !ReferenceEquals(prior, item))
{
UnbindRestrictionAuthority(prior);
}
_objects[item.ObjectId] = item;
BindRestrictionAuthority(item);
}
private void BindRestrictionAuthority(ClientObject item)
{
if (!_restrictionObservedObjects.Add(item)) return;
item.RestrictionAuthorityChanged += OnRestrictionAuthorityChanged;
}
private void UnbindRestrictionAuthority(ClientObject item)
{
if (!_restrictionObservedObjects.Remove(item)) return;
item.RestrictionAuthorityChanged -= OnRestrictionAuthorityChanged;
}
private void OnRestrictionAuthorityChanged(ClientObject item)
{
// Unbinding is synchronous, but keep the exact-reference check as a
// defensive lifetime gate against a stale/replaced object callback.
if (_objects.TryGetValue(item.ObjectId, out ClientObject? retained)
&& ReferenceEquals(retained, item))
{
AdvanceMutationRevision();
}
}
/// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded;
/// <summary>
/// Fires after a complete placement notice (moved between packs,
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Origin"/>
/// distinguishes an immediate local projection from ACE's authoritative
/// response; transaction owners must complete only on the latter.
/// <see cref="ClientObjectMove.Item"/>
/// is null when retail publishes the notice before CreateObject resolves
/// the GUID; the old/new placement remains authoritative notice data.
/// </summary>
public event Action<ClientObjectMove>? ObjectMoved;
/// <summary>
/// Fires after retail ViewContents replaces a container's ordered member
/// lists. This is a list-projection change, not an item ownership move.
/// </summary>
public event Action<uint>? ContainerContentsReplaced;
/// <summary>
/// Fires after an optimistic inventory/equipment move is rejected and
/// <see cref="RollbackMove"/> has restored the exact pre-move state.
/// Consumers with projections outside the item grid (for example a
/// parented 3-D weapon) can restore their prior projection without
/// treating every ordinary equip as a rollback.
/// </summary>
public event Action<ClientObject>? MoveRolledBack;
/// <summary>
/// Fires for an authoritative WieldObject response after its object state
/// has been applied. This is deliberately independent of optimistic-move
/// rollback bookkeeping: retail clears IR_WIELD on the matching server
/// response even when separate reconciliation state exists for the item.
/// </summary>
public event Action<uint>? WieldConfirmed;
/// <summary>
/// Fires for every InventoryServerSaveFailed response, including requests
/// which did not mutate the table optimistically. Transaction coordinators
/// use this to abandon server-confirmed multi-step moves without timers.
/// </summary>
public event Action<MoveRequestFailure>? MoveRequestFailed;
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? ObjectRemoved;
/// <summary>
/// Generation-aware removal stream for runtime owners. UI consumers keep
/// using <see cref="ObjectRemoved"/>; runtime owners use the reason and
/// generation to preserve only packets addressed to future incarnations.
/// </summary>
public event Action<ClientObjectRemoval>? ObjectRemovalClassified;
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated;
/// <summary>
/// Fires only for an authoritative SetStackSize response. Inventory request
/// owners use this narrower seam instead of treating an unrelated appraisal
/// or property update as the response to a merge, split, give, or drop.
/// </summary>
public event Action<ClientObject>? StackSizeUpdated;
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
public event Action? Cleared;
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
/// <see cref="ClientObject.Effects"/>.</summary>
public const uint UiEffectsPropertyId = 18u;
public const uint CurrentWieldedLocationPropertyId = 10u;
public const uint HookTypePropertyId = 151u;
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;
public int ContainerProjectionCount => _containerIndex.Count;
public int EquipmentOwnerCount => _equipmentIndex.Count;
public int PendingMoveCount => _pendingMoves.Count;
public IEnumerable<ClientObject> Objects => _objects.Values;
public IEnumerable<Container> Containers => _containers.Values;
/// <summary>
/// Look up an object by its server-assigned <c>ObjectId</c>.
/// </summary>
public ClientObject? Get(uint objectId) =>
_objects.TryGetValue(objectId, out var item) ? item : null;
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0</c>.
/// Direct containment/wielding counts, as does an item inside one of the
/// owner's authored pack containers or on one of the owner's locations.
/// </summary>
public bool IsOwnedByObject(uint objectId, uint ownerId)
{
if (ownerId == 0u
|| !_objects.TryGetValue(objectId, out ClientObject? item))
{
return false;
}
if (item.ObjectId == ownerId
|| item.ContainerId == ownerId
|| item.WielderId == ownerId)
{
return true;
}
if (!_objects.ContainsKey(ownerId))
return false;
if (item.ContainerId != 0u
&& _containerIndex.TryGetValue(ownerId, out List<uint>? ownerContents)
&& ownerContents.Contains(item.ContainerId))
{
return true;
}
return _equipmentIndex.TryGetValue(ownerId, out List<uint>? ownerLocations)
&& ownerLocations.Contains(item.ObjectId);
}
/// <summary>
/// Look up a container by object id, creating a lightweight stub if
/// the id doesn't match any known container (defensive — avoids losing
/// references when the server announces a move into a container it
/// hasn't described yet).
/// </summary>
public Container? GetContainer(uint objectId) =>
_containers.TryGetValue(objectId, out var c) ? c : null;
/// <summary>
/// Register / refresh an object in the table. Called on
/// CreateObject for item-typed weenies and on IdentifyObjectResponse
/// to fill in detail properties.
/// Does NOT update the container index — use Ingest for container-tracked objects.
/// </summary>
public void AddOrUpdate(ClientObject item)
{
ArgumentNullException.ThrowIfNull(item);
bool existed = _objects.TryGetValue(item.ObjectId, out ClientObject? prior);
ClientObjectPlacement previous = prior is null
? default
: ClientObjectPlacement.From(prior);
RetainObject(item);
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
/// <summary>
/// Register a container. Idempotent.
/// </summary>
public void AddContainer(Container container)
{
ArgumentNullException.ThrowIfNull(container);
_containers[container.ObjectId] = container;
}
/// <summary>
/// Move the local projection of an item without changing authoritative
/// WielderId. Optimistic inventory requests use this path because retail
/// UIAttemptWield sends the request without rewriting public weenie fields.
/// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
return ApplyPlacement(
item,
new ClientObjectPlacement(
newContainerId,
newSlot,
item.WielderId,
newEquipLocation),
containerTypeHint,
ClientObjectMoveOrigin.LocalProjection);
}
/// <summary>
/// Apply an authoritative server move, including the item's wielder
/// ownership. Retail <c>ACCWeenieObject::ServerSaysMoveItem @
/// 0x0058DBB0</c> updates <c>_containerID</c>, <c>_wielderID</c>, and
/// <c>_location</c> in one callback. Keeping the old wielder after an
/// unwield makes <c>DetermineUseResult @ 0x00588460</c> classify a bow in
/// the backpack as already wielded, so a later double click sends no
/// request.
/// </summary>
public bool ApplyServerMove(
uint itemId,
uint newContainerId,
uint newWielderId,
int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None,
uint? containerTypeHint = null)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
return ApplyPlacement(
item,
new ClientObjectPlacement(
newContainerId,
newSlot,
newWielderId,
newEquipLocation),
containerTypeHint,
ClientObjectMoveOrigin.AuthoritativeResponse,
retailContainerInsert: true);
}
private bool ApplyPlacement(
ClientObject item,
ClientObjectPlacement current,
uint? containerTypeHint,
ClientObjectMoveOrigin origin,
bool retailContainerInsert = false)
{
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
bool previousWasContainer = IsContainerListMember(item);
List<uint>? changedContainers;
if (retailContainerInsert)
{
changedContainers = RemoveFromAllContainerIndexes(
item.ObjectId,
current.ContainerId,
previousWasContainer);
}
else
{
changedContainers = RemoveFromOtherContainerIndexes(
item.ObjectId,
current.ContainerId);
}
item.ContainerId = current.ContainerId;
item.ContainerSlot = current.ContainerSlot;
item.WielderId = current.WielderId;
item.CurrentlyEquippedLocation = current.EquipLocation;
if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint;
if (retailContainerInsert && item.ContainerId != 0u)
InsertContainerMember(item, current.ContainerSlot);
else
Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous, origin);
PublishContainerContentsChanges(changedContainers);
return true;
}
private void PublishPlacementChange(
ClientObject item,
ClientObjectPlacement previous,
ClientObjectMoveOrigin origin)
{
ClientObjectPlacement current = ClientObjectPlacement.From(item);
UpdateEquipmentIndex(item.ObjectId, previous, current);
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current, origin));
}
/// <summary>
/// Applies a server-confirmed container/world move. The matching pending
/// request is reconciled before <see cref="ObjectMoved"/> is published,
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
/// a new request without the old confirmation consuming it afterward.
/// </summary>
public bool ApplyConfirmedServerMove(
uint itemId,
uint newContainerId,
uint newWielderId,
int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None,
uint? containerTypeHint = null)
{
ConfirmMove(itemId);
if (ApplyServerMove(
itemId,
newContainerId,
newWielderId,
newSlot,
newEquipLocation,
containerTypeHint))
{
return true;
}
ObjectMoved?.Invoke(new ClientObjectMove(
itemId,
Item: null,
Previous: default,
Current: new ClientObjectPlacement(
newContainerId,
newSlot,
newWielderId,
newEquipLocation),
ClientObjectMoveOrigin.AuthoritativeResponse));
return false;
}
/// <summary>
/// Applies retail WieldObject (0x0023). The packet has only item and
/// location; the local player is the wielder, while ContainerId is zero.
/// Pending reconciliation precedes notifications as in
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>.
/// </summary>
public bool ApplyConfirmedServerWield(
uint itemId,
uint wielderId,
EquipMask equipLocation)
{
ConfirmMove(itemId);
var placement = new ClientObjectPlacement(
ContainerId: 0u,
ContainerSlot: 0,
WielderId: wielderId,
EquipLocation: equipLocation);
if (!_objects.TryGetValue(itemId, out ClientObject? item))
{
ObjectMoved?.Invoke(new ClientObjectMove(
itemId,
Item: null,
Previous: default,
Current: placement,
ClientObjectMoveOrigin.AuthoritativeResponse));
WieldConfirmed?.Invoke(itemId);
return false;
}
if (!ApplyPlacement(
item,
placement,
containerTypeHint: null,
ClientObjectMoveOrigin.AuthoritativeResponse))
{
return false;
}
WieldConfirmed?.Invoke(itemId);
return true;
}
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
/// move) and WieldItemOptimistic (wield).</summary>
private void RecordPending(uint itemId, ClientObject item)
{
if (_pendingMoves.TryGetValue(itemId, out var p))
_pendingMoves[itemId] = (p.placement, p.outstanding + 1);
else
_pendingMoves[itemId] = (ClientObjectPlacement.From(item), 1);
}
/// <summary>
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
/// </summary>
public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
// Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The
// count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME
// item from clearing the snapshot while a later move is still unconfirmed — else a later
// reject would find nothing pending and strand the item at the rejected position.
RecordPending(itemId, item);
// Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both
// containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with
// the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint
// (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list.
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
uint oldContainer = previous.ContainerId;
item.ContainerId = newContainerId;
item.CurrentlyEquippedLocation = EquipMask.None;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
newContainerId);
if (oldContainer != 0 && oldContainer != newContainerId
&& _containerIndex.TryGetValue(oldContainer, out var srcList))
{ srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too
if (newContainerId != 0)
{
if (!_containerIndex.TryGetValue(newContainerId, out var dstList))
_containerIndex[newContainerId] = dstList = new List<uint>();
dstList.Remove(itemId); // same-container move: pull out first
int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot;
dstList.Insert(idx, itemId);
RenumberContainer(dstList); // sequential ContainerSlots → no ties
}
else item.ContainerSlot = newSlot;
PublishPlacementChange(item, previous, ClientObjectMoveOrigin.LocalProjection);
PublishContainerContentsChanges(changedContainers);
return true;
}
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged
/// until <see cref="ApplyConfirmedServerWield"/> receives the authoritative WieldObject, matching retail's
/// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to
/// ContainerId zero + authoritative WielderId. <see cref="RollbackMove"/> restores the local projection. Fires
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
RecordPending(itemId, item);
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
}
/// <summary>Assign each item in <paramref name="list"/> a sequential ContainerSlot (0..N-1) matching
/// its list position — keeps a container gapless + collision-free so the grid order is stable.</summary>
private void RenumberContainer(List<uint> list)
{
for (int i = 0; i < list.Count; i++)
if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i;
}
/// <summary>The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the
/// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op
/// for a server-initiated move we never tracked.</summary>
public void ConfirmMove(uint itemId)
{
if (!_pendingMoves.TryGetValue(itemId, out var p)) return;
if (p.outstanding <= 1)
{
_pendingMoves.Remove(itemId);
}
else
{
_pendingMoves[itemId] = (p.placement, p.outstanding - 1);
}
}
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
/// any still-outstanding moves). False if nothing was pending.</summary>
public bool RollbackMove(uint itemId)
{
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId);
ClientObjectPlacement placement = pre.placement;
if (!_objects.TryGetValue(itemId, out ClientObject? item)
|| !ApplyPlacement(
item,
placement,
containerTypeHint: null,
ClientObjectMoveOrigin.ServerRollbackProjection))
return false;
MoveRolledBack?.Invoke(_objects[itemId]);
return true;
}
/// <summary>
/// Reconcile an InventoryServerSaveFailed response and publish the failure
/// even when no optimistic snapshot exists. Retail's blocked AutoWield path
/// waits for the server before changing its local item lists, so failure is
/// still a meaningful transaction event in that case.
/// </summary>
public bool RejectMove(uint itemId, uint weenieError)
{
bool rolledBack = RollbackMove(itemId);
MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack));
return rolledBack;
}
/// <summary>
/// Handle a server-driven remove (destroyed item, dropped into 3D
/// space, stolen, etc).
/// </summary>
public bool Remove(uint itemId)
=> RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true);
/// <summary>Removes an exact accepted server object incarnation.</summary>
public bool RemoveLogicalGeneration(uint itemId, ushort generation)
=> RemoveCore(
itemId,
ClientObjectRemovalReason.LogicalDelete,
generation,
notifyObjectRemoved: true);
private bool RemoveCore(
uint itemId,
ClientObjectRemovalReason reason,
ushort generation,
bool notifyObjectRemoved)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
UnbindRestrictionAuthority(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
exceptContainerId: 0u);
UpdateEquipmentIndex(
itemId,
ClientObjectPlacement.From(item),
default);
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
if (notifyObjectRemoved)
ObjectRemoved?.Invoke(item);
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
PublishContainerContentsChanges(changedContainers);
return true;
}
/// <summary>
/// Atomically replaces the retained qualities for a newer server object
/// incarnation while publishing a generation-specific teardown event.
/// </summary>
public ClientObject? ReplaceGeneration(
WeenieData data,
ushort generation,
Func<bool>? canInstallReplacement = null)
{
RemoveCore(
data.Guid,
ClientObjectRemovalReason.GenerationReplacement,
generation,
notifyObjectRemoved: true);
// Removal observers may synchronously accept a newer CreateObject for
// this GUID. The outer generation must not resume and merge its stale
// qualities into that replacement. The authoritative live-identity
// owner supplies the exact-incarnation predicate.
if (canInstallReplacement?.Invoke() == false)
return null;
return Ingest(data);
}
/// <summary>
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual
/// keys in the incoming bundle overwrite existing values; keys not
/// present are left untouched.
/// </summary>
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Apply a <see cref="PropertyBundle"/> patch, creating the object if it does not
/// exist yet. Used for the local player's own properties from PlayerDescription
/// (0x0013), which may arrive BEFORE the player's CreateObject — unlike
/// <see cref="UpdateProperties"/>, which no-ops on an unknown object. Fires
/// ObjectAdded on create, else ObjectUpdated.
/// </summary>
public void UpsertProperties(uint guid, PropertyBundle incoming)
{
ArgumentNullException.ThrowIfNull(incoming);
bool existed = _objects.TryGetValue(guid, out var item);
if (!existed || item is null)
{
item = new ClientObject { ObjectId = guid };
RetainObject(item);
}
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
}
/// <summary>
/// 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"/>;
/// 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)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == SharedCooldownPropertyId) item.CooldownId = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
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);
return true;
}
private static void ApplyCooldownProperties(
ClientObject item,
PropertyBundle properties)
{
if (properties.Ints.TryGetValue(
SharedCooldownPropertyId,
out int cooldownId))
item.CooldownId = (uint)cooldownId;
if (properties.Floats.TryGetValue(
CooldownDurationPropertyId,
out double cooldownDuration))
item.CooldownDuration = cooldownDuration;
}
/// <summary>
/// Apply a single PropertyInt64 update to an object's bundle and fire
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
/// <see cref="UpdateIntProperty"/> (used by the character sheet's
/// optimistic unassigned-XP debit; also the hook for future
/// PrivateUpdatePropertyInt64 parsing). False if the object is unknown.
/// </summary>
public bool UpdateInt64Property(uint itemId, uint propertyId, long value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Int64s[propertyId] = value;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and
/// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the
/// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...).
/// </summary>
public bool UpdateStackSize(uint guid, int stackSize, int value)
{
if (!_objects.TryGetValue(guid, out var item)) return false;
item.StackSize = stackSize;
item.Value = value;
ObjectUpdated?.Invoke(item);
StackSizeUpdated?.Invoke(item);
return true;
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): live <c>House_UpdateRestrictions
/// (0x0248)</c> refresh of a house object's guest/ban list — retail resends
/// the whole <c>RestrictionDB</c> (open flag + allegiance monarch + guest
/// table) as one unit rather than a delta. No-ops (returns false) if the
/// house object hasn't arrived via CreateObject yet, matching every other
/// targeted property update in this table.
/// </summary>
public bool UpdateHouseRestrictions(uint guid, HouseRestrictionRecord restrictions)
{
ArgumentNullException.ThrowIfNull(restrictions);
if (!_objects.TryGetValue(guid, out var item)) return false;
item.Restrictions = restrictions;
ObjectUpdated?.Invoke(item);
return true;
}
/// <summary>
/// Canonical CreateObject ingestion: create-if-absent, else patch the
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
/// PropertyBundle (appraise) and any field the wire didn't carry.
/// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract.
/// </summary>
public ClientObject Ingest(WeenieData d)
{
bool existed = _objects.TryGetValue(d.Guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = d.Guid };
RetainObject(obj);
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!;
if (d.Type is { } t) obj.Type = t;
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known
// class id with a spurious 0 (and leaves a PD stub's 0 until CreateObject fills it).
if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId;
if (d.IconId != 0) obj.IconId = d.IconId;
if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId;
if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId;
obj.Effects = d.Effects; // D.5.2 contract
if (d.Value is { } v) obj.Value = v;
if (d.StackSize is { } s) obj.StackSize = s;
if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm;
if (d.Burden is { } b) obj.Burden = b;
if (d.ContainerId is { } c) obj.ContainerId = c;
if (d.WielderId is { } w) obj.WielderId = w;
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.Useability is { } use) obj.Useability = use;
if (d.TargetType is { } targetType) obj.TargetType = targetType;
if (d.PublicWeenieBitfield is { } bitfield) obj.PublicWeenieBitfield = bitfield;
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
if (d.SpellId is { } spellId) obj.SpellId = spellId;
if (d.CooldownId is { } cooldownId) obj.CooldownId = cooldownId;
if (d.CooldownDuration is { } cooldownDuration)
obj.CooldownDuration = cooldownDuration;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.HookItemTypes is { } hookItemTypes) obj.HookItemTypes = hookItemTypes;
if (d.HookType is { } hookType) obj.HookType = hookType;
if (d.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
if (d.MaterialType is { } materialType) obj.MaterialType = materialType;
if (d.HouseOwnerId is { } houseOwnerId) obj.HouseOwnerId = houseOwnerId;
if (d.MonarchId is { } monarchId) obj.MonarchId = monarchId;
if (d.Restrictions is { } restrictions) obj.Restrictions = restrictions;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj;
}
/// <summary>
/// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. PlayerDescription may
/// use the player as <paramref name="containerId"/> for its login-time
/// placement projection; live WieldObject confirmation later establishes
/// canonical ContainerId zero plus WielderId player.
/// Never touches icon/name/type/effects — that data comes from CreateObject.
/// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None, uint? containerTypeHint = null,
uint? priority = null)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = guid };
RetainObject(obj);
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (containerId != 0)
obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = equip;
if (equip != EquipMask.None)
obj.ContainerSlot = -1;
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
if (priority is { } p) obj.Priority = p;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj;
}
/// <summary>
/// Initialize PlayerDescription equipment in canonical retail form while
/// preserving the packed InventoryPlacement list order exactly. Retail
/// <c>UpdateObjectInventory @ 0x00559550</c> assigns the complete list; it
/// does not replay live head-insertion for each login entry.
/// </summary>
public void InitializeEquipmentManifest(
uint wielderId,
IReadOnlyList<EquipmentManifestEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (wielderId == 0u) return;
var ordered = new List<uint>(entries.Count);
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var changedContainers = new List<uint>();
var incoming = new HashSet<uint>();
for (int i = 0; i < entries.Count; i++)
incoming.Add(entries[i].Guid);
// UpdateObjectInventory assigns a complete authoritative list. Clear
// canonical placement on members omitted by a replay/replacement
// manifest before installing the new order; replacing only the index
// leaves ghost equipment visible to AutoWield and burden queries.
if (_equipmentIndex.TryGetValue(wielderId, out List<uint>? priorEquipment))
{
foreach (uint priorId in priorEquipment.ToArray())
{
if (incoming.Contains(priorId)
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|| prior is null
|| (prior.WielderId != wielderId
&& !(prior.ContainerId == wielderId
&& prior.CurrentlyEquippedLocation != EquipMask.None)))
continue;
prior.ContainerId = 0u;
prior.ContainerSlot = -1;
prior.WielderId = 0u;
prior.CurrentlyEquippedLocation = EquipMask.None;
prior.Priority = 0u;
if (RemoveFromOtherContainerIndexes(priorId, exceptContainerId: 0u)
is { } removedFrom)
{
changedContainers.AddRange(removedFrom);
}
notifications.Add((prior, true));
}
}
for (int i = 0; i < entries.Count; i++)
{
EquipmentManifestEntry entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
RetainObject(obj);
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
obj.ContainerId = 0u;
obj.ContainerSlot = -1;
obj.WielderId = wielderId;
obj.CurrentlyEquippedLocation = entry.EquipLocation;
obj.Priority = entry.Priority;
if (RemoveFromOtherContainerIndexes(entry.Guid, exceptContainerId: 0u)
is { } changed)
{
changedContainers.AddRange(changed);
}
Reindex(obj, previous.ContainerId);
RemoveEquipmentIndexMembership(entry.Guid);
notifications.Add((obj, existed));
}
_equipmentIndex[wielderId] = ordered;
foreach ((ClientObject item, bool existed) in notifications)
{
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
PublishContainerContentsChanges(changedContainers);
}
private List<uint>? RemoveFromOtherContainerIndexes(
uint itemId,
uint exceptContainerId)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (containerId == exceptContainerId || !members.Remove(itemId))
continue;
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
/// <summary>
/// Retail <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>
/// removes the object from its old <c>IDList</c> before
/// <c>AddContent @ 0x0058CCE0</c> inserts it at the server-supplied list
/// index. Remove the canonical member as well as stale ViewContents
/// projections so an authoritative same-container move can perform that
/// exact remove-then-insert transition.
/// </summary>
private List<uint>? RemoveFromAllContainerIndexes(
uint itemId,
uint destinationContainerId,
bool wasContainer)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (!members.Remove(itemId))
continue;
RenumberContainerCategory(members, wasContainer);
if (containerId != destinationContainerId)
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
/// <summary>
/// Port of retail <c>ACCWeenieObject::AddContent @ 0x0058CCE0</c>:
/// items and child containers have separate ordered <c>IDList</c>s and
/// <c>IDList::AddAtNum</c> clamps the requested index to the list length.
/// Our compact projection stores both categories in one list, so insertion
/// and renumbering operate only inside the matching retail category.
/// </summary>
private void InsertContainerMember(ClientObject item, int requestedSlot)
{
if (!_containerIndex.TryGetValue(item.ContainerId, out List<uint>? members))
_containerIndex[item.ContainerId] = members = new List<uint>();
bool isContainer = IsContainerListMember(item);
int categoryCount = 0;
int insertionIndex = members.Count;
int requestedIndex = requestedSlot < 0 ? int.MaxValue : requestedSlot;
for (int i = 0; i < members.Count; i++)
{
if (!_objects.TryGetValue(members[i], out ClientObject? existing)
|| IsContainerListMember(existing) != isContainer)
{
continue;
}
if (categoryCount == requestedIndex)
{
insertionIndex = i;
break;
}
categoryCount++;
insertionIndex = i + 1;
}
members.Insert(insertionIndex, item.ObjectId);
int publishedSlot = requestedSlot < 0 ? categoryCount : requestedSlot;
RenumberContainerCategory(
members,
isContainer,
preservedItemId: item.ObjectId,
preservedSlot: publishedSlot);
}
private void RenumberContainerCategory(
List<uint> members,
bool isContainer,
uint preservedItemId = 0u,
int preservedSlot = -1)
{
int slot = 0;
for (int i = 0; i < members.Count; i++)
{
if (_objects.TryGetValue(members[i], out ClientObject? member)
&& IsContainerListMember(member) == isContainer)
{
member.ContainerSlot = member.ObjectId == preservedItemId
? preservedSlot
: slot;
slot++;
}
}
}
private static bool IsContainerListMember(ClientObject item) =>
item.ContainerTypeHint != 0u
|| (item.Type & ItemType.Container) != 0
|| item.ItemsCapacity > 0;
private void PublishContainerContentsChanges(List<uint>? changed)
{
if (changed is null || changed.Count == 0)
return;
var published = new HashSet<uint>();
foreach (uint containerId in changed)
{
if (!published.Add(containerId))
continue;
ContainerContentsReplaced?.Invoke(containerId);
}
}
private void Reindex(ClientObject obj, uint oldContainerId)
{
if (oldContainerId != obj.ContainerId && oldContainerId != 0
&& _containerIndex.TryGetValue(oldContainerId, out var oldList))
oldList.Remove(obj.ObjectId);
if (obj.ContainerId != 0)
{
if (!_containerIndex.TryGetValue(obj.ContainerId, out var list))
_containerIndex[obj.ContainerId] = list = new List<uint>();
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
var priorOrder = new Dictionary<uint, int>(list.Count);
for (int i = 0; i < list.Count; i++)
priorOrder[list[i]] = i;
list.Sort((a, b) =>
{
int c = SlotOf(a).CompareTo(SlotOf(b));
return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]);
});
}
}
private static uint EquipmentOwner(ClientObjectPlacement placement)
{
if (placement.EquipLocation == EquipMask.None)
return 0u;
return placement.WielderId != 0u
? placement.WielderId
: placement.ContainerId;
}
private void RemoveEquipmentIndexMembership(uint itemId)
{
List<uint>? emptyOwners = null;
foreach ((uint ownerId, List<uint> placements) in _equipmentIndex)
{
placements.Remove(itemId);
if (placements.Count == 0)
(emptyOwners ??= new List<uint>()).Add(ownerId);
}
if (emptyOwners is null)
return;
foreach (uint ownerId in emptyOwners)
_equipmentIndex.Remove(ownerId);
}
/// <summary>
/// Maintain retail's <c>_invPlacement</c> order. Named retail
/// <c>SetPlayerWieldLocation @ 0x0058D8C0</c> inserts a placement at the
/// head; <c>GetObjectAtLocation @ 0x0058CE00</c> scans head-to-tail.
/// </summary>
private void UpdateEquipmentIndex(
uint itemId,
ClientObjectPlacement previous,
ClientObjectPlacement current)
{
if (previous == current)
return;
uint previousOwner = EquipmentOwner(previous);
if (previousOwner != 0u
&& _equipmentIndex.TryGetValue(previousOwner, out List<uint>? oldList))
{
oldList.Remove(itemId);
if (oldList.Count == 0)
_equipmentIndex.Remove(previousOwner);
}
uint currentOwner = EquipmentOwner(current);
if (currentOwner == 0u)
return;
if (!_equipmentIndex.TryGetValue(currentOwner, out List<uint>? currentList))
_equipmentIndex[currentOwner] = currentList = new List<uint>();
currentList.Remove(itemId);
currentList.Insert(0, itemId);
}
private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
? o.ContainerSlot
: int.MaxValue;
/// <summary>
/// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot.
/// Returns a SNAPSHOT (safe to hold / read off-thread); empty for an unknown container.
/// </summary>
public IReadOnlyList<uint> GetContents(uint containerId) =>
_containerIndex.TryGetValue(containerId, out var l)
? l.ToArray() : System.Array.Empty<uint>();
/// <summary>
/// Snapshot of items equipped by <paramref name="wielderId"/>. Confirmed
/// retail equipment has ContainerId zero and WielderId set; the
/// ContainerId arm keeps the optimistic pre-confirm projection visible.
/// </summary>
public IReadOnlyList<ClientObject> GetEquippedBy(uint wielderId) =>
_equipmentIndex.TryGetValue(wielderId, out List<uint>? placements)
? placements
.Select(Get)
.Where(item => item is not null
&& item.CurrentlyEquippedLocation != EquipMask.None
&& (item.WielderId == wielderId || item.ContainerId == wielderId))
.Cast<ClientObject>()
.ToArray()
: Array.Empty<ClientObject>();
/// <summary>
/// Replace only a viewed container's ordered membership projection.
/// Retail <c>ACCObjectMaint::ViewObjectContents @ 0x00558A70</c> rebuilds
/// the container lists without mutating child ownership or equip state.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
{
ArgumentNullException.ThrowIfNull(guids);
if (containerId == 0) return;
var entries = new ContainerContentEntry[guids.Count];
for (int i = 0; i < guids.Count; i++)
entries[i] = new ContainerContentEntry(guids[i], 0u);
ReplaceContents(containerId, entries);
}
/// <summary>
/// Replace only a viewed container's ordered membership projection.
/// ContentProfile order is retained in <see cref="GetContents"/> while the
/// child weenie's canonical placement remains owned by move/wield events.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (containerId == 0) return;
var ordered = new List<uint>(entries.Count);
var added = new List<ClientObject>();
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
RetainObject(obj);
}
obj.ContainerTypeHint = entry.ContainerType;
if (!existed) added.Add(obj);
}
_containerIndex[containerId] = ordered;
foreach (ClientObject item in added)
ObjectAdded?.Invoke(item);
ContainerContentsReplaced?.Invoke(containerId);
}
/// <summary>
/// Retires a viewed container's temporary ordered projection without inventing
/// item movement or deleting its child objects. Retail
/// <c>ACCObjectMaint::StopViewingObjectContents</c> is driven by server event
/// CloseGroundContainer; canonical ownership remains owned by move/delete wire.
/// </summary>
public bool StopViewingContents(uint containerId)
{
if (containerId == 0u || !_containerIndex.Remove(containerId))
return false;
ContainerContentsReplaced?.Invoke(containerId);
return true;
}
/// <summary>
/// Retires the root and every nested container projection reachable through
/// the last ViewContents snapshots. ACE sends a snapshot for each nested
/// container; retail queues the external root's contents tree for destruction
/// when the view closes.
/// </summary>
public int StopViewingContentsTree(uint rootContainerId)
{
if (rootContainerId == 0u)
return 0;
var pending = new Stack<uint>();
var visited = new HashSet<uint>();
var removed = new List<uint>();
pending.Push(rootContainerId);
while (pending.Count != 0)
{
uint containerId = pending.Pop();
if (!visited.Add(containerId)
|| !_containerIndex.TryGetValue(containerId, out List<uint>? contents))
continue;
foreach (uint childId in contents)
{
if (_containerIndex.ContainsKey(childId))
pending.Push(childId);
}
_containerIndex.Remove(containerId);
removed.Add(containerId);
}
foreach (uint containerId in removed)
ContainerContentsReplaced?.Invoke(containerId);
return removed.Count;
}
/// <summary>
/// Initialize canonical inventory ownership from PlayerDescription. This
/// login manifest is object-state initialization, not a move transition.
/// </summary>
public void InitializeInventoryManifest(
uint ownerId,
IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (ownerId == 0u) return;
var ordered = new List<uint>(entries.Count);
var changedContainers = new List<uint>();
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var incoming = new HashSet<uint>();
for (int i = 0; i < entries.Count; i++)
incoming.Add(entries[i].Guid);
if (_containerIndex.TryGetValue(ownerId, out List<uint>? priorInventory))
{
foreach (uint priorId in priorInventory.ToArray())
{
if (incoming.Contains(priorId)
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|| prior is null
|| prior.ContainerId != ownerId)
continue;
ClientObjectPlacement previous = ClientObjectPlacement.From(prior);
prior.ContainerId = 0u;
prior.ContainerSlot = -1;
prior.WielderId = 0u;
prior.CurrentlyEquippedLocation = EquipMask.None;
prior.Priority = 0u;
UpdateEquipmentIndex(
prior.ObjectId,
previous,
ClientObjectPlacement.From(prior));
notifications.Add((prior, true));
}
}
for (int i = 0; i < entries.Count; i++)
{
ContainerContentEntry entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
RetainObject(obj);
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
obj.ContainerId = ownerId;
obj.ContainerSlot = i;
obj.WielderId = 0u;
obj.CurrentlyEquippedLocation = EquipMask.None;
obj.ContainerTypeHint = entry.ContainerType;
if (RemoveFromOtherContainerIndexes(entry.Guid, ownerId) is { } changed)
changedContainers.AddRange(changed);
UpdateEquipmentIndex(entry.Guid, previous, ClientObjectPlacement.From(obj));
notifications.Add((obj, existed));
}
_containerIndex[ownerId] = ordered;
foreach ((ClientObject item, bool existed) in notifications)
{
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
ContainerContentsReplaced?.Invoke(ownerId);
PublishContainerContentsChanges(changedContainers);
}
/// <summary>
/// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
/// whose container chain roots at the owner (pack + side-bag contents, retail
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
/// equivalent of the server's <c>EncumbranceVal</c> (PropertyInt 5) — used by
/// the inventory burden bar until the wire value is parsed (B-Wire). The hop
/// cap (8) is a cycle guard; real chains are ≤2.
/// </summary>
public int SumCarriedBurden(uint ownerGuid)
{
int total = 0;
foreach (var o in _objects.Values)
if (IsCarriedBy(o, ownerGuid))
total += o.Burden;
return total;
}
// NOTE: WielderId is populated from authoritative wire data only (CreateObject →
// Ingest or ApplyServerMove). An
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
// Equipment queries must therefore accept authoritative WielderId OR the optimistic ContainerId.
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
{
if (o.WielderId == ownerGuid) return true;
uint c = o.ContainerId;
for (int hops = 0; c != 0 && hops < 8; hops++)
{
if (c == ownerGuid) return true;
c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
}
return false;
}
/// <summary>
/// Flush the table — typically called on logoff or teleport
/// that drops the session's object state.
/// </summary>
public void Clear()
{
foreach (ClientObject item in _restrictionObservedObjects.ToArray())
UnbindRestrictionAuthority(item);
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
_equipmentIndex.Clear();
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
Cleared?.Invoke();
}
}