fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -116,6 +116,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_itemInteraction = itemInteraction;
_stackSplitQuantity = stackSplitQuantity;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
if (_itemInteraction is not null)
_itemInteraction.MergeAttempted += OnMergeAttempted;
WindowChromeController.BindCloseButton(layout, onClose);
@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (containerId == EffectiveOpen() || containerId == _playerGuid())
Populate();
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnInteractionStateChanged() => Populate();
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not null
|| pending.ItemId == 0u
|| pending.ContainerId != EffectiveOpen()
|| _objects.Get(pending.ItemId) is not { } item
|| IsBag(item))
|| pending.ContainerId == 0u
|| _objects.Get(pending.ItemId) is null)
{
return;
}
@ -375,12 +376,33 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Side-bag column: ALWAYS the player's bags (constant across container switches; only the
// open/selected indicators move). Equipped items never appear here.
var visibleBags = new List<uint>();
foreach (var guid in _objects.GetContents(p))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = IsBag(item);
if (isBag) AddCell(_containerList, guid, isContainer: true);
if (isBag) visibleBags.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } bagProjection
&& bagProjection.ContainerId == p
&& _objects.Get(bagProjection.ItemId) is { } pendingBag
&& IsBag(pendingBag))
{
visibleBags.Remove(bagProjection.ItemId);
int index = Math.Clamp(bagProjection.Placement, 0, visibleBags.Count);
visibleBags.Insert(index, bagProjection.ItemId);
}
foreach (uint guid in visibleBags)
{
bool waiting = IsWaitingSource(guid)
|| pending is { } waitingBagProjection
&& waitingBagProjection.ContainerId == p
&& waitingBagProjection.ItemId == guid;
AddCell(_containerList, guid, isContainer: true, waiting);
}
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
@ -394,20 +416,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (!isBag) visibleContents.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } projection
&& projection.ContainerId == open
&& !visibleContents.Contains(projection.ItemId)
&& _objects.Get(projection.ItemId) is { } pendingItem
&& !IsBag(pendingItem))
{
visibleContents.Remove(projection.ItemId);
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
visibleContents.Insert(index, projection.ItemId);
}
foreach (uint guid in visibleContents)
{
bool waiting = pending is { } waitingProjection
bool waiting = IsWaitingSource(guid)
|| pending is { } waitingProjection
&& waitingProjection.ContainerId == open
&& waitingProjection.ItemId == guid;
AddCell(_contentsGrid, guid, isContainer: false, waiting);
@ -455,8 +477,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
dragIconTexture: _dragIconIds?.Invoke(
ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u);
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.SetWaitingState(IsWaitingSource(p));
main.Clicked = () => OpenContainer(p);
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
@ -474,6 +496,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private int CountLooseContents(uint containerId)
{
int count = 0;
foreach (uint guid in _objects.GetContents(containerId))
{
if (_objects.Get(guid) is { } item
&& item.CurrentlyEquippedLocation == EquipMask.None
&& !IsBag(item))
{
count++;
}
}
return count;
}
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
@ -499,12 +536,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
ConfigureDropFeedback(list, cell);
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer)
{
cell.Clicked = () => OpenContainer(guid);
SetCapacityBar(cell, guid);
}
else
{
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
}
list.AddItem(cell);
}
@ -513,7 +553,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferPrimaryClick(guid)
is not null and not ItemPrimaryClickResult.NotActive)
return true;
SelectItem(guid);
if (_objects.Get(guid) is { } item && IsBag(item))
OpenContainer(guid);
else
SelectItem(guid);
return false;
}
@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return true;
SelectItem(guid);
OpenContainer(guid);
return false;
}
@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0;
if (cap <= 0) { cell.CapacityFill = -1f; return; }
int n = _objects.GetContents(containerGuid).Count;
// Player contents contain two independent retail lists: loose items
// and side packs. ItemsCapacity applies only to the former; counting
// packs here made a main pack stay visually/full logically rejected
// even after the player freed an item slot.
int n = CountLooseContents(containerGuid);
cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f);
}
// ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ──────────────
// ── IItemListDragHandler (B-Drag) — request first; server owns placement ────────────────────
/// <summary>Retail ItemList_BeginDrag selects an unselected item before enabling its waiting
/// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot
/// until the server confirms the eventual drop.</summary>
@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// remove-on-lift stands.
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source);
if (targetList == _contentsGrid)
return sourceIsBag
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
{
// UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the
// dragged object's container flag before interpreting this list.
// A container drag addresses the player's contained-container
// list itself; an empty authored slot is therefore a valid pack
// destination rather than "no target".
if (sourceIsBag)
return targetCell.ItemId == payload.ObjId
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId)
return ItemDragAcceptance.Reject;
return IsContainerFull(targetCell.ItemId)
? ItemDragAcceptance.Reject
: ItemDragAcceptance.Accept;
}
return ItemDragAcceptance.Reject;
return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _)
== InventoryContainerPlacementRejection.None
? ItemDragAcceptance.Accept
: ItemDragAcceptance.Reject;
}
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
/// for the server-created object's guid; a whole move remains optimistic. Retail:
/// for the server-created object's guid; a whole move displays only the
/// destination list's waiting projection until the server responds. Retail:
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
@ -627,8 +654,24 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// DropReleased is still delivered to the list after a reject overlay;
// pin the release to the same retail policy instead of relying on the
// advisory color alone.
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
InventoryContainerPlacementRejection legality = EvaluateDrop(
targetList,
targetCell,
item,
out _,
out uint legalityDestination);
if (legality != InventoryContainerPlacementRejection.None)
{
if (InventoryContainerPlacementPolicy.ComposeClientLocal(
legality,
_objects.Get(item),
_objects.Get(legalityDestination),
_playerGuid()) is { } refusal)
{
_itemInteraction?.ReportClientLocal(refusal);
}
return;
}
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary
@ -662,7 +705,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
container = EffectiveOpen();
placement = targetCell.ItemId != 0
? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot
: _objects.GetContents(container).Count; // first empty = append
: CountLooseContents(container); // first empty = append after visible loose items
}
else if (targetList == _containerList || targetList == _topContainer)
{
@ -697,79 +740,65 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack.
DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
if (_itemInteraction is not null)
{
_itemInteraction.TrySplitToContainer(
item,
container,
(uint)placement,
splitSize);
}
else
{
DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
}
return;
}
}
// External-container contents retain canonical ownership while the request
// is in flight, but retail immediately inserts an m_pendingItem copy into
// the chosen destination slot and ghosts it. The server move/failure notice
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
// Canonical ownership never changes on request. Retail immediately
// publishes the destination ItemList's m_pendingItem projection and
// resolves it from the server move/failure response.
if (_itemInteraction is not null)
{
if (_itemInteraction is not null)
{
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
InventoryRequestKind.Pickup,
() =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(item, container, placement);
return true;
}))
{
return;
}
return;
}
else
{
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
}
}
else
{
if (_itemInteraction is not null)
{
DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground
? InventoryRequestKind.Pickup
: InventoryRequestKind.PutInContainer;
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
kind,
() =>
{
if (_sendPutItemInContainer is null
|| !_objects.MoveItemOptimistic(item, container, placement))
{
if (_sendPutItemInContainer is null)
return false;
}
_sendPutItemInContainer(item, container, placement);
return true;
});
}))
{
return;
}
_objects.MoveItemOptimistic(item, container, placement);
return;
}
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
_sendPutItemInContainer?.Invoke(item, container, placement);
}
@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
int cap = _objects.Get(container)?.ItemsCapacity ?? 0;
if (cap <= 0) return false;
return _objects.GetContents(container).Count >= cap;
return CountLooseContents(container) >= cap;
}
private void OnMergeAttempted(uint sourceId, uint targetId)
{
_notifyMergeAttempt?.Invoke(sourceId, targetId);
_selection.Select(targetId, SelectionChangeSource.Inventory);
}
private InventoryContainerPlacementRejection EvaluateDrop(
UiItemList targetList,
UiItemSlot targetCell,
uint itemId,
out bool sourceIsBag,
out uint destinationId)
{
sourceIsBag = _objects.Get(itemId) is { } source && IsBag(source);
destinationId = 0u;
if (itemId == 0u)
return InventoryContainerPlacementRejection.InvalidItem;
if (ReferenceEquals(targetList, _contentsGrid))
{
destinationId = EffectiveOpen();
// Carried containers belong to the authored container selector,
// never the loose-item grid, even when both address the player.
if (sourceIsBag)
return InventoryContainerPlacementRejection.ContainerCapacityFull;
}
else if (ReferenceEquals(targetList, _containerList)
|| ReferenceEquals(targetList, _topContainer))
{
destinationId = sourceIsBag ? _playerGuid() : targetCell.ItemId;
if (!sourceIsBag && (targetCell.ItemId == 0u || targetCell.ItemId == itemId))
return InventoryContainerPlacementRejection.InvalidDestination;
}
else
{
return InventoryContainerPlacementRejection.InvalidDestination;
}
return InventoryContainerPlacementPolicy.Evaluate(
_objects,
itemId,
destinationId,
_playerGuid());
}
private bool IsWaitingSource(uint itemGuid)
=> _itemInteraction?.IsPendingInventorySource(itemGuid) == true;
/// <summary>Select an item (panel-wide green square) without changing the open container or
/// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0).</summary>
private void SelectItem(uint guid)
@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
var cell = list.GetItem(i);
if (cell is null) continue;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true
|| IsWaitingSource(cell.ItemId);
cell.Selected = cell.ItemId != 0
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingTargetSource;
@ -1012,6 +1090,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
if (_itemInteraction is not null)
{
_itemInteraction.MergeAttempted -= OnMergeAttempted;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;