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

@ -24,14 +24,31 @@ public readonly record struct ExternalContainerTransition(
/// </summary>
public sealed class ExternalContainerState
{
private readonly HashSet<uint> _openedCorpses = [];
public uint RequestedContainerId { get; private set; }
public uint CurrentContainerId { get; private set; }
public int OpenedCorpseCount => _openedCorpses.Count;
public event Action<ExternalContainerTransition>? Changed;
public bool RequestOpen(uint containerId)
/// <summary>
/// Sets retail's requested ground object. When that object is a corpse,
/// this is also the exact <c>SetGroundObject</c> edge at which retail calls
/// <c>ACCWeenieObject::SetCorpseOpened @ 0x0058E670</c>.
/// </summary>
public bool RequestOpen(uint containerId, bool isCorpse = false)
{
if (containerId == 0u || RequestedContainerId == containerId)
if (containerId == 0u)
return false;
// Retail marks the corpse on the SetGroundObject edge even when the
// requested ground-object id is already current. Keep that lifetime
// fact independent from whether this call changes presentation state.
if (isCorpse)
_openedCorpses.Add(containerId);
if (RequestedContainerId == containerId)
return false;
uint previous = CurrentContainerId;
@ -54,6 +71,19 @@ public sealed class ExternalContainerState
return true;
}
/// <summary>
/// Retail <c>ACCWeenieObject::HasCorpseBeenOpened @ 0x0058DB70</c>.
/// The set is session-scoped and an object's delete edge removes its id.
/// </summary>
public bool HasCorpseBeenOpened(uint objectId)
=> objectId != 0u && _openedCorpses.Contains(objectId);
/// <summary>
/// Retail <c>ACCWeenieObject::SetCorpseDeleted @ 0x0058E6C0</c>.
/// </summary>
public bool SetCorpseDeleted(uint objectId)
=> objectId != 0u && _openedCorpses.Remove(objectId);
public bool ApplyViewContents(uint containerId)
{
if (containerId == 0u || containerId != RequestedContainerId)
@ -98,9 +128,12 @@ public sealed class ExternalContainerState
public bool Reset()
{
uint previous = CurrentContainerId;
bool changed = previous != 0u || RequestedContainerId != 0u;
bool changed = previous != 0u
|| RequestedContainerId != 0u
|| _openedCorpses.Count != 0;
CurrentContainerId = 0u;
RequestedContainerId = 0u;
_openedCorpses.Clear();
var transition = new ExternalContainerTransition(
ExternalContainerTransitionKind.Reset,

View file

@ -0,0 +1,158 @@
namespace AcDream.Core.Items;
/// <summary>
/// Side-effect-free container-placement result shared by drag hover and
/// release. It ports the observable rules from
/// <c>ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c>,
/// <c>AttemptToPlaceInContainer_IsContainerLegal @ 0x005879B0</c>, and
/// <c>WillItemFitInContainer @ 0x00587D60</c> that can be answered from the
/// client's public object projection.
/// </summary>
public enum InventoryContainerPlacementRejection
{
None,
InvalidItem,
CannotMovePlayer,
CannotMoveCreature,
SourceBeingTraded,
InvalidDestination,
DestinationBeingTraded,
RecursiveContainment,
ItemCapacityFull,
ContainerCapacityFull,
}
public static class InventoryContainerPlacementPolicy
{
public static InventoryContainerPlacementRejection Evaluate(
ClientObjectTable objects,
uint itemId,
uint destinationId,
uint playerId)
{
ArgumentNullException.ThrowIfNull(objects);
if (itemId == 0u || objects.Get(itemId) is not { } item)
return InventoryContainerPlacementRejection.InvalidItem;
if (itemId == playerId)
return InventoryContainerPlacementRejection.CannotMovePlayer;
if ((item.Type & ItemType.Creature) != 0)
return InventoryContainerPlacementRejection.CannotMoveCreature;
if (item.TradeState == 1)
return InventoryContainerPlacementRejection.SourceBeingTraded;
ClientObject? destination = objects.Get(destinationId);
if (destinationId == 0u
|| (destination is null && destinationId != playerId)
|| (destination is not null && !IsContainer(destination) && destinationId != playerId))
{
return InventoryContainerPlacementRejection.InvalidDestination;
}
if (destination?.TradeState == 1)
return InventoryContainerPlacementRejection.DestinationBeingTraded;
if (itemId == destinationId || IsContainedBy(objects, destinationId, itemId))
return InventoryContainerPlacementRejection.RecursiveContainment;
bool alreadyDirectlyContained = item.ContainerId == destinationId;
if (IsContainer(item))
{
int capacity = destination?.ContainersCapacity ?? 0;
if (!alreadyDirectlyContained
&& capacity > 0
&& CountContainers(objects, destinationId) >= capacity)
{
return InventoryContainerPlacementRejection.ContainerCapacityFull;
}
}
else
{
int capacity = destination?.ItemsCapacity ?? 0;
if (!alreadyDirectlyContained
&& capacity > 0
&& CountItems(objects, destinationId) >= capacity)
{
return InventoryContainerPlacementRejection.ItemCapacityFull;
}
}
return InventoryContainerPlacementRejection.None;
}
public static string? ComposeClientLocal(
InventoryContainerPlacementRejection rejection,
ClientObject? item,
ClientObject? destination,
uint playerId)
{
string itemName = item?.GetAppropriateName() ?? "item";
string destinationName = destination?.GetAppropriateName() ?? "container";
return rejection switch
{
InventoryContainerPlacementRejection.None => null,
InventoryContainerPlacementRejection.InvalidItem => "That item is not valid!",
InventoryContainerPlacementRejection.CannotMovePlayer =>
"You cannot place yourself within another object!",
InventoryContainerPlacementRejection.CannotMoveCreature =>
"You cannot pick up creatures!",
InventoryContainerPlacementRejection.SourceBeingTraded =>
$"The {itemName} is being traded",
InventoryContainerPlacementRejection.InvalidDestination =>
"The destination container is not valid!",
InventoryContainerPlacementRejection.DestinationBeingTraded =>
$"The {destinationName} is being traded",
InventoryContainerPlacementRejection.RecursiveContainment =>
"You cannot place an object within itself!",
InventoryContainerPlacementRejection.ItemCapacityFull =>
destination?.ObjectId == playerId
? $"{destinationName} is completely full!"
: $"The {destinationName} is completely full!",
InventoryContainerPlacementRejection.ContainerCapacityFull =>
destination?.ObjectId == playerId
? $"{destinationName} can carry no more containers!"
: $"The {destinationName} can fit no more containers!",
_ => null,
};
}
public static bool IsContainer(ClientObject item)
=> item.ContainerTypeHint != 0u
|| (item.Type & ItemType.Container) != 0
|| item.ItemsCapacity != 0
|| item.ContainersCapacity != 0;
private static int CountItems(ClientObjectTable objects, uint containerId)
{
int count = 0;
foreach (uint childId in objects.GetContents(containerId))
{
if (objects.Get(childId) is { } child && !IsContainer(child))
count++;
}
return count;
}
private static int CountContainers(ClientObjectTable objects, uint containerId)
{
int count = 0;
foreach (uint childId in objects.GetContents(containerId))
{
if (objects.Get(childId) is { } child && IsContainer(child))
count++;
}
return count;
}
private static bool IsContainedBy(
ClientObjectTable objects,
uint candidateId,
uint possibleAncestorId)
{
var visited = new HashSet<uint>();
uint current = candidateId;
while (current != 0u && visited.Add(current))
{
if (current == possibleAncestorId)
return true;
current = objects.Get(current)?.ContainerId ?? 0u;
}
return false;
}
}

View file

@ -6,8 +6,10 @@ public enum InventoryRequestKind
PutInContainer,
SplitToContainer,
Merge,
Move,
DropToWorld,
SplitToWorld,
Wield,
Give,
}

View file

@ -20,13 +20,22 @@ public enum PublicWeenieFlags : uint
Attackable = 0x00000010,
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
PlayerKiller = 0x00000020,
HiddenAdmin = 0x00000040,
UiHidden = 0x00000080,
Book = 0x00000100,
Vendor = 0x00000200,
PlayerKillerSwitch = 0x00000400,
NonPlayerKillerSwitch = 0x00000800,
Door = 0x00001000,
Corpse = 0x00002000,
Lifestone = 0x00004000,
Food = 0x00008000,
Healer = 0x00010000,
Lockpick = 0x00020000,
Portal = 0x00040000,
Admin = 0x00100000,
FreePlayerKiller = 0x00200000,
ImmuneCellRestrictions = 0x00400000,
RequiresPackSlot = 0x00800000,
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
@ -37,6 +46,8 @@ public enum PublicWeenieFlags : uint
Retained = 0x01000000,
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
PlayerKillerLite = 0x02000000,
IncludesSecondHeader = 0x04000000,
Bindstone = 0x08000000,
VolatileRare = 0x10000000,
WieldOnUse = 0x20000000,
WieldLeft = 0x40000000,
@ -79,7 +90,8 @@ public readonly record struct ItemPolicyObject(
int TradeState,
int StackSize,
int MaxSplitSize,
bool IsIn3DView)
bool IsIn3DView,
string Name = "item")
{
public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0;
}
@ -249,11 +261,11 @@ public static class ItemInteractionPolicy
}
if (source.TradeState == 1)
return Reject("You cannot use an item while it is being traded.");
return Reject($"You cannot use the {NameOf(source)} because you are trading it");
if (source.CurrentLocation == EquipMask.None
&& ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded)
return Reject("You must wield that item before you can use it.");
return Reject($"You must wield the {NameOf(source)} to use it");
if (ItemUseability.IsTargeted(source.Useability))
{
@ -261,9 +273,9 @@ public static class ItemInteractionPolicy
return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id));
if (input.SelectedTarget is not { } target)
return Reject("Select a target for this item first.");
if (!IsTargetCompatible(source, target, input.PlayerId))
return Reject("That is not a valid target for this item.");
return Reject($"Select your target before using the {NameOf(source)}");
if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure)
return Reject(failure);
var actions = new List<ItemPolicyAction>
{
@ -305,13 +317,13 @@ public static class ItemInteractionPolicy
if (source.Id == input.PlayerId)
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
if ((source.Flags & PublicWeenieFlags.Door) != 0)
return Reject("You cannot open or close that object right now.");
return Reject($"You can't open or close this {NameOf(source)} that way");
if ((source.Flags & PublicWeenieFlags.Attackable) != 0
&& input.InNonCombatMode)
return Reject("You must switch to a combat mode before attacking that target.");
return Reject($"To attack {NameOf(source)}, click on the dove icon first");
if ((source.Flags & PublicWeenieFlags.Attackable) == 0
|| input.InNonCombatMode)
return Reject("That object cannot be used.");
return Reject($"The {NameOf(source)} cannot be used");
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
}
@ -319,9 +331,18 @@ public static class ItemInteractionPolicy
in ItemPolicyObject source,
in ItemPolicyObject target,
uint playerId)
=> TargetCompatibilityFailure(source, target, playerId) is null;
private static string? TargetCompatibilityFailure(
in ItemPolicyObject source,
in ItemPolicyObject target,
uint playerId)
{
if (source.TradeState == 1)
return false;
return $"You cannot use the {NameOf(source)} because you are trading it";
if (target.TradeState == 1)
return $"You can't use the {NameOf(source)} on an item you are trading";
uint flags = ItemUseability.TargetFlags(source.Useability);
if (!target.OwnedByPlayer)
@ -330,18 +351,20 @@ public static class ItemInteractionPolicy
if ((least & ItemUseability.Contained) != 0)
{
if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0))
return false;
return $"You can't use the {NameOf(source)} on what you don't own";
}
else if ((least & ItemUseability.Wielded) != 0)
{
return false;
return $"You can't use the {NameOf(source)} on what you aren't wielding";
}
}
if (target.Id == playerId && (flags & ItemUseability.Self) == 0)
return false;
return $"Cannot use the {NameOf(source)} on yourself";
return (source.TargetType & (uint)target.Type) != 0;
return (source.TargetType & (uint)target.Type) != 0
? null
: $"Cannot use the {NameOf(source)} with the {NameOf(target)}";
}
public static ItemPlacementPolicyDecision DecidePlacement(
@ -353,9 +376,10 @@ public static class ItemInteractionPolicy
if (input.TargetId == input.PlayerId)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id));
if (!input.Item.OwnedByPlayer)
return Placement(false, RejectAction("You must first pick up that item."));
return Placement(false, RejectAction($"You must first pick up the {NameOf(input.Item)}"));
if (input.Item.TradeState != 0)
return Placement(false, RejectAction("You cannot move an item while it is being traded."));
return Placement(false, RejectAction(
$"You are trading the {NameOf(input.Item)}, it cannot be dropped"));
if (input.TargetId == 0)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
@ -370,7 +394,7 @@ public static class ItemInteractionPolicy
if (input.SplitSize >= input.Item.MaxSplitSize)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor,
input.Item.Id, target.Id, input.SplitSize));
return Placement(false, RejectAction("Split the stack before selling part of it."));
return Placement(false, RejectAction("You must split the stack before selling it."));
}
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
@ -384,16 +408,17 @@ public static class ItemInteractionPolicy
if (target.IsContainer)
{
if ((target.Flags & PublicWeenieFlags.Openable) == 0)
return Placement(false, RejectAction("That container is locked."));
return Placement(false, RejectAction($"The {NameOf(target)} is locked"));
if (target.Id != input.GroundObjectId)
return Placement(false, RejectAction("You must open that container first."));
return Placement(false, RejectAction($"You must open the {NameOf(target)} first"));
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer,
input.Item.Id, target.Id, input.SplitSize));
}
if (input.AllowGroundFallback)
return PlaceOnGround(input);
return Placement(false, RejectAction("You cannot give that item to this target."));
return Placement(false, RejectAction(
$"Cannot give {NameOf(input.Item)} to {NameOf(target)}"));
}
private static IReadOnlyList<ItemPolicyAction> BuildUsingItemActions(
@ -436,15 +461,18 @@ public static class ItemInteractionPolicy
in ItemPlacementPolicyInput input)
{
if (!input.PlayerOnGround)
return Placement(false, RejectAction("You cannot do that in mid air."));
return Placement(false, RejectAction("You cannot do that in mid air"));
if (input.SplitSize < input.Item.MaxSplitSize)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld,
input.Item.Id, Amount: input.SplitSize));
if (!input.Item.IsIn3DView)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id));
return Placement(false, RejectAction("Move cancelled."));
return Placement(false, RejectAction("Move cancelled"));
}
private static string NameOf(in ItemPolicyObject item)
=> string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name;
private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions)
=> new(true, actions);

View file

@ -127,6 +127,27 @@ public sealed class VendorStagingList
return true;
}
/// <summary>
/// Replaces retail's temporary pre-split sell-row identity with the
/// server-created split stack while preserving the row's position and
/// selected quantity. <c>VendorSellUI::ItemAttributesChanged</c>
/// performs the same in-place substitution after matching the new
/// object's class id and stack size.
/// </summary>
public bool Replace(uint itemGuid, uint replacementGuid)
{
if (itemGuid == 0u || replacementGuid == 0u || itemGuid == replacementGuid)
return false;
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
if (index < 0 || _entries.Exists(entry => entry.ItemGuid == replacementGuid))
return false;
_entries[index] = _entries[index] with { ItemGuid = replacementGuid };
Changed?.Invoke();
return true;
}
/// <summary>Port of the unconditional <c>PackableList&lt;ItemProfile&gt;::Flush</c> calls
/// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All
/// perform immediately after their wire send — see the batched-send call sites).</summary>