acdream/src/AcDream.Core/Items/ItemInteractionPolicy.cs
Erik c68ad1e646
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 12:50:28 +02:00

460 lines
18 KiB
C#

using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Retail <c>PublicWeenieDesc::BitfieldIndex</c> values used by item interaction.
/// Names and values are from named-retail <c>acclient.h:6431</c>; the two damaged
/// decompiler expressions were verified in the matching v11.4186 executable at
/// <c>0x005884B6</c> and <c>0x00588752</c>.
/// </summary>
[Flags]
public enum PublicWeenieFlags : uint
{
None = 0,
Openable = 0x00000001,
Inscribable = 0x00000002,
Stuck = 0x00000004,
Player = 0x00000008,
Attackable = 0x00000010,
Vendor = 0x00000200,
PlayerKillerSwitch = 0x00000400,
NonPlayerKillerSwitch = 0x00000800,
Door = 0x00001000,
Corpse = 0x00002000,
Healer = 0x00010000,
Lockpick = 0x00020000,
RequiresPackSlot = 0x00800000,
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
/// <c>VendorProfile::InqAcceptability</c> tests (<c>pc:005d1aa7</c>,
/// byte 3 bit 0 of <c>PublicWeenieDesc::_bitfield</c>). See
/// <see cref="VendorSellAcceptability"/>.
/// </summary>
Retained = 0x01000000,
VolatileRare = 0x10000000,
WieldOnUse = 0x20000000,
WieldLeft = 0x40000000,
}
/// <summary>Return values of retail <c>ItemHolder::DetermineUseResult</c>.</summary>
public enum ItemPrimaryUseResult : ushort
{
None = 0,
ItemUse = 1,
PlaceInBackpack = 2,
WieldRight = 3,
AutoSort = 4,
OpenSecureTrade = 5,
OpenSalvage = 6,
BeginGame = 7,
WieldLeft = 8,
}
/// <summary>
/// Immutable data needed by the retail item rules. App builds this snapshot from
/// the mutable object table; Core never reaches into UI, networking, or physics.
/// </summary>
public readonly record struct ItemPolicyObject(
uint Id,
ItemType Type,
PublicWeenieFlags Flags,
uint ContainerId,
uint WielderId,
EquipMask ValidLocations,
EquipMask CurrentLocation,
byte CombatUse,
int ItemsCapacity,
int ContainersCapacity,
uint Useability,
uint TargetType,
bool OwnedByPlayer,
bool IsContainer,
bool IsComponentPack,
int TradeState,
int StackSize,
int MaxSplitSize,
bool IsIn3DView)
{
public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0;
}
public enum ItemPolicyActionKind
{
PlaceInBackpack,
WieldRight,
AutoSort,
OpenSecureTrade,
OpenSalvage,
BeginGame,
WieldLeft,
OpenContainedContainer,
SetGroundObject,
EnterTargetMode,
SendUse,
SendUseWithTarget,
IncrementBusy,
ConfirmPlayerKillerSwitch,
ConfirmNonPlayerKillerSwitch,
ConfirmVolatileRare,
MergeStack,
SellToVendor,
StartSecureTrade,
GiveToTarget,
PlaceInContainer,
SplitToWorld,
DropToWorld,
Reject,
}
public readonly record struct ItemPolicyAction(
ItemPolicyActionKind Kind,
uint ObjectId = 0,
uint TargetId = 0,
int Amount = 0,
string? Message = null);
public readonly record struct ItemUsePolicyInput(
ItemPolicyObject Source,
uint PlayerId,
uint GroundObjectId,
bool ReadyForInventoryRequest,
uint ActiveVendorId,
bool BypassClassification,
bool UseCurrentSelection,
ItemPolicyObject? SelectedTarget,
bool ConfirmVolatileRareUses,
bool InNonCombatMode);
public readonly record struct ItemUsePolicyDecision(
bool Consumed,
IReadOnlyList<ItemPolicyAction> Actions);
public readonly record struct ItemPlacementPolicyInput(
ItemPolicyObject Item,
uint PlayerId,
uint GroundObjectId,
bool ReadyForInventoryRequest,
uint TargetId,
ItemPolicyObject? Target,
bool AllowGroundFallback,
bool MergeAccepted,
bool DragOnPlayerOpensSecureTrade,
bool PlayerOnGround,
int SplitSize);
/// <summary>
/// Retail's placement return is deliberately separate from its action list:
/// vendor sale and secure-trade attempts return false even though they act.
/// </summary>
public readonly record struct ItemPlacementPolicyDecision(
bool ReturnValue,
IReadOnlyList<ItemPolicyAction> Actions);
/// <summary>
/// Pure ports of <c>ItemHolder::DetermineUseResult @ 0x00588460</c>,
/// <c>UseObject @ 0x00588A80</c>, and <c>AttemptPlaceIn3D @ 0x00588600</c>.
/// </summary>
public static class ItemInteractionPolicy
{
private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u;
private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu;
private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u;
private const ItemType ToolbarEquipmentTypes =
ItemType.Armor | ItemType.Clothing | ItemType.Jewelry;
/// <summary>
/// Exact selected-object predicate used by retail's toolbar hand.
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c> leaves the
/// button active for combat-use objects, armor/clothing/jewelry, or an
/// <c>ItemUses</c> value whose <c>USEABLE_NO</c> bit is clear.
/// </summary>
public static bool IsToolbarUseEnabled(
ItemType type,
byte combatUse,
uint useability)
=> combatUse != 0
|| (type & ToolbarEquipmentTypes) != 0
|| ItemUseability.IsUseable(useability);
public static ItemPrimaryUseResult DetermineUseResult(
in ItemPolicyObject item,
uint playerId,
uint groundObjectId)
{
bool looseOrViewed = (item.ContainerId == 0
&& (item.Flags & PublicWeenieFlags.Stuck) == 0)
|| (groundObjectId != 0 && item.ContainerId == groundObjectId);
if (looseOrViewed && (item.WielderId == 0 || item.WielderId == playerId))
{
bool ordinaryLooseItem = (item.Flags & PublicWeenieFlags.RequiresPackSlot) == 0
&& item.ItemsCapacity == 0
&& item.ContainersCapacity == 0;
if (ordinaryLooseItem || item.IsComponentPack)
return ItemPrimaryUseResult.PlaceInBackpack;
}
if (item.OwnedByPlayer)
{
bool wieldOnPrimaryUse = item.CombatUse != 0
|| (item.Type & ItemType.Caster) != 0
|| (item.Flags & PublicWeenieFlags.WieldOnUse) != 0;
if (wieldOnPrimaryUse && item.WielderId != playerId)
return (item.Flags & PublicWeenieFlags.WieldLeft) != 0
? ItemPrimaryUseResult.WieldLeft
: ItemPrimaryUseResult.WieldRight;
if (HasUnusedLocation(item.ValidLocations, item.CurrentLocation))
return ItemPrimaryUseResult.AutoSort;
if ((item.Type & ItemType.TinkeringTool) != 0)
return ItemPrimaryUseResult.OpenSalvage;
}
else if ((item.Type & ItemType.Gameboard) != 0)
{
return ItemPrimaryUseResult.BeginGame;
}
if (ItemUseability.IsUseable(item.Useability))
return ItemPrimaryUseResult.ItemUse;
if (item.IsPlayer && item.Id != playerId)
return ItemPrimaryUseResult.OpenSecureTrade;
return ItemPrimaryUseResult.None;
}
public static ItemUsePolicyDecision DecideUse(in ItemUsePolicyInput input)
{
var source = input.Source;
if (!input.ReadyForInventoryRequest)
return Consumed();
if (input.ActiveVendorId != 0 && source.ContainerId == input.ActiveVendorId)
return Consumed();
if (!input.BypassClassification)
{
var primary = DetermineUseResult(source, input.PlayerId, input.GroundObjectId);
// Exact retail bound: UseObject classifies 2..7, deliberately excluding 8.
if (primary is >= ItemPrimaryUseResult.PlaceInBackpack
and <= ItemPrimaryUseResult.BeginGame)
return Consumed(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, primary));
}
if (source.TradeState == 1)
return Reject("You cannot use an item while it is being traded.");
if (source.CurrentLocation == EquipMask.None
&& ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded)
return Reject("You must wield that item before you can use it.");
if (ItemUseability.IsTargeted(source.Useability))
{
if (!input.UseCurrentSelection)
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.");
var actions = new List<ItemPolicyAction>
{
new(ItemPolicyActionKind.SendUseWithTarget, source.Id, target.Id),
new(ItemPolicyActionKind.IncrementBusy),
};
actions.AddRange(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId)));
return Consumed(actions);
}
if (ItemUseability.IsUseable(source.Useability))
{
if ((source.Flags & PublicWeenieFlags.PlayerKillerSwitch) != 0)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmPlayerKillerSwitch, source.Id));
if ((source.Flags & PublicWeenieFlags.NonPlayerKillerSwitch) != 0)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch, source.Id));
if ((source.Flags & PublicWeenieFlags.VolatileRare) != 0
&& input.ConfirmVolatileRareUses)
return Consumed(new ItemPolicyAction(
ItemPolicyActionKind.ConfirmVolatileRare, source.Id));
var actions = new List<ItemPolicyAction>
{
new(ItemPolicyActionKind.SendUse, source.Id),
new(ItemPolicyActionKind.IncrementBusy),
};
actions.AddRange(BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId)));
return Consumed(actions);
}
var fallback = BuildUsingItemActions(source, input.PlayerId,
input.GroundObjectId, DetermineUseResult(source, input.PlayerId, input.GroundObjectId));
if (fallback.Count != 0)
return Consumed(fallback);
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.");
if ((source.Flags & PublicWeenieFlags.Attackable) != 0
&& input.InNonCombatMode)
return Reject("You must switch to a combat mode before attacking that target.");
if ((source.Flags & PublicWeenieFlags.Attackable) == 0
|| input.InNonCombatMode)
return Reject("That object cannot be used.");
return new ItemUsePolicyDecision(false, Array.Empty<ItemPolicyAction>());
}
public static bool IsTargetCompatible(
in ItemPolicyObject source,
in ItemPolicyObject target,
uint playerId)
{
if (source.TradeState == 1)
return false;
uint flags = ItemUseability.TargetFlags(source.Useability);
if (!target.OwnedByPlayer)
{
uint least = ItemUseability.LeastLimitedTargetUse(source.Useability);
if ((least & ItemUseability.Contained) != 0)
{
if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0))
return false;
}
else if ((least & ItemUseability.Wielded) != 0)
{
return false;
}
}
if (target.Id == playerId && (flags & ItemUseability.Self) == 0)
return false;
return (source.TargetType & (uint)target.Type) != 0;
}
public static ItemPlacementPolicyDecision DecidePlacement(
in ItemPlacementPolicyInput input)
{
if (!input.ReadyForInventoryRequest)
return Placement(false);
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."));
if (input.Item.TradeState != 0)
return Placement(false, RejectAction("You cannot move an item while it is being traded."));
if (input.TargetId == 0)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
if (input.MergeAccepted)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.MergeStack,
input.Item.Id, input.TargetId, input.SplitSize));
if (input.Target is not { } target)
return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false);
if ((target.Flags & PublicWeenieFlags.Vendor) != 0)
{
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."));
}
if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer)
return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.StartSecureTrade,
input.Item.Id, target.Id, input.SplitSize));
if (target.Type == ItemType.Creature)
return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.GiveToTarget,
input.Item.Id, target.Id, input.SplitSize));
if (target.IsContainer)
{
if ((target.Flags & PublicWeenieFlags.Openable) == 0)
return Placement(false, RejectAction("That container is locked."));
if (target.Id != input.GroundObjectId)
return Placement(false, RejectAction("You must open that container 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."));
}
private static IReadOnlyList<ItemPolicyAction> BuildUsingItemActions(
in ItemPolicyObject item,
uint playerId,
uint groundObjectId,
ItemPrimaryUseResult primary)
{
var actions = new List<ItemPolicyAction>(3);
ItemPolicyActionKind? primaryAction = primary switch
{
ItemPrimaryUseResult.PlaceInBackpack => ItemPolicyActionKind.PlaceInBackpack,
ItemPrimaryUseResult.WieldRight => ItemPolicyActionKind.WieldRight,
ItemPrimaryUseResult.AutoSort => ItemPolicyActionKind.AutoSort,
ItemPrimaryUseResult.OpenSecureTrade => ItemPolicyActionKind.OpenSecureTrade,
ItemPrimaryUseResult.OpenSalvage => ItemPolicyActionKind.OpenSalvage,
ItemPrimaryUseResult.BeginGame => ItemPolicyActionKind.BeginGame,
ItemPrimaryUseResult.WieldLeft => ItemPolicyActionKind.WieldLeft,
_ => null,
};
if (primaryAction is { } action)
actions.Add(new ItemPolicyAction(action, item.Id));
if (item.OwnedByPlayer && item.IsContainer)
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.OpenContainedContainer, item.Id));
if (!item.OwnedByPlayer && item.IsContainer
&& ItemUseability.IsUseable(item.Useability)
&& !ItemUseability.IsTargeted(item.Useability))
actions.Add(new ItemPolicyAction(ItemPolicyActionKind.SetGroundObject, item.Id,
groundObjectId));
return actions;
}
private static bool HasUnusedLocation(EquipMask valid, EquipMask current)
=> ((valid & BodyLocations) != 0 && (current & BodyLocations) == 0)
|| ((valid & ClothingLocations) != 0 && (current & ClothingLocations) == 0)
|| ((valid & HeldLocations) != 0 && (current & HeldLocations) == 0);
private static ItemPlacementPolicyDecision PlaceOnGround(
in ItemPlacementPolicyInput input)
{
if (!input.PlayerOnGround)
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."));
}
private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions)
=> new(true, actions);
private static ItemUsePolicyDecision Consumed(IReadOnlyList<ItemPolicyAction> actions)
=> new(true, actions);
private static ItemUsePolicyDecision Reject(string message)
=> Consumed(RejectAction(message));
private static ItemPolicyAction RejectAction(string message)
=> new(ItemPolicyActionKind.Reject, Message: message);
private static ItemPlacementPolicyDecision Placement(
bool returnValue,
params ItemPolicyAction[] actions)
=> new(returnValue, actions);
}