acdream/src/AcDream.Core/Items/ClientObjectTable.cs
Erik 07be994d97 feat: port retail magic lifecycle and retained spell UI
Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 10:55:22 +02:00

1151 lines
48 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 readonly record struct ClientObjectMove(
uint ItemId,
ClientObject? Item,
ClientObjectPlacement Previous,
ClientObjectPlacement Current);
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();
// 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();
/// <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.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 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 int ObjectCount => _objects.Count;
public int ContainerCount => _containers.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>
/// 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);
_objects[item.ObjectId] = 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);
}
/// <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);
}
private bool ApplyPlacement(
ClientObject item,
ClientObjectPlacement current,
uint? containerTypeHint = null)
{
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? 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;
Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous);
PublishContainerContentsChanges(changedContainers);
return true;
}
private void PublishPlacementChange(
ClientObject item,
ClientObjectPlacement previous)
{
ClientObjectPlacement current = ClientObjectPlacement.From(item);
UpdateEquipmentIndex(item.ObjectId, previous, current);
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current));
}
/// <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)));
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));
WieldConfirmed?.Invoke(itemId);
return false;
}
if (!ApplyPlacement(
item,
placement))
{
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);
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 (!ApplyServerMove(
itemId,
placement.ContainerId,
placement.WielderId,
placement.ContainerSlot,
placement.EquipLocation))
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;
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)
{
RemoveCore(
data.Guid,
ClientObjectRemovalReason.GenerationReplacement,
generation,
notifyObjectRemoved: true);
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;
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 };
_objects[guid] = 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;
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
}
/// <summary>
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) 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.
/// </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 == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
ObjectUpdated?.Invoke(item);
return true;
}
/// <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);
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 };
_objects[d.Guid] = 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.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.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
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 };
_objects[guid] = 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 };
_objects[entry.Guid] = 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;
}
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 };
_objects[entry.Guid] = 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>
/// 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 };
_objects[entry.Guid] = 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()
{
_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();
}
}