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

@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<uint, uint, uint, uint>? _sendSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable
Func<uint, uint, int, uint, bool>? sendBuy = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
Action<string, RetailLogTextType>? interfaceText = null)
Action<string, RetailLogTextType>? interfaceText = null,
Action<uint, uint, uint>? sendStackableMerge = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable
_sendSplitToWorld = sendSplitToWorld;
_sendPutItemInContainer = sendPutItemInContainer;
_sendSplitToContainer = sendSplitToContainer;
_sendStackableMerge = sendStackableMerge;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable
_playerGuid,
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage,
combatState,
sendChangeCombatMode);
sendChangeCombatMode,
_transactions);
_interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable
public event Action? StateChanged;
/// <summary>
/// Retail <c>ItemHolder::AttemptMerge</c> immediately selects the target
/// stack and publishes the toolbar merge-attempt notice after dispatch.
/// </summary>
public event Action<uint, uint>? MergeAttempted;
/// <summary>
/// Retail's two secure-trade open paths surface here for the trade UI:
/// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player
@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
=> _transactions.TryGetPending(out pending);
/// <summary>
/// Retail <c>ACCWeenieObject::UIAttemptSplitToContainer</c>: split an
/// exact partial quantity into a container through the canonical
/// one-request inventory gate. The source remains in place until the
/// authoritative stack update and newly-created split object arrive.
/// </summary>
public bool TrySplitToContainer(
uint itemId,
uint containerId,
uint placement,
uint amount)
{
if (itemId == 0u
|| containerId == 0u
|| _sendSplitToContainer is null
|| _objects.Get(itemId) is not { } item)
{
return false;
}
uint fullStack = (uint)Math.Max(1, item.StackSize);
if (amount == 0u || amount >= fullStack)
return false;
return TryDispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
itemId,
() =>
{
_sendSplitToContainer(itemId, containerId, placement, amount);
return true;
});
}
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable
public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
/// <summary>
/// True while retail's global inventory request latch owns this physical
/// item. Retained item lists use it for the waiting/ghosted source visual;
/// canonical placement remains unchanged until the server response.
/// </summary>
public bool IsPendingInventorySource(uint itemGuid)
=> itemGuid != 0
&& _transactions.TryGetPending(out PendingInventoryRequest pending)
&& pending.ItemId == itemGuid;
/// <summary>Route a literal local refusal to retail's SpewBox channel.</summary>
public void ReportClientLocal(string message)
{
if (string.IsNullOrWhiteSpace(message))
return;
if (_interfaceText is not null)
_interfaceText(message, RetailLogTextType.ClientLocal);
else if (_systemMessage is not null)
_systemMessage(message);
else
_toast?.Invoke(message);
}
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation.
@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable
/// publishes the waiting destination slot before issuing the move request,
/// exactly like double-click pickup through ItemHolder.
/// </summary>
public bool PlaceWorldItemInBackpack(uint itemGuid)
public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false)
{
if (itemGuid == 0u || _placeInBackpack is null)
return false;
uint containerId = _backpackContainerId();
uint containerId = mainPack ? _playerGuid() : _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
// CPlayerSystem::PlaceInBackpack passes autoMerge=true to
// ItemHolder::AttemptToPlaceInContainer. Retail searches the player's
// exhaustive carried inventory first and only merges when one target
// can accept the complete selected split quantity.
if (TryPlanAutoMerge(itemGuid) is { } merge)
{
if (!TryDispatchPendingBackpackPlacement(
itemGuid,
containerId,
placement,
InventoryRequestKind.Merge,
() =>
{
_sendStackableMerge!(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
MergeAttempted?.Invoke(
merge.SourceObjectId,
merge.TargetObjectId);
return true;
}))
{
// As with ordinary pickup, retail consumes the key while the
// shared inventory-request gate is busy.
return true;
}
return true;
}
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
private StackMergePlan? TryPlanAutoMerge(uint sourceId)
{
if (_sendStackableMerge is null
|| _objects.Get(sourceId) is not { } source
|| source.StackSizeMax <= 1)
{
return null;
}
uint requested = _stackSplitQuantity?.GetObjectSplitSize(
sourceId,
_selectedObjectId(),
(uint)Math.Max(1, source.StackSize))
?? (uint)Math.Max(1, source.StackSize);
int requestedAmount = (int)Math.Min(requested, int.MaxValue);
var sourceMerge = ToStackMergeItem(source);
uint player = _playerGuid();
if (player == 0u)
return null;
var visitedContainers = new HashSet<uint>();
foreach (uint targetId in ExhaustiveContents(player, visitedContainers))
{
if (_objects.Get(targetId) is not { } target)
continue;
StackMergePlan? plan = StackMergePlanner.Plan(
sourceMerge,
ToStackMergeItem(target),
CanMakeInventoryRequest,
requestedAmount);
// AttemptAutoMerge rejects a partial fit and keeps searching.
if (plan is { } complete && complete.Amount == requested)
return complete;
}
return null;
}
private IEnumerable<uint> ExhaustiveContents(
uint containerId,
HashSet<uint> visitedContainers)
{
if (!visitedContainers.Add(containerId))
yield break;
foreach (uint itemId in _objects.GetContents(containerId))
{
yield return itemId;
if (_objects.GetContents(itemId).Count == 0)
continue;
foreach (uint nested in ExhaustiveContents(itemId, visitedContainers))
yield return nested;
}
}
private static StackMergeItem ToStackMergeItem(ClientObject item) => new(
item.ObjectId,
item.WeenieClassId,
item.StackSize,
item.StackSizeMax,
item.TradeState);
public bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
/// <summary>
/// Keyboard equivalent of dropping the selected inventory item into the
/// 3-D view. Retail routes Give Selected and Drop Selected through the
/// same <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c> policy as a drag.
/// </summary>
public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid)
=> PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid);
/// <summary>
/// Retail inventory drag released into SmartBox. The release target is the
/// world object under the cursor, or zero for empty ground. This is the live
@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable
{
ArgumentNullException.ThrowIfNull(payload);
if (payload.SourceKind == ItemDragSource.ShortcutBar)
return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid);
}
private bool PlaceIn3D(
uint itemGuid,
ItemDragSource sourceKind,
uint targetGuid)
{
if (sourceKind == ItemDragSource.ShortcutBar)
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
ReportClientLocal(action.Message);
break;
case ItemPolicyActionKind.OpenSecureTrade:
// Use-on-player (ItemHolder::DetermineUseResult
@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable
PolicyActionRequested?.Invoke(action);
bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null;
if (!handled)
_toast?.Invoke(PolicyActionMessage(action));
acted |= handled || _toast is not null;
ReportClientLocal(PolicyActionMessage(action));
acted |= handled || _interfaceText is not null
|| _systemMessage is not null || _toast is not null;
break;
}
}
@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable
action.ObjectId,
() =>
{
if (_sendDrop is null
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
if (_sendDrop is null)
return false;
}
_sendDrop(action.ObjectId);
return true;
});
@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable
}
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);
ReportClientLocal(action.Message);
break;
default:
_auxiliaryAction?.Invoke(action);
PolicyActionRequested?.Invoke(action);
if (_auxiliaryAction is null && PolicyActionRequested is null)
_toast?.Invoke(PolicyActionMessage(action));
ReportClientLocal(PolicyActionMessage(action));
break;
}
}
@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.EnterUseItemOnTarget(sourceGuid);
var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}");
ReportClientLocal($"Choose a target for the {name}");
}
private void ClearTargetMode()
@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable
PendingInventoryRequest request,
uint weenieError)
{
if (_interfaceText is null)
return;
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
if (item is null)
return;
@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
is { } text)
{
_interfaceText(text, RetailLogTextType.ClientLocal);
ReportClientLocal(text);
}
}
@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;
MergeAttempted = null;
_autoWield.Dispose();
}
@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable
stackSize,
stackSize,
IsIn3DView: item.ContainerId == 0 && item.WielderId == 0
&& item.ObjectId != _playerGuid());
&& item.ObjectId != _playerGuid(),
Name: item.GetAppropriateName());
}
/// <summary>