namespace AcDream.Core.Items; /// /// Outcome of — retail /// VendorSellUI::DragItemAcceptable's (pc:201195-201307, /// 0x004c20c0) full gate chain: ownership, the non-empty-container /// bypass, then VendorProfile::InqAcceptability /// (pc:484768-484797, 0x005d1a90). /// public enum VendorSellRejection { /// Acceptable — stage the drop. None = 0, /// ACCWeenieObject::IsOwnedByPlayer(esi) == 0 (pc:201206). NotOwnedByPlayer, /// /// InqAcceptability returned the raw item_types bitmask /// (the type-mismatch/non-sellable-bit branch, pc:005d1af8) — /// mapped here to the generic "You cannot sell that here" message the /// same way retail's DragItemAcceptable switch falls through for /// any return value outside its four named cases (1-4). /// /// 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: /// MeleeWeapon/Armor/Clothing are the single bits /// 1/2/4, and a real specialist vendor's own /// MerchandiseItemTypes 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 /// item_types return WOULD collide with DragItemAcceptable'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 /// 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. /// /// WrongType, /// /// InqAcceptability literally returned 1 — retail's /// DragItemAcceptable switch has a case for it /// (pc:201259-201267), but nothing in InqAcceptability'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. /// CannotBeSoldHere, /// InqAcceptability == 2: per-unit value is exactly zero (pc:005d1ac3). NoValue, /// /// InqAcceptability's "too valuable" branch (pc:005d1add): /// max_value != -1 && value > max_value. /// /// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): the /// actual x86 at that return site is mov eax,edi; shr eax,0x10; /// not eax; and eax,4; ret — i.e. (~(itemTypeMask >> 16)) & 4, /// a BITWISE complement (the decompiled pseudo-C's ! is /// misleading — it is not a logical NOT). ItemType.PromissoryNote /// (0x00040000, bit 18) sits exactly at bit 2 of /// itemTypeMask >> 16, so a trade note above the vendor's /// max value returns 0 (fully , exempt) instead of 4 /// () — and the ret at that exact /// address means the min-value check below is skipped entirely for a /// trade note, not merely the max-value rejection. /// /// TooValuable, /// /// InqAcceptability == 3: min_value != -1 && value < min_value /// (pc:005d1af2). /// TooCheap, } /// /// Pure port of VendorSellUI::DragItemAcceptable + /// VendorProfile::InqAcceptability — gates a Selling-tab drop AND its /// hover-preview cursor (the silent distinction is the caller's job: /// always computes the same outcome, callers choose /// whether to surface ). /// public static class VendorSellAcceptability { /// Retail's -1/0xffffffff "no limit" sentinel for min_value/max_value. public const uint NoLimit = uint.MaxValue; /// ACCWeenieObject::IsOwnedByPlayer(esi). /// /// ACCWeenieObject::GetNumContainedItems(esi) — a non-empty /// container (a bag with stuff in it) always passes, bypassing the /// type/value filter entirely (pc:201229-201233). /// /// The dragged item's own PublicWeenieDesc::_type. /// /// The dragged item's per-unit value — /// applied to its own Value/StackSize, matching /// InqAcceptability's own _stackSize > 0 ? _value/_stackSize : _value /// division (pc:005d1ab2-005d1ab6). /// /// The vendor's VendorShopProfile.MerchandiseItemTypes. /// /// The vendor's VendorShopProfile.MerchandiseMinValue — /// means retail's unset -1. /// /// /// The vendor's VendorShopProfile.MerchandiseMaxValue — /// means retail's unset -1. /// /// /// F4 (Slice 6b/6c review): the dragged item's own /// PublicWeenieDesc::_bitfield (, /// populated on every ordinary CreateObject — including the /// player's own pack items, the only things ever dragged here). Tested /// against /// (BF_RETAINED = 0x01000000, acclient.h:6456) — retail's /// InqAcceptability ORs this bit into the SAME type-mismatch /// branch (byte 3 bit 0 of the bitfield, pc:005d1aa7), so it /// folds into the same /// outcome, not a distinct rejection reason. /// 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; } /// /// Retail's exact rejection strings, read directly out of the decompiled /// binary's data segment (docs/research/named-retail/acclient_2013_pseudo_c.txt, /// addresses 0x007b51a8/0x007b51e0/0x007b5230/ /// 0x007b5278/0x007b52cc/0x007b5308) resolving the /// truncated "…" citations the Slice 6b/6c research doc quoted. Delivered /// via ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...) — the SAME /// system-message notice channel (0x1a) every other retail-ported /// transient string in this codebase already uses. /// 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", }; }