acdream/src/AcDream.Core/Items/VendorSellAcceptability.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

195 lines
9.6 KiB
C#

namespace AcDream.Core.Items;
/// <summary>
/// Outcome of <see cref="VendorSellAcceptability.Evaluate"/> — retail
/// <c>VendorSellUI::DragItemAcceptable</c>'s (<c>pc:201195-201307</c>,
/// <c>0x004c20c0</c>) full gate chain: ownership, the non-empty-container
/// bypass, then <c>VendorProfile::InqAcceptability</c>
/// (<c>pc:484768-484797</c>, <c>0x005d1a90</c>).
/// </summary>
public enum VendorSellRejection
{
/// <summary>Acceptable — stage the drop.</summary>
None = 0,
/// <summary><c>ACCWeenieObject::IsOwnedByPlayer(esi) == 0</c> (<c>pc:201206</c>).</summary>
NotOwnedByPlayer,
/// <summary>
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
/// mapped here to the generic "You cannot sell that here" message the
/// same way retail's <c>DragItemAcceptable</c> switch falls through for
/// any return value outside its four named cases (1-4).
/// <para>
/// F12 (Slice 6b/6c review) — SOFTENED CLAIM: an earlier version of this
/// doc comment asserted a genuine bitmask is "almost never literally
/// 1-4." That is not true in general: <see cref="ItemType"/>
/// <c>MeleeWeapon</c>/<c>Armor</c>/<c>Clothing</c> are the single bits
/// <c>1</c>/<c>2</c>/<c>4</c>, and a real specialist vendor's own
/// <c>MerchandiseItemTypes</c> could legitimately be exactly one of
/// them (a weapon-only, armor-only, or clothing-only shop is an
/// ordinary AC vendor archetype). For such a vendor, retail's raw
/// <c>item_types</c> return WOULD collide with <c>DragItemAcceptable</c>'s
/// own named cases 1/2/4 ("cannot be sold here" / "has no value" /
/// "too valuable") — a genuine type mismatch would show the WRONG
/// retail message, not the generic one. This class does not reproduce
/// that collision (it always returns the semantic
/// <see cref="WrongType"/> case, never a raw integer another case could
/// alias), so acdream's own message is unaffected either way; this note
/// only corrects the doc's claim about how often retail's OWN collision
/// is reachable, in case a byte-exact reproduction is ever wanted.
/// </para>
/// </summary>
WrongType,
/// <summary>
/// <c>InqAcceptability</c> literally returned 1 — retail's
/// <c>DragItemAcceptable</c> switch has a case for it
/// (<c>pc:201259-201267</c>), but nothing in <c>InqAcceptability</c>'s
/// own body ever produces a literal 1 (only 0, 2, 3, the "too valuable"
/// value, or the raw type bitmask) — ported for exact control-flow
/// fidelity per CLAUDE.md's "do not simplify the switch" rule, not
/// because it is known to be reachable.
/// </summary>
CannotBeSoldHere,
/// <summary><c>InqAcceptability</c> == 2: per-unit value is exactly zero (<c>pc:005d1ac3</c>).</summary>
NoValue,
/// <summary>
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
/// <c>max_value != -1 &amp;&amp; value &gt; max_value</c>.
/// <para>
/// F3 (Slice 6b/6c review, byte-verified at <c>0x005d1add</c>): the
/// actual x86 at that return site is <c>mov eax,edi; shr eax,0x10;
/// not eax; and eax,4; ret</c> — i.e. <c>(~(itemTypeMask &gt;&gt; 16)) &amp; 4</c>,
/// a BITWISE complement (the decompiled pseudo-C's <c>!</c> is
/// misleading — it is not a logical NOT). <c>ItemType.PromissoryNote</c>
/// (<c>0x00040000</c>, bit 18) sits exactly at bit 2 of
/// <c>itemTypeMask &gt;&gt; 16</c>, so a trade note above the vendor's
/// max value returns 0 (fully <see cref="None"/>, exempt) instead of 4
/// (<see cref="TooValuable"/>) — and the <c>ret</c> at that exact
/// address means the min-value check below is skipped entirely for a
/// trade note, not merely the max-value rejection.
/// </para>
/// </summary>
TooValuable,
/// <summary>
/// <c>InqAcceptability</c> == 3: <c>min_value != -1 &amp;&amp; value &lt; min_value</c>
/// (<c>pc:005d1af2</c>).
/// </summary>
TooCheap,
}
/// <summary>
/// Pure port of <c>VendorSellUI::DragItemAcceptable</c> +
/// <c>VendorProfile::InqAcceptability</c> — gates a Selling-tab drop AND its
/// hover-preview cursor (the <c>silent</c> distinction is the caller's job:
/// <see cref="Evaluate"/> always computes the same outcome, callers choose
/// whether to surface <see cref="MessageFor"/>).
/// </summary>
public static class VendorSellAcceptability
{
/// <summary>Retail's -1/0xffffffff "no limit" sentinel for <c>min_value</c>/<c>max_value</c>.</summary>
public const uint NoLimit = uint.MaxValue;
/// <param name="ownedByPlayer"><c>ACCWeenieObject::IsOwnedByPlayer(esi)</c>.</param>
/// <param name="containedItemCount">
/// <c>ACCWeenieObject::GetNumContainedItems(esi)</c> — a non-empty
/// container (a bag with stuff in it) always passes, bypassing the
/// type/value filter entirely (<c>pc:201229-201233</c>).
/// </param>
/// <param name="itemTypeMask">The dragged item's own <c>PublicWeenieDesc::_type</c>.</param>
/// <param name="perUnitValue">
/// The dragged item's per-unit value — <see cref="VendorPricing.PerUnitValue"/>
/// applied to its own <c>Value</c>/<c>StackSize</c>, matching
/// <c>InqAcceptability</c>'s own <c>_stackSize &gt; 0 ? _value/_stackSize : _value</c>
/// division (<c>pc:005d1ab2-005d1ab6</c>).
/// </param>
/// <param name="merchandiseItemTypes">The vendor's <c>VendorShopProfile.MerchandiseItemTypes</c>.</param>
/// <param name="merchandiseMinValue">
/// The vendor's <c>VendorShopProfile.MerchandiseMinValue</c> —
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
/// </param>
/// <param name="merchandiseMaxValue">
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
/// </param>
/// <param name="publicWeenieBitfield">
/// F4 (Slice 6b/6c review): the dragged item's own
/// <c>PublicWeenieDesc::_bitfield</c> (<see cref="ClientObject.PublicWeenieBitfield"/>,
/// populated on every ordinary <c>CreateObject</c> — including the
/// player's own pack items, the only things ever dragged here). Tested
/// against <see cref="PublicWeenieFlags.Retained"/>
/// (<c>BF_RETAINED = 0x01000000</c>, acclient.h:6456) — retail's
/// <c>InqAcceptability</c> ORs this bit into the SAME type-mismatch
/// branch (byte 3 bit 0 of the bitfield, <c>pc:005d1aa7</c>), so it
/// folds into the same <see cref="VendorSellRejection.WrongType"/>
/// outcome, not a distinct rejection reason.
/// </param>
public static VendorSellRejection Evaluate(
bool ownedByPlayer,
int containedItemCount,
uint itemTypeMask,
int perUnitValue,
uint merchandiseItemTypes,
uint merchandiseMinValue,
uint merchandiseMaxValue,
uint publicWeenieBitfield = 0u)
{
if (!ownedByPlayer)
return VendorSellRejection.NotOwnedByPlayer;
if (containedItemCount > 0)
return VendorSellRejection.None;
// F4: InqAcceptability's first check ORs the type-mask mismatch
// with the BF_RETAINED bit (pc:005d1aa7) -- both branches return
// the SAME raw item_types value, so both fold into WrongType here.
bool retained = (publicWeenieBitfield & (uint)PublicWeenieFlags.Retained) != 0u;
if ((itemTypeMask & merchandiseItemTypes) == 0u || retained)
return VendorSellRejection.WrongType;
if (perUnitValue == 0)
return VendorSellRejection.NoValue;
if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue)
{
// F3 (byte-verified at 0x005d1add) -- see TooValuable's own doc
// comment: a PromissoryNote (trade note) is EXEMPT from the
// max-value rejection (and, by the disassembly's early ret,
// from the min-value check too), not just capped differently.
return (itemTypeMask & (uint)ItemType.PromissoryNote) != 0u
? VendorSellRejection.None
: VendorSellRejection.TooValuable;
}
if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue)
return VendorSellRejection.TooCheap;
return VendorSellRejection.None;
}
/// <summary>
/// Retail's exact rejection strings, read directly out of the decompiled
/// binary's data segment (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>,
/// addresses <c>0x007b51a8</c>/<c>0x007b51e0</c>/<c>0x007b5230</c>/
/// <c>0x007b5278</c>/<c>0x007b52cc</c>/<c>0x007b5308</c>) resolving the
/// truncated "…" citations the Slice 6b/6c research doc quoted. Delivered
/// via <c>ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)</c> — the SAME
/// system-message notice channel (<c>0x1a</c>) every other retail-ported
/// transient string in this codebase already uses.
/// </summary>
public static string? MessageFor(VendorSellRejection rejection) => rejection switch
{
VendorSellRejection.None => null,
VendorSellRejection.NotOwnedByPlayer => "You can only sell items you are carrying",
VendorSellRejection.CannotBeSoldHere => "That item cannot be sold here",
VendorSellRejection.NoValue => "That item has no value and cannot be sold",
VendorSellRejection.TooCheap => "That item is too cheap to sell here",
VendorSellRejection.TooValuable => "That item is too valuable to sell here",
VendorSellRejection.WrongType => "You cannot sell that here",
_ => "You cannot sell that here",
};
}