fix(gameplay): reconcile wield ownership and target facing
Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks. Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state. Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration. Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
b26f84cc69
commit
7b7ffcd278
36 changed files with 3884 additions and 761 deletions
|
|
@ -100,6 +100,7 @@ public static class WeenieErrorMessages
|
|||
[0x0498] = "You have moved too far!", // YouHaveMovedTooFar
|
||||
[0x0499] = "That is not a valid destination!", // TeleToInvalidPosition
|
||||
[0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.",
|
||||
[0x0550] = "Out of Range!", // MissileOutOfRange
|
||||
|
||||
// Fellowship
|
||||
[0x0528] = "The fellowship is locked; you cannot open locked fellowships.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ namespace AcDream.Core.Items;
|
|||
/// </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
|
||||
|
|
@ -21,6 +27,35 @@ public readonly record struct MoveRequestFailure(
|
|||
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,
|
||||
|
|
@ -72,21 +107,29 @@ 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, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
|
||||
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 when an object's container / slot changes (moved between
|
||||
/// packs, equipped, unequipped, dropped on ground). Old and new
|
||||
/// container ids are 0 if origin or destination is "world" / "nowhere".
|
||||
/// 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<ClientObject, uint, uint>? ObjectMoved;
|
||||
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
|
||||
|
|
@ -103,7 +146,7 @@ public sealed class ClientObjectTable
|
|||
/// rollback bookkeeping: retail clears IR_WIELD on the matching server
|
||||
/// response even when separate reconciliation state exists for the item.
|
||||
/// </summary>
|
||||
public event Action<ClientObject>? WieldConfirmed;
|
||||
public event Action<uint>? WieldConfirmed;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for every InventoryServerSaveFailed response, including requests
|
||||
|
|
@ -164,8 +207,12 @@ public sealed class ClientObjectTable
|
|||
public void AddOrUpdate(ClientObject item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
bool existed = _objects.ContainsKey(item.ObjectId);
|
||||
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);
|
||||
}
|
||||
|
|
@ -180,25 +227,156 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a server-driven move — called from
|
||||
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
|
||||
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
|
||||
/// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the
|
||||
/// unwield path manages it (a wielded item is modeled as contained-by-the-wielder),
|
||||
/// so RollbackMove restores full pre-move state through this method alone.
|
||||
/// 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);
|
||||
}
|
||||
|
||||
uint oldContainer = item.ContainerId;
|
||||
item.ContainerId = newContainerId;
|
||||
item.ContainerSlot = newSlot;
|
||||
item.CurrentlyEquippedLocation = newEquipLocation;
|
||||
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
|
||||
Reindex(item, oldContainer);
|
||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||
/// <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;
|
||||
}
|
||||
|
||||
|
|
@ -209,10 +387,9 @@ public sealed class ClientObjectTable
|
|||
private void RecordPending(uint itemId, ClientObject item)
|
||||
{
|
||||
if (_pendingMoves.TryGetValue(itemId, out var p))
|
||||
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
|
||||
_pendingMoves[itemId] = (p.placement, p.outstanding + 1);
|
||||
else
|
||||
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
|
||||
item.CurrentlyEquippedLocation, 1);
|
||||
_pendingMoves[itemId] = (ClientObjectPlacement.From(item), 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -234,9 +411,13 @@ public sealed class ClientObjectTable
|
|||
// 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.
|
||||
uint oldContainer = item.ContainerId;
|
||||
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))
|
||||
|
|
@ -253,15 +434,16 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
else item.ContainerSlot = newSlot;
|
||||
|
||||
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
||||
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 — EXACTLY what the server's
|
||||
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as
|
||||
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the
|
||||
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires
|
||||
/// 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)
|
||||
|
|
@ -291,24 +473,10 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
else
|
||||
{
|
||||
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
||||
_pendingMoves[itemId] = (p.placement, p.outstanding - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconcile an authoritative WieldObject response and publish the exact
|
||||
/// retail inventory-request completion boundary. The notification is not
|
||||
/// conditional on the optimistic rollback counter reaching zero.
|
||||
/// Retail: ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 clears the
|
||||
/// matching prevRequestObjectID directly.
|
||||
/// </summary>
|
||||
public void ConfirmWield(uint itemId)
|
||||
{
|
||||
ConfirmMove(itemId);
|
||||
if (_objects.TryGetValue(itemId, out ClientObject? item))
|
||||
WieldConfirmed?.Invoke(item);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
|
@ -316,7 +484,13 @@ public sealed class ClientObjectTable
|
|||
{
|
||||
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
||||
_pendingMoves.Remove(itemId);
|
||||
if (!MoveItem(itemId, pre.container, pre.slot, pre.equip))
|
||||
ClientObjectPlacement placement = pre.placement;
|
||||
if (!ApplyServerMove(
|
||||
itemId,
|
||||
placement.ContainerId,
|
||||
placement.WielderId,
|
||||
placement.ContainerSlot,
|
||||
placement.EquipLocation))
|
||||
return false;
|
||||
MoveRolledBack?.Invoke(_objects[itemId]);
|
||||
return true;
|
||||
|
|
@ -357,12 +531,18 @@ public sealed class ClientObjectTable
|
|||
bool notifyObjectRemoved)
|
||||
{
|
||||
if (!_objects.TryRemove(itemId, out var item)) return false;
|
||||
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
|
||||
l.Remove(itemId);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -437,10 +617,13 @@ public sealed class ClientObjectTable
|
|||
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;
|
||||
}
|
||||
|
|
@ -489,6 +672,7 @@ public sealed class ClientObjectTable
|
|||
_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!;
|
||||
|
|
@ -524,17 +708,23 @@ public sealed class ClientObjectTable
|
|||
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. Equipped entries may
|
||||
/// specify the player as <paramref name="containerId"/> so login and live
|
||||
/// WieldObject updates share the same contained-by-wielder projection.
|
||||
/// 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,
|
||||
|
|
@ -548,6 +738,7 @@ public sealed class ClientObjectTable
|
|||
_objects[guid] = obj;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
|
||||
if (containerId != 0)
|
||||
obj.ContainerId = containerId;
|
||||
obj.CurrentlyEquippedLocation = equip;
|
||||
|
|
@ -555,11 +746,130 @@ public sealed class ClientObjectTable
|
|||
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
|
||||
|
|
@ -582,6 +892,62 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -596,13 +962,25 @@ public sealed class ClientObjectTable
|
|||
? l.ToArray() : System.Array.Empty<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the
|
||||
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a
|
||||
/// container. Members no longer present are detached (ContainerId → 0); new members are
|
||||
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries
|
||||
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the
|
||||
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints.
|
||||
/// Retail consumer: ClientUISystem::OnViewContents.
|
||||
/// 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)
|
||||
{
|
||||
|
|
@ -616,51 +994,110 @@ public sealed class ClientObjectTable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
|
||||
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
|
||||
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
|
||||
/// chooses the loose-item list versus the side-pack/container list.
|
||||
/// 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 keep = new HashSet<uint>();
|
||||
foreach (var entry in entries)
|
||||
keep.Add(entry.Guid);
|
||||
|
||||
// Detach prior members no longer present (they left the container server-side). GetContents
|
||||
// returns a snapshot, so mutating the index inside the loop is safe.
|
||||
foreach (var old in GetContents(containerId))
|
||||
{
|
||||
if (keep.Contains(old)) continue;
|
||||
if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue;
|
||||
o.ContainerId = 0;
|
||||
o.CurrentlyEquippedLocation = EquipMask.None;
|
||||
Reindex(o, containerId);
|
||||
ObjectMoved?.Invoke(o, containerId, 0);
|
||||
}
|
||||
|
||||
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
||||
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;
|
||||
}
|
||||
uint oldContainer = obj.ContainerId;
|
||||
obj.ContainerId = containerId;
|
||||
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;
|
||||
Reindex(obj, oldContainer);
|
||||
if (!existed) ObjectAdded?.Invoke(obj);
|
||||
else ObjectMoved?.Invoke(obj, oldContainer, containerId);
|
||||
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>
|
||||
|
|
@ -680,10 +1117,11 @@ public sealed class ClientObjectTable
|
|||
return total;
|
||||
}
|
||||
|
||||
// NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An
|
||||
// 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).
|
||||
// A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone.
|
||||
// Equipment queries must therefore accept authoritative WielderId OR the optimistic ContainerId.
|
||||
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
|
||||
{
|
||||
if (o.WielderId == ownerGuid) return true;
|
||||
|
|
@ -705,6 +1143,7 @@ public sealed class ClientObjectTable
|
|||
_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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -952,9 +952,17 @@ public sealed class MotionInterpreter : IMotionDoneSink
|
|||
/// </summary>
|
||||
public WeenieError PerformMovement(MovementStruct mvs)
|
||||
{
|
||||
var p = new MovementParameters
|
||||
// Retail CMotionInterp::PerformMovement (0x00528E80) forwards the
|
||||
// MovementStruct's params pointer unchanged for types 1-4. Preserve
|
||||
// that exact parameter object when the CPhysicsObj boundary supplied
|
||||
// one: rebuilding only speed/modify bits loses CancelMoveTo,
|
||||
// SetHoldKey, Autonomous, and the remaining retail bitfield. The
|
||||
// legacy scalar fields remain as a compatibility path for older
|
||||
// callers that construct a MovementStruct without Params.
|
||||
var p = mvs.Params ?? new MovementParameters
|
||||
{
|
||||
Speed = mvs.Speed,
|
||||
Autonomous = mvs.Autonomous,
|
||||
ModifyInterpretedState = mvs.ModifyInterpretedState,
|
||||
ModifyRawState = mvs.ModifyRawState,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue