using System; using System.Collections.Concurrent; using System.Collections.Generic; namespace AcDream.Core.Items; /// /// Ordered container snapshot entry from retail ContentProfile: /// guid plus m_uContainerProperties. /// public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType); /// One login-time PlayerDescription equipment placement. public readonly record struct EquipmentManifestEntry( uint Guid, EquipMask EquipLocation, uint Priority); /// /// Server rejection of an inventory move request. /// is true when the table restored a locally optimistic move; false for /// server-confirmed transactions which deliberately left the local projection /// unchanged while waiting. /// public readonly record struct MoveRequestFailure( uint ItemId, uint WeenieError, bool RolledBack); /// /// One side of retail's ServerSaysMoveItem_s 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. /// 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); } /// /// Retail-shaped inventory placement notice. Named retail /// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 snapshots and /// publishes both complete placements after applying the new state. /// 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); /// /// The client's table of every server object (retail weenie_object_table / /// CObjectMaint). Resolve by guid via Get. /// /// /// Retail semantics (r06): /// /// /// Every object is a with a unique /// ObjectId. CreateObject seeds it when the server tells us /// the item exists (in our inventory, on the ground, in a /// vendor's list, etc). /// /// /// Moves happen via -carrying messages: /// WieldObject, InventoryPutObjInContainer, /// InventoryPutObjectIn3D, ViewContents, /// CloseGroundContainer. /// /// /// InventoryServerSaveFailed reverts a speculative local /// state change (e.g. when a drag-drop was rejected server-side). /// /// /// /// /// /// Thread safety: designed for single-threaded use from the render /// thread; the event delegates run synchronously on the caller's /// thread. A backs the /// map so plugin code can look up items from any thread without /// corrupting state. /// /// public sealed class ClientObjectTable { private readonly ConcurrentDictionary _objects = new(); private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); private readonly Dictionary> _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 _pendingMoves = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; /// /// Fires after a complete placement notice (moved between packs, /// equipped, unequipped, dropped on ground). /// is null when retail publishes the notice before CreateObject resolves /// the GUID; the old/new placement remains authoritative notice data. /// public event Action? ObjectMoved; /// /// Fires after retail ViewContents replaces a container's ordered member /// lists. This is a list-projection change, not an item ownership move. /// public event Action? ContainerContentsReplaced; /// /// Fires after an optimistic inventory/equipment move is rejected and /// 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. /// public event Action? MoveRolledBack; /// /// 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. /// public event Action? WieldConfirmed; /// /// 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. /// public event Action? MoveRequestFailed; /// Fires when an object is removed from the session. public event Action? ObjectRemoved; /// /// Generation-aware removal stream for runtime owners. UI consumers keep /// using ; runtime owners use the reason and /// generation to preserve only packets addressed to future incarnations. /// public event Action? ObjectRemovalClassified; /// Fires when an object's properties are updated (typically after Appraise). public event Action? ObjectUpdated; /// Fires after all session object and pending-move state is flushed. public event Action? Cleared; /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield; /// the typed mirror maintains on /// . public const uint UiEffectsPropertyId = 18u; public const uint CurrentWieldedLocationPropertyId = 10u; public int ObjectCount => _objects.Count; public int ContainerCount => _containers.Count; public IEnumerable Objects => _objects.Values; public IEnumerable Containers => _containers.Values; /// /// Look up an object by its server-assigned ObjectId. /// public ClientObject? Get(uint objectId) => _objects.TryGetValue(objectId, out var item) ? item : null; /// /// 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). /// public Container? GetContainer(uint objectId) => _containers.TryGetValue(objectId, out var c) ? c : null; /// /// 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. /// 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); } /// /// Register a container. Idempotent. /// public void AddContainer(Container container) { ArgumentNullException.ThrowIfNull(container); _containers[container.ObjectId] = container; } /// /// 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. /// 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); } /// /// Apply an authoritative server move, including the item's wielder /// ownership. Retail ACCWeenieObject::ServerSaysMoveItem @ /// 0x0058DBB0 updates _containerID, _wielderID, and /// _location in one callback. Keeping the old wielder after an /// unwield makes DetermineUseResult @ 0x00588460 classify a bow in /// the backpack as already wielded, so a later double click sends no /// request. /// 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? 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)); } /// /// Applies a server-confirmed container/world move. The matching pending /// request is reconciled before is published, /// matching retail ServerSaysMoveItem: a reentrant listener may start /// a new request without the old confirmation consuming it afterward. /// 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; } /// /// 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 /// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0. /// 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; } /// 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). 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); } /// /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST /// time it moves, then (immediate repaint via ObjectMoved). The wire /// PutItemInContainer is sent by the caller; / /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. /// 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? 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(); 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; } /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged /// until receives the authoritative WieldObject, matching retail's /// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to /// ContainerId zero + authoritative WielderId. restores the local projection. Fires /// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023 /// echo) / RollbackMove (on 0x00A0) reconcile. 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); } /// Assign each item in a sequential ContainerSlot (0..N-1) matching /// its list position — keeps a container gapless + collision-free so the grid order is stable. private void RenumberContainer(List list) { for (int i = 0; i < list.Count; i++) if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i; } /// 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. 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); } } /// 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. 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; } /// /// 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. /// public bool RejectMove(uint itemId, uint weenieError) { bool rolledBack = RollbackMove(itemId); MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack)); return rolledBack; } /// /// Handle a server-driven remove (destroyed item, dropped into 3D /// space, stolen, etc). /// public bool Remove(uint itemId) => RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true); /// Removes an exact accepted server object incarnation. 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? 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; } /// /// Atomically replaces the retained qualities for a newer server object /// incarnation while publishing a generation-specific teardown event. /// public ClientObject ReplaceGeneration(WeenieData data, ushort generation) { RemoveCore( data.Guid, ClientObjectRemovalReason.GenerationReplacement, generation, notifyObjectRemoved: true); return Ingest(data); } /// /// Apply a patch (e.g. from an /// IdentifyObjectResponse) to an existing object. Individual /// keys in the incoming bundle overwrite existing values; keys not /// present are left untouched. /// 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; } /// /// Apply a 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 /// , which no-ops on an unknown object. Fires /// ObjectAdded on create, else ObjectUpdated. /// 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); } /// /// 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) → . Fires /// ObjectUpdated so bound widgets re-composite. Extensible hook for future /// typed PropertyInts (StackSize, Structure, …). False if the object is unknown. /// public bool UpdateIntProperty(uint itemId, uint propertyId, int value) { 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; } /// /// Apply a single PropertyInt64 update to an object's bundle and fire /// ObjectUpdated so bound widgets refresh. Int64 counterpart of /// (used by the character sheet's /// optimistic unassigned-XP debit; also the hook for future /// PrivateUpdatePropertyInt64 parsing). False if the object is unknown. /// 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; } /// /// 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...). /// 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; } /// /// 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. /// 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? 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; } /// /// PlayerDescription manifest: record that this guid is the player's /// (in inventory or equipped at ), creating an /// empty entry if CreateObject hasn't arrived yet. PlayerDescription may /// use the player as 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. /// 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? 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; } /// /// Initialize PlayerDescription equipment in canonical retail form while /// preserving the packed InventoryPlacement list order exactly. Retail /// UpdateObjectInventory @ 0x00559550 assigns the complete list; it /// does not replay live head-insertion for each login entry. /// public void InitializeEquipmentManifest( uint wielderId, IReadOnlyList entries) { ArgumentNullException.ThrowIfNull(entries); if (wielderId == 0u) return; var ordered = new List(entries.Count); var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count); var changedContainers = new List(); var incoming = new HashSet(); 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? 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? RemoveFromOtherContainerIndexes( uint itemId, uint exceptContainerId) { List? changed = null; foreach ((uint containerId, List members) in _containerIndex) { if (containerId == exceptContainerId || !members.Remove(itemId)) continue; (changed ??= new List()).Add(containerId); } return changed; } private void PublishContainerContentsChanges(List? changed) { if (changed is null || changed.Count == 0) return; var published = new HashSet(); 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(); if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId); var priorOrder = new Dictionary(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? emptyOwners = null; foreach ((uint ownerId, List placements) in _equipmentIndex) { placements.Remove(itemId); if (placements.Count == 0) (emptyOwners ??= new List()).Add(ownerId); } if (emptyOwners is null) return; foreach (uint ownerId in emptyOwners) _equipmentIndex.Remove(ownerId); } /// /// Maintain retail's _invPlacement order. Named retail /// SetPlayerWieldLocation @ 0x0058D8C0 inserts a placement at the /// head; GetObjectAtLocation @ 0x0058CE00 scans head-to-tail. /// 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? 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? currentList)) _equipmentIndex[currentOwner] = currentList = new List(); 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; /// /// 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. /// public IReadOnlyList GetContents(uint containerId) => _containerIndex.TryGetValue(containerId, out var l) ? l.ToArray() : System.Array.Empty(); /// /// Snapshot of items equipped by . Confirmed /// retail equipment has ContainerId zero and WielderId set; the /// ContainerId arm keeps the optimistic pre-confirm projection visible. /// public IReadOnlyList GetEquippedBy(uint wielderId) => _equipmentIndex.TryGetValue(wielderId, out List? placements) ? placements .Select(Get) .Where(item => item is not null && item.CurrentlyEquippedLocation != EquipMask.None && (item.WielderId == wielderId || item.ContainerId == wielderId)) .Cast() .ToArray() : Array.Empty(); /// /// Replace only a viewed container's ordered membership projection. /// Retail ACCObjectMaint::ViewObjectContents @ 0x00558A70 rebuilds /// the container lists without mutating child ownership or equip state. /// public void ReplaceContents(uint containerId, IReadOnlyList 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); } /// /// Replace only a viewed container's ordered membership projection. /// ContentProfile order is retained in while the /// child weenie's canonical placement remains owned by move/wield events. /// public void ReplaceContents(uint containerId, IReadOnlyList entries) { ArgumentNullException.ThrowIfNull(entries); if (containerId == 0) return; var ordered = new List(entries.Count); var added = new List(); 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); } /// /// Initialize canonical inventory ownership from PlayerDescription. This /// login manifest is object-state initialization, not a move transition. /// public void InitializeInventoryManifest( uint ownerId, IReadOnlyList entries) { ArgumentNullException.ThrowIfNull(entries); if (ownerId == 0u) return; var ordered = new List(entries.Count); var changedContainers = new List(); var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count); var incoming = new HashSet(); for (int i = 0; i < entries.Count; i++) incoming.Add(entries[i].Guid); if (_containerIndex.TryGetValue(ownerId, out List? 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); } /// /// Σ Burden over every object carried by : 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 EncumbranceVal (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. /// 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; } /// /// Flush the table — typically called on logoff or teleport /// that drops the session's object state. /// 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(); } }