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

@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<(uint Source, uint Target, uint Amount)> Merges = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
public readonly List<uint> Drops = new();
@ -130,7 +131,9 @@ public sealed class ItemInteractionControllerTests
Sells.Add((vendorGuid, items));
return true;
},
interfaceText: (text, type) => InterfaceTexts.Add((text, type)));
interfaceText: (text, type) => InterfaceTexts.Add((text, type)),
sendStackableMerge: (source, target, amount) =>
Merges.Add((source, target, amount)));
}
public ItemInteractionController Controller { get; }
@ -589,7 +592,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndWaitsForServer()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
@ -602,12 +605,12 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
var equipped = h.Objects.Get(0x50000A05u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndWaitsForServer()
{
var h = new Harness();
const EquipMask coatMask =
@ -624,8 +627,8 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
@ -654,7 +657,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
@ -692,12 +695,13 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
public void EquippableItemWithNoFreeSlot_movesBlockerThenWieldsAfterServerConfirm()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Name = "Old Shield",
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
@ -712,9 +716,17 @@ public sealed class ItemInteractionControllerTests
bool activated = h.Controller.ActivateItem(0x50000A06u);
Assert.True(activated);
Assert.Empty(h.Wields);
Assert.False(activated);
Assert.Equal(new[] { (0x50000AF0u, Player, 0) }, h.Puts);
Assert.Equal(new[] { "Moving Old Shield to your backpack" }, h.SystemMessages);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerMove(0x50000AF0u, Player, 0u, 0));
Assert.Equal(
new[] { (0x50000A06u, (uint)EquipMask.Shield) },
h.Wields);
}
[Theory]
@ -752,14 +764,18 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
// Authoritative 0x0022: only now does retail retry AutoWield.
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Equal(EquipMask.None,
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
Assert.Equal(EquipMask.MissileWeapon,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
}
[Theory]
@ -797,7 +813,7 @@ public sealed class ItemInteractionControllerTests
// wand away. The transaction retains the initial active-combat intent
// rather than consulting this intermediate state on its second pass.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Empty(h.CombatModeRequests);
@ -860,7 +876,7 @@ public sealed class ItemInteractionControllerTests
// replacement wield.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields);
Assert.True(h.Objects.ApplyConfirmedServerWield(
@ -899,7 +915,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(wand));
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
wand,
Player,
@ -935,7 +951,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
@ -1027,7 +1043,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Single(h.Wields);
// Bow is optimistic but ACE has not sent WieldObject yet. Retail's
@ -1121,13 +1137,13 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(bow));
Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (shield, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(shield, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(shield, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1164,12 +1180,12 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (arrows, Player, 0) },
h.Puts);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1213,7 +1229,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
Assert.Equal(EquipMask.MissileAmmo,
@ -1247,7 +1263,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
}
@ -1292,14 +1308,14 @@ public sealed class ItemInteractionControllerTests
crossbow, EquipMask.MissileWeapon));
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(
new[] { (bow, Player, 0), (arrows, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(
new[] { (crossbow, (uint)EquipMask.MissileWeapon) },
@ -1333,7 +1349,11 @@ public sealed class ItemInteractionControllerTests
new[] { (ring, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
ring, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
}
@ -1366,14 +1386,61 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(rightRing, Player, 0u, 0));
Assert.Equal(
new[] { (leftRing, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
leftRing, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
}
[Fact]
public void ActivateItem_whenEveryCompatibleSlotIsOccupied_movesRetailPreferredBlockerThenWields()
{
var h = new Harness();
const uint leftRing = 0x50000B74u;
const uint rightRing = 0x50000B75u;
const uint requestedRing = 0x50000B76u;
EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = leftRing,
Name = "Left Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = rightRing,
Name = "Right Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight);
h.AddContained(requestedRing, item =>
{
item.Name = "New Ring";
item.Type = ItemType.Jewelry;
item.ValidLocations = valid;
});
Assert.True(h.Controller.ActivateItem(requestedRing));
Assert.Equal(new[] { (leftRing, Player, 0) }, h.Puts);
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Left Ring to your backpack" }, h.SystemMessages);
Assert.True(h.Objects.ApplyConfirmedServerMove(leftRing, Player, 0u, 0));
Assert.Equal(
new[] { (requestedRing, (uint)EquipMask.FingerWearLeft) },
h.Wields);
}
[Fact]
@ -1400,7 +1467,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
public void InventoryDragOutsideUi_sendsDropAndWaitsForServerPlacement()
{
var h = new Harness();
h.AddContained(0x50000A07u);
@ -1413,7 +1480,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(0x50000A07u)!.ContainerId);
}
/// <summary>
@ -1517,7 +1584,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Gives);
Assert.Equal(new[] { item }, h.Drops);
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Theory]
@ -1898,6 +1965,126 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void KeyboardPickup_AutoMergesWholeStackBeforeContainerPlacement()
{
var h = new Harness();
const uint source = 0x70000B01u;
const uint target = 0x50000B02u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 77u,
Name = "World stack",
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(target, item =>
{
item.WeenieClassId = 77u;
item.StackSize = 5;
item.StackSizeMax = 10;
});
var attempts = new List<(uint Source, uint Target)>();
h.Controller.MergeAttempted += (from, into) => attempts.Add((from, into));
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 3u) }, h.Merges);
Assert.Equal(new[] { (source, target) }, attempts);
Assert.Empty(h.BackpackPlacements);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.Merge, pending.Kind);
Assert.True(pending.Dispatched);
}
[Fact]
public void KeyboardPickup_UsesSelectedSplitQuantityAndSearchesNestedPacks()
{
var h = new Harness();
const uint nestedPack = 0x50000B10u;
const uint source = 0x70000B11u;
const uint target = 0x50000B12u;
h.AddContained(nestedPack, item => item.Type = ItemType.Container);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 88u,
StackSize = 10,
StackSizeMax = 10,
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = target,
WeenieClassId = 88u,
StackSize = 8,
StackSizeMax = 10,
});
h.Objects.MoveItem(target, nestedPack, 0);
h.SelectedObject = source;
h.SplitQuantity.Reset(10u, 2u);
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 2u) }, h.Merges);
Assert.Empty(h.BackpackPlacements);
}
[Fact]
public void KeyboardPickup_SkipsPartialMergeTargetAndFallsBackToPlacement()
{
var h = new Harness();
const uint source = 0x70000B20u;
const uint partialTarget = 0x50000B21u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 99u,
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(partialTarget, item =>
{
item.WeenieClassId = 99u;
item.StackSize = 9;
item.StackSizeMax = 10;
});
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Empty(h.Merges);
Assert.Equal(new[] { (source, Player, 0) }, h.BackpackPlacements);
}
[Fact]
public void TrySplitToContainerDispatchesTheExactSelectedQuantity()
{
var h = new Harness();
const uint source = 0x50000A30u;
h.AddContained(source, item => item.StackSize = 10);
Assert.True(h.Controller.TrySplitToContainer(source, Pack, 3u, 2u));
Assert.Equal(new[] { (source, Pack, 3u, 2u) }, h.SplitPuts);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.SplitToContainer, pending.Kind);
Assert.Equal(source, pending.ItemId);
}
[Fact]
public void TrySplitToContainerRejectsZeroAndWholeStackAmounts()
{
var h = new Harness();
const uint source = 0x50000A31u;
h.AddContained(source, item => item.StackSize = 10);
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 0u));
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 10u));
Assert.Empty(h.SplitPuts);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void MatchingInventoryFailureReleasesGlobalRequest()
{