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

@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable
private readonly Func<uint> _playerGuid;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _sendChangeCombatMode;
private readonly InventoryTransactionState? _transactions;
private PendingSwitch? _pendingSwitch;
private PendingCombatSettlement? _pendingCombatSettlement;
@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable
Func<uint> playerGuid,
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
Action<CombatMode>? sendChangeCombatMode = null,
InventoryTransactionState? transactions = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendWield = sendWield;
_sendPutItemInContainer = sendPutItemInContainer;
_toast = toast;
_systemMessage = systemMessage;
_combatState = combatState;
_sendChangeCombatMode = sendChangeCombatMode;
_transactions = transactions;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
@ -238,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable
: BestAvailableEquipMask(item);
if (mask == EquipMask.None)
{
_toast?.Invoke("That slot is already in use");
return false;
// UsingItem calls retail AutoWield with its automatic-unblock flag.
// When every compatible slot is occupied, retail chooses the first
// compatible slot, moves that blocker to the backpack, and retries
// only after RecvNotice_ServerSaysMoveItem confirms the move.
// CPlayerSystem::AutoWield @ 0x0056173D-0x0056186E.
mask = FirstCompatibleEquipMask(item);
ClientObject? blocker = GetEquippedObjectAtLocation(
mask, priority: 0, item.ObjectId);
return blocker is not null
&& BeginWeaponReplacement(
item.ObjectId,
blocker,
mask,
combatModeAfterWield: null);
}
return SendWield(item, mask, combatModeAfterWield: null);
@ -252,10 +264,7 @@ internal sealed class AutoWieldController : IDisposable
CombatMode? combatModeAfterWield)
{
if (_sendPutItemInContainer is null)
{
_toast?.Invoke("That slot is already in use");
return false;
}
uint player = _playerGuid();
if (player == 0)
@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable
// is the transaction boundary and preserves its stance-specific motion.
_systemMessage?.Invoke(
$"Moving {blockingItem.GetAppropriateName()} to your backpack");
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true;
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
blockingItem.ObjectId,
() =>
{
_sendPutItemInContainer(blockingItem.ObjectId, player, 0);
return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
}
private bool SendWield(
@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable
BlockingItemId: 0,
RequestedMask: mask,
CombatModeAfterWield: combatModeAfterWield);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
return false;
}
// Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590.
_sendWield(item.ObjectId, (uint)mask);
return true;
bool dispatched = DispatchInventoryRequest(
InventoryRequestKind.Wield,
item.ObjectId,
() =>
{
_sendWield(item.ObjectId, (uint)mask);
return true;
});
if (!dispatched)
_pendingSwitch = null;
return dispatched;
}
private bool DispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch)
=> _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch();
private void OnObjectMoved(ClientObjectMove move)
{
if (_pendingSwitch is not { } pending
@ -454,6 +481,14 @@ internal sealed class AutoWieldController : IDisposable
return EquipMask.None;
}
private static EquipMask FirstCompatibleEquipMask(ClientObject item)
{
foreach (EquipMask mask in AutoEquipOrder)
if ((item.ValidLocations & mask) != EquipMask.None)
return mask;
return EquipMask.None;
}
private bool AutoWearIsLegal(
ClientObject item,
out ClientObject? blocker)