fix(vendor): re-gate residuals — MaxStackSize is the stack operand, wire-authored use radius, purse summaries
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
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
R1 the split bar's operand is the item's authored MaxStackSize —
three retail sites read pwd._maxStackSize directly (InqListSlotCount
pc:200052, buy-button cases pc:203996/204086) where ACE never fills
the desc stack and standard stock is unlimited. Threaded StackSizeMax
end to end with one shared resolver; the two literal _maxStackSize
sites are now byte-exact; AP-165 retired, AP-169 corrected.
R2 walk-to-vendor never opened because GetUseRadius used an UNCITED
3m Creature heuristic as the local stop distance while ACE's poll
demands the authored radius (default 0.6 m) — the walk stopped and
the Use fired far outside acceptance. Now reads the wire-authored
spawn UseRadius with ACE's exact fallback; heuristic constants
deleted. A first sabotage attempt was non-discriminating
(coincidental 0.6) and was corrected — the discriminating version is
what landed.
R3 the Buying/Selling purse summaries ("Buying %d %s worth %hsp" /
"You have %hsp") recovered from the binary data segment where BN
mis-attributes the Buy-side literal; wired to staging and money
changes on the four authored text elements; AP-166 narrowed to the
pending-sell highlight.
Clean-room complete solution: 11,528 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
68568a3a59
commit
d003449bb4
11 changed files with 680 additions and 103 deletions
|
|
@ -76,11 +76,13 @@ internal sealed class WorldSelectionQuery
|
|||
: IWorldSelectionQuery,
|
||||
IRetainedUiSelectionQuery
|
||||
{
|
||||
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
private const uint StuckObjectFlag = 0x0004u;
|
||||
/// <summary>
|
||||
/// ACE's own fallback when a target authors no wire <c>UseRadius</c> at
|
||||
/// all (<c>WorldObject_Use.cs:50</c>, <c>useRadius ?? 0.6f</c>) — see
|
||||
/// <see cref="GetUseRadius"/>.
|
||||
/// </summary>
|
||||
private const float DefaultUseRadius = 0.6f;
|
||||
private const float CreatureUseRadius = 3f;
|
||||
private const float LargeObjectUseRadius = 2f;
|
||||
private const float AceCanChargeDistance = 7.5f;
|
||||
|
||||
private const uint SmallItemMask =
|
||||
|
|
@ -527,13 +529,46 @@ internal sealed class WorldSelectionQuery
|
|||
ignoreZDelta: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2 gate-finding fix (2026-08-08, grand-gate walk-to-vendor
|
||||
/// regression): retail reads the TARGET's own wire-authored
|
||||
/// <c>PublicWeenieDesc::_useRadius</c> directly for every
|
||||
/// range/approach purpose — <c>CPlayerSystem::RegisterObjectRangeHandler</c>
|
||||
/// (<c>pc:195159</c>/<c>203677</c>/<c>210429</c>; <c>203677</c> is
|
||||
/// <c>gmVendorUI::OpenVendor</c>'s own range-handler registration,
|
||||
/// reading <c>eax->pwd._useRadius</c> for the VENDOR target itself,
|
||||
/// the exact NPC kind this bug was found on). ACE's server-side
|
||||
/// acceptance test (<c>WorldObject_Use.cs:47-55</c>,
|
||||
/// <c>IsWithinUseRadiusOf</c>) reads the SAME wire field:
|
||||
/// <c>useRadius ?? 0.6f</c> — no item-type special-casing at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>What this replaces.</b> The prior implementation ignored the wire
|
||||
/// field entirely and guessed a flat radius from the target's item
|
||||
/// type/flags (3m for ANY <see cref="ItemType.Creature"/>, 2m for a
|
||||
/// "large object" flag combination, 0.6m otherwise) — an uncited,
|
||||
/// retail-incorrect heuristic. For a typical vendor NPC (Creature-typed,
|
||||
/// authoring a much tighter UseRadius than 3m in practice) this made
|
||||
/// the CLIENT's own local "arrived" test (<c>MoveToManager</c>'s
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.GetCurrentDistance"/>
|
||||
/// cylinder-distance arrival check, gated on
|
||||
/// <see cref="AcDream.Core.Physics.Motion.MovementParameters.DistanceToObject"/>
|
||||
/// = this method's return value) satisfied well outside ACE's real
|
||||
/// acceptance zone. The player's walk stopped — and the AP-170/G3
|
||||
/// arrival-gated Use dispatched — several meters before the player was
|
||||
/// ever within ACE's own poll-based <c>WithinUseRadius</c> check
|
||||
/// (<c>Player_Move.cs</c>'s <c>CreateMoveToChain</c>), so
|
||||
/// <c>ApproachVendor</c> never arrived: the exact "Use lost silently"
|
||||
/// defect AP-170 closed, reproduced from a different angle (a too-loose
|
||||
/// LOCAL arrival threshold racing ACE's tighter real one, not a missing
|
||||
/// arrival gate). The vendor's cosmetic greeting the user observed on
|
||||
/// approach is a SEPARATE, distance-only proximity emote independent of
|
||||
/// this Use-radius gate — its firing does not imply the player was ever
|
||||
/// within ACE's real UseRadius.
|
||||
/// </remarks>
|
||||
private float GetUseRadius(uint serverGuid)
|
||||
{
|
||||
if ((GetItemType(serverGuid) & ItemType.Creature) != 0)
|
||||
return CreatureUseRadius;
|
||||
return _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
&& ((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u
|
||||
? LargeObjectUseRadius
|
||||
=> _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
&& spawn.UseRadius is > 0f
|
||||
? spawn.UseRadius.Value
|
||||
: DefaultUseRadius;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
public const uint SellingListId = 0x100000CEu;
|
||||
public const uint SellingScrollbarId = 0x100000CFu;
|
||||
|
||||
// R3 (user-requested retail presentation, closing AP-166's text half):
|
||||
// the Buying/Selling tabs' own staged-count/total-value and player-
|
||||
// purse text elements — retail m_buyListText/m_buyPurseText
|
||||
// (VendorBuyUI::VendorBuyUI, pc:199733-199738) and m_sellListText/
|
||||
// m_sellPurseText (VendorSellUI::VendorSellUI, pc:199777-199782).
|
||||
public const uint BuyingListTextId = 0x100000C7u;
|
||||
public const uint BuyingPurseTextId = 0x100000C8u;
|
||||
public const uint SellingListTextId = 0x100000D0u;
|
||||
public const uint SellingPurseTextId = 0x100000D1u;
|
||||
|
||||
// Slice 6b: the "Buying" tab's staging-review buttons
|
||||
// (docs/research/2026-08-08-slice5-vendor-browse-research.md §B.4 D0
|
||||
// tree). All four are optional (nullable) the same way BuyButtonId/
|
||||
|
|
@ -333,6 +343,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
private readonly UiMenu _typeMenu;
|
||||
private readonly UiText _itemNameText;
|
||||
private readonly UiText _itemCostText;
|
||||
// R3: the Buying/Selling tabs' own staged summary text — optional, the
|
||||
// same nullable degrade-gracefully convention as the staging buttons.
|
||||
private readonly UiText? _buyListText;
|
||||
private readonly UiText? _buyPurseText;
|
||||
private readonly UiText? _sellListText;
|
||||
private readonly UiText? _sellPurseText;
|
||||
private readonly UiButton? _close;
|
||||
private readonly UiButton? _buyButton;
|
||||
private readonly UiButton? _addButton;
|
||||
|
|
@ -391,6 +407,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
UiMenu typeMenu,
|
||||
UiText itemNameText,
|
||||
UiText itemCostText,
|
||||
UiText? buyListText,
|
||||
UiText? buyPurseText,
|
||||
UiText? sellListText,
|
||||
UiText? sellPurseText,
|
||||
UiButton? close,
|
||||
UiButton? buyButton,
|
||||
UiButton? addButton,
|
||||
|
|
@ -431,6 +451,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_typeMenu = typeMenu;
|
||||
_itemNameText = itemNameText;
|
||||
_itemCostText = itemCostText;
|
||||
_buyListText = buyListText;
|
||||
_buyPurseText = buyPurseText;
|
||||
_sellListText = sellListText;
|
||||
_sellPurseText = sellPurseText;
|
||||
_close = close;
|
||||
_buyButton = buyButton;
|
||||
_addButton = addButton;
|
||||
|
|
@ -598,6 +622,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// track staging too — see RefreshItemsTabAvailability's doc.
|
||||
_buyStaging.Changed += RefreshItemsTabAvailability;
|
||||
_sellStaging.Changed += RebuildSellingList;
|
||||
// R3: the tabs' own staged-count/total-value/purse text must track
|
||||
// every staging change, matching retail's Update() -> ...
|
||||
// UpdateTransactionValue()/UpdateTotalValue() chain (VendorBuyUI::Update
|
||||
// pc:202996-203005, VendorSellUI::Update pc:203009-203018) — both
|
||||
// called unconditionally on EVERY staging mutation, not just Add/Remove.
|
||||
_buyStaging.Changed += UpdateBuyTransactionText;
|
||||
_sellStaging.Changed += UpdateSellTransactionText;
|
||||
// R3: a player money change (a purchase/sale elsewhere, a pickup, a
|
||||
// drop) must repaint the purse line even with staging unchanged —
|
||||
// retail's own m_totalValue/m_last_sale-driven purse text has no
|
||||
// separate "staging changed" gate from "holdings changed" (both
|
||||
// UpdateTotalValue calls read the LIVE holding fresh, same as
|
||||
// BuildCostText's own PropertyInt.CoinValue read).
|
||||
_objects.ObjectUpdated += OnObjectMoneyChanged;
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
ClearContent();
|
||||
|
|
@ -726,6 +764,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return null;
|
||||
}
|
||||
|
||||
// R3: the Buying/Selling tabs' own summary text — optional, same
|
||||
// degrade-gracefully convention as the staging buttons.
|
||||
UiText? buyListText = layout.FindElement(BuyingListTextId) as UiText;
|
||||
UiText? buyPurseText = layout.FindElement(BuyingPurseTextId) as UiText;
|
||||
UiText? sellListText = layout.FindElement(SellingListTextId) as UiText;
|
||||
UiText? sellPurseText = layout.FindElement(SellingPurseTextId) as UiText;
|
||||
|
||||
UiButton? close = layout.FindElement(CloseId) as UiButton;
|
||||
UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar;
|
||||
UiButton? buyButton = layout.FindElement(BuyButtonId) as UiButton;
|
||||
|
|
@ -770,6 +815,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
typeMenu,
|
||||
itemNameText,
|
||||
itemCostText,
|
||||
buyListText,
|
||||
buyPurseText,
|
||||
sellListText,
|
||||
sellPurseText,
|
||||
close,
|
||||
buyButton,
|
||||
addButton,
|
||||
|
|
@ -1351,10 +1400,21 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// PREVIOUSLY-selected DIFFERENT stackable item must never leak into
|
||||
/// this one — otherwise the CURRENT slider value via
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>).
|
||||
/// <para>
|
||||
/// R1 gate-finding fix (2026-08-08, register AP-169 correction): the
|
||||
/// ceiling this gates on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/>
|
||||
/// — <see cref="VendorShopItem.DescStackSize"/> when the wire carries
|
||||
/// it, else <see cref="VendorShopItem.MaxStackSize"/> — the SAME
|
||||
/// resolution <see cref="VendorShopItemMaterializer"/> uses to seed
|
||||
/// <see cref="ClientObject.StackSize"/> (and therefore the toolbar
|
||||
/// slider's own ceiling, <c>SelectedObjectController</c>). Using a
|
||||
/// narrower source here than the visible slider would let the slider
|
||||
/// show a ceiling of 1000 while every Buy still sent quantity 1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private uint ResolveBuyQuantity(VendorShopItem item)
|
||||
{
|
||||
uint stackSize = (uint)Math.Max(item.DescStackSize ?? 1, 1);
|
||||
uint stackSize = (uint)VendorSplitPolicy.ResolveAuthoredStackSize(item.DescStackSize, item.MaxStackSize);
|
||||
if (stackSize <= 1u)
|
||||
return 1u;
|
||||
|
||||
|
|
@ -1541,19 +1601,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// Retail's <c>Buy Item</c>/<c>Clear Item</c> (Buying tab) shared
|
||||
/// removal-amount rule (<c>pc:203996</c>/<c>204086</c>): stackable -> -1
|
||||
/// (full removal), else 1. Retail tests the item's own
|
||||
/// <c>pwd._maxStackSize</c> (the item TYPE's stack ceiling) — a field
|
||||
/// <see cref="VendorShopItem"/> does not carry on the wire today. This
|
||||
/// substitutes <see cref="VendorShopItem.DescStackSize"/> (the item's
|
||||
/// CURRENT authored stack depth, already threaded through for pricing)
|
||||
/// as the stackability test instead; the two agree for every case that
|
||||
/// matters in practice (<c>DescStackSize <= 1</c> implies
|
||||
/// <c>MaxStackSize <= 1</c>) and disagree only for a vendor stocking a
|
||||
/// single unit of an otherwise-stackable item TYPE, where the worst case
|
||||
/// is a staged entry decrementing by one instead of clearing outright —
|
||||
/// a minor UI residue, not a money/wire-safety issue. See the register.
|
||||
/// <c>pwd._maxStackSize</c> (the item TYPE's stack ceiling) — now a
|
||||
/// byte-exact port (register AP-165 RETIRED 2026-08-08, R1 gate
|
||||
/// finding): <see cref="VendorShopItem.MaxStackSize"/> threads the wire
|
||||
/// field through directly, so this no longer needs the
|
||||
/// <see cref="VendorShopItem.DescStackSize"/> substitute the row
|
||||
/// originally filed.
|
||||
/// </summary>
|
||||
private static int BuyStagingRemovalAmount(VendorShopItem item) =>
|
||||
(item.DescStackSize ?? 1) > 1 ? -1 : 1;
|
||||
(item.MaxStackSize ?? 1) > 1 ? -1 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Buy Item" — retail case <c>0x100000c9</c>
|
||||
|
|
@ -1718,10 +1774,192 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: the Selling tab's total-proceeds counterpart to
|
||||
/// <see cref="ComputeBuyTransactionValue"/> — retail
|
||||
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202380-202468</c>),
|
||||
/// which prices each staged row via <c>VendorProfile::VendorBuyPrice</c>
|
||||
/// (the rate the VENDOR pays when IT buys FROM the player — see
|
||||
/// <see cref="VendorPricing"/>'s naming-inversion warning). Unlike the
|
||||
/// Buying side (whose priced item lives in <see cref="_vendor"/>'s shop
|
||||
/// list), a staged SELL entry's item is the PLAYER's OWN pack item —
|
||||
/// <see cref="_objects"/>, the same source <see cref="RebuildSellingList"/>
|
||||
/// already reads for icon/type data.
|
||||
/// </summary>
|
||||
private int ComputeSellTransactionValue()
|
||||
{
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int total = 0;
|
||||
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
||||
{
|
||||
if (_objects.Get(entry.ItemGuid) is not { } item)
|
||||
continue;
|
||||
int perUnit = VendorPricing.PerUnitValue(item.Value, item.StackSize);
|
||||
total += VendorPricing.BuyPrice(perUnit, (uint)item.Type, profile.BuyPrice, entry.Quantity);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3 (user-requested retail presentation, closing AP-166's text half —
|
||||
/// grand-gate live evidence: a selected vendor taper shows the Buying
|
||||
/// tab's right-side summary "Buying 2 items worth 422p" / "You have
|
||||
/// 23p"). Retail's exact literal, recovered BYTE-VERBATIM: the Sell side
|
||||
/// is directly legible in the decompiled body of
|
||||
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202458</c>,
|
||||
/// <c>u"Selling %d %s worth %hsp"</c>); the Buy side's IDENTICALLY-SHAPED
|
||||
/// literal is mis-attributed by the decompiler to a bogus vtable-slot
|
||||
/// symbol at its own call site (<c>VendorBuyUI::UpdateTransactionValue</c>,
|
||||
/// <c>pc:202290</c>) — recovered instead by reading the binary's own
|
||||
/// data segment directly (<c>C:\Users\erikn\Downloads\acclient.exe</c>,
|
||||
/// the Sept 2013 EoR build paired with <c>refs/acclient.pdb</c>), which
|
||||
/// carries the wide string <c>"Buying %d %s worth %hsp"</c> at VA
|
||||
/// <c>0x007b58bc</c> (Sell's own literal likewise resolves at
|
||||
/// <c>0x007b5930</c>, confirming both strings byte-for-byte). The "p"
|
||||
/// after <c>%hs</c> is a LITERAL pyreal-currency suffix character —
|
||||
/// same convention as <see cref="BuildCostText"/>'s own
|
||||
/// <c>"{0} {1}p (you have {2}p)"</c> — not part of the specifier.
|
||||
/// Singular/plural ("item"/"items") gates on the STAGED COUNT (the sum
|
||||
/// of every entry's quantity), matching retail's own per-row
|
||||
/// <c>_stackSize</c> accumulator feeding the same singular/plural test.
|
||||
/// <para>
|
||||
/// Alt-currency (a rare trade-note vendor): the Buy side's exact literal
|
||||
/// IS confirmed directly legible in the decompiled body
|
||||
/// (<c>VendorBuyUI::UpdateTotalValue</c>, <c>pc:202344</c>,
|
||||
/// <c>"You have %d %s."</c>) for the PURSE line; this method's alt-
|
||||
/// currency LIST-line construction is a faithful EXTRAPOLATION of the
|
||||
/// confirmed pyreal shape (dropping the "p" suffix, substituting the
|
||||
/// currency's plural name for the value) — the exact alt-currency LIST
|
||||
/// format string was not independently recovered byte-verbatim. See the
|
||||
/// register, AP-166, for this narrow residual.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static string BuildTransactionListText(
|
||||
string verb, int count, int totalValue, VendorShopProfile profile)
|
||||
{
|
||||
string noun = count == 1 ? "item" : "items";
|
||||
if (profile.AlternateCurrencyWcid != 0u)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} {1} {2} worth {3} {4}",
|
||||
verb,
|
||||
count,
|
||||
noun,
|
||||
totalValue,
|
||||
profile.AlternateCurrencyPluralName);
|
||||
}
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} {1} {2} worth {3}p",
|
||||
verb,
|
||||
count,
|
||||
noun,
|
||||
totalValue.ToString("N0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: shared "You have %hsp" purse-text builder — retail
|
||||
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202366</c>,
|
||||
/// <c>u"You have %hsp"</c>) and <c>VendorSellUI::UpdateTotalValue</c>
|
||||
/// (<c>pc:202495</c>, the SAME literal <c>u"You have %hsp"</c>) — both
|
||||
/// directly legible in the decompiled body, byte-identical. Comma
|
||||
/// grouping matches retail's own <c>InsertCommas</c> call immediately
|
||||
/// before this format runs. The alt-currency branch's literal
|
||||
/// <c>"You have %d %s."</c> is directly legible at
|
||||
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202344</c>); the Sell
|
||||
/// side's alt-currency purse text was not independently traced but is
|
||||
/// presumed identical by symmetry — both read the SAME
|
||||
/// <c>shopVendorProfile->trade_num - m_last_sale</c> holding
|
||||
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> already
|
||||
/// substitutes for elsewhere (see the register, AP-161's <c>m_last_sale</c>
|
||||
/// residual, and AP-166 for this untraced half).
|
||||
/// </summary>
|
||||
private string BuildPurseText(VendorShopProfile profile)
|
||||
{
|
||||
if (profile.AlternateCurrencyWcid != 0u)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"You have {0} {1}.",
|
||||
(int)profile.AlternateCurrencyAmount,
|
||||
profile.AlternateCurrencyPluralName);
|
||||
}
|
||||
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"You have {0}p",
|
||||
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: repaints the Buying tab's own summary text — retail
|
||||
/// <c>VendorBuyUI::Update</c> (<c>pc:202996-203005</c>) calls
|
||||
/// <c>UpdateTransactionValue</c> then <c>UpdateTotalValue</c>
|
||||
/// unconditionally on every staging mutation.
|
||||
/// </summary>
|
||||
private void UpdateBuyTransactionText()
|
||||
{
|
||||
if (_buyListText is null && _buyPurseText is null)
|
||||
return;
|
||||
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int count = _buyStaging.Entries.Sum(e => e.Quantity);
|
||||
int totalValue = ComputeBuyTransactionValue();
|
||||
if (_buyListText is not null)
|
||||
SetPlainText(_buyListText, BuildTransactionListText("Buying", count, totalValue, profile));
|
||||
if (_buyPurseText is not null)
|
||||
SetPlainText(_buyPurseText, BuildPurseText(profile));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: the Selling tab's counterpart to <see cref="UpdateBuyTransactionText"/> —
|
||||
/// retail <c>VendorSellUI::Update</c> (<c>pc:203009-203018</c>), same shape.
|
||||
/// </summary>
|
||||
private void UpdateSellTransactionText()
|
||||
{
|
||||
if (_sellListText is null && _sellPurseText is null)
|
||||
return;
|
||||
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int count = _sellStaging.Entries.Sum(e => e.Quantity);
|
||||
int totalValue = ComputeSellTransactionValue();
|
||||
if (_sellListText is not null)
|
||||
SetPlainText(_sellListText, BuildTransactionListText("Selling", count, totalValue, profile));
|
||||
if (_sellPurseText is not null)
|
||||
SetPlainText(_sellPurseText, BuildPurseText(profile));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: a player money change (purchase/sale/pickup/drop elsewhere) must
|
||||
/// repaint BOTH tabs' purse line even when staging itself is unchanged —
|
||||
/// retail's own purse text always reads the LIVE holding fresh (same as
|
||||
/// <see cref="BuildCostText"/>'s own <c>PropertyInt.CoinValue</c> read),
|
||||
/// with no separate "did staging change" gate.
|
||||
/// </summary>
|
||||
private void OnObjectMoneyChanged(ClientObject updated)
|
||||
{
|
||||
if (updated.ObjectId != _playerGuid())
|
||||
return;
|
||||
UpdateBuyTransactionText();
|
||||
UpdateSellTransactionText();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
|
||||
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
|
||||
/// comment for the container-classification approximation.
|
||||
/// <para>
|
||||
/// R1 gate-finding fix (2026-08-08): retail's stackable test at this
|
||||
/// exact call site is <c>eax->pwd._maxStackSize <= 1</c>
|
||||
/// (<c>pc:200052</c>) — literally <c>MaxStackSize</c>, never
|
||||
/// <c>_stackSize</c>. This now reads <see cref="VendorShopItem.MaxStackSize"/>
|
||||
/// directly, a byte-exact port now that the wire field is threaded
|
||||
/// through (previously approximated with <c>DescStackSize</c>, which
|
||||
/// ACE never populates for a browse-list row, so a stackable item was
|
||||
/// always misclassified as non-stackable — see AP-169's sibling
|
||||
/// finding).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded(
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||
|
|
@ -1732,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
if (!TryFindShopItem(guid, out VendorShopItem item))
|
||||
continue;
|
||||
bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u;
|
||||
bool stackable = (item.DescStackSize ?? 1) > 1;
|
||||
bool stackable = (item.MaxStackSize ?? 1) > 1;
|
||||
if (stackable)
|
||||
{
|
||||
if (isContainer) containerSlots += 1; else itemSlots += 1;
|
||||
|
|
@ -2242,6 +2480,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// close clears a vendor-owned selection" behavior is OnObjectRemoved
|
||||
// reacting to VendorShopItemMaterializer's removal, not this method.
|
||||
ClearSelectionDisplay();
|
||||
// R3: reset the Buying/Selling summary text to its empty-staging
|
||||
// shape (retail's own text is never simply blanked — UpdateTotalValue
|
||||
// still renders "You have Np" etc. with a zero transaction) on both
|
||||
// constructor-time setup and session close/reset.
|
||||
UpdateBuyTransactionText();
|
||||
UpdateSellTransactionText();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2292,11 +2536,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_vendor.Changed -= OnVendorChanged;
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectMoneyChanged;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_buyStaging.Changed -= RebuildBuyingList;
|
||||
_buyStaging.Changed -= RefreshItemsTabAvailability;
|
||||
_buyStaging.Changed -= UpdateBuyTransactionText;
|
||||
_sellStaging.Changed -= RebuildSellingList;
|
||||
_sellStaging.Changed -= UpdateSellTransactionText;
|
||||
DismissCloseConfirmationIfOpen();
|
||||
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
|
||||
RetailTabBinding.SetClick(_itemsTab, null);
|
||||
|
|
|
|||
|
|
@ -425,6 +425,11 @@ public static class GameEventWiring
|
|||
// divisor for turning Value's stack-total wire number
|
||||
// into a per-unit display price.
|
||||
item.Desc.StackSize,
|
||||
// Grand-gate finding R1 (register AP-169 correction):
|
||||
// the item TYPE's authored stack ceiling (retail
|
||||
// PublicWeenieDesc::_maxStackSize) — see
|
||||
// VendorShopItem.MaxStackSize's doc comment.
|
||||
item.Desc.StackSizeMax,
|
||||
// Slice 5.4 review fix F5: forward the icon overlay/
|
||||
// underlay/effects PublicWeenieDescParser already
|
||||
// captures, so a shop item's icon composites the same
|
||||
|
|
|
|||
|
|
@ -42,4 +42,37 @@ public static class VendorSplitPolicy
|
|||
IsSplitExempt(itemType)
|
||||
? 1
|
||||
: authoredStackSize is { } size && size > 0 ? size : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the
|
||||
/// retail-faithful "how big is one stack of this item" answer for a
|
||||
/// VENDOR-owned selection, used everywhere retail reads
|
||||
/// <c>PublicWeenieDesc::_stackSize</c> for splitting/seeding purposes
|
||||
/// (<c>gmToolbarUI::HandleSelectionChanged</c>,
|
||||
/// <c>pc:198688</c>/<c>198744</c>/<c>198774</c>/<c>198791</c>;
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c>, <c>pc:401465-401477</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Prefers <paramref name="descStackSize"/> (the wire's own
|
||||
/// <c>PublicWeenieDesc::_stackSize</c> — retail-faithful FIRST, honored
|
||||
/// unchanged if a real retail server or a future ACE fix ever populates
|
||||
/// it). ACE never sets it for a vendor browse-list row
|
||||
/// (<c>Vendor.LoadInventoryItem</c> never calls <c>wo.SetStackSize</c>),
|
||||
/// so this falls back to <paramref name="maxStackSize"/> — the item
|
||||
/// TYPE's authored stack ceiling, which ACE DOES populate (an ordinary
|
||||
/// weenie property, not an instance-specific stack count) and which a
|
||||
/// real retail server evidently uses AS <c>_stackSize</c> for an
|
||||
/// unlimited-supply "one full stack" browse listing (the live retail
|
||||
/// screenshot: a Prismatic Taper listing shows "1000 Prismatic Tapers",
|
||||
/// 1000 being the taper's authored max stack size, not any bounded
|
||||
/// supply count). Finally falls back to 1 (non-splittable) when neither
|
||||
/// field is available. See the register, AP-169.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static int ResolveAuthoredStackSize(int? descStackSize, int? maxStackSize) =>
|
||||
descStackSize is { } desc && desc > 0
|
||||
? desc
|
||||
: maxStackSize is { } max && max > 0
|
||||
? max
|
||||
: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,23 @@ public readonly record struct VendorShopItem(
|
|||
// matching retail's own zeroed-struct default of 0 for the same case
|
||||
// (see VendorPricing.PerUnitValue's <= 0 guard).
|
||||
int? DescStackSize = null,
|
||||
// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the
|
||||
// item TYPE's authored stack ceiling (retail PublicWeenieDesc::
|
||||
// _maxStackSize, wire AcDream.Core.Net.Messages.PublicWeenieDescBody.
|
||||
// StackSizeMax). Retail's OWN InqListSlotCount (pc:200038-200065) and
|
||||
// the Buying tab's Buy Item/Clear Item removal rule
|
||||
// (gmVendorUI::HandleButtonClicks cases 0x100000c9/0x100000cb,
|
||||
// pc:203989-204010/204080-204094) read THIS field literally, never
|
||||
// DescStackSize, to decide whether an item is stackable at all. It also
|
||||
// serves as this port's retail-faithful SUBSTITUTE for the toolbar's
|
||||
// splitSize/maxSplitSize seed (gmToolbarUI::HandleSelectionChanged,
|
||||
// pc:198688/198744/198774/198791 — reads PublicWeenieDesc::_stackSize,
|
||||
// which ACE never populates for a vendor browse-list row; a real retail
|
||||
// server evidently authors _stackSize == _maxStackSize for an
|
||||
// unlimited-supply "one full stack" browse listing, so MaxStackSize is
|
||||
// the value retail's own server would have put there) — see
|
||||
// VendorSplitPolicy.ResolveAuthoredStackSize and the register (AP-169).
|
||||
int? MaxStackSize = null,
|
||||
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
|
||||
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see
|
||||
// AcDream.Core.Net.Messages.PublicWeenieDescBody. Mirrors
|
||||
|
|
|
|||
|
|
@ -226,9 +226,9 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
/// the wire-shaped merge patch <see cref="ClientObjectTable.Ingest"/>
|
||||
/// expects.
|
||||
/// <para>
|
||||
/// G2 gate-finding fix (2026-08-08, register AP-169): retail's own
|
||||
/// client (<c>gmToolbarUI::HandleSelectionChanged</c>, <c>pc:198688</c>/
|
||||
/// <c>198744</c>/<c>198774</c>/<c>198791</c>) reads
|
||||
/// R1 gate-finding fix (2026-08-08, register AP-169 correction): retail's
|
||||
/// own client (<c>gmToolbarUI::HandleSelectionChanged</c>,
|
||||
/// <c>pc:198688</c>/<c>198744</c>/<c>198774</c>/<c>198791</c>) reads
|
||||
/// <c>eax_5->pwd._stackSize</c> — our <see cref="VendorShopItem.DescStackSize"/>
|
||||
/// — uniformly for BOTH owned-inventory and vendor-owned selections to
|
||||
/// decide whether the toolbar split slider shows and what it caps at.
|
||||
|
|
@ -246,18 +246,28 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
/// for every vendor listing. Reading only <c>DescStackSize</c> here
|
||||
/// therefore left <see cref="ClientObject.StackSize"/> at its default
|
||||
/// for every materialized shop item, so the toolbar split slider never
|
||||
/// appeared for ANY vendor stack, matching the live report exactly (it
|
||||
/// DID appear for owned-inventory stacks, which ACE populates normally
|
||||
/// via ordinary pickup/loot <c>SetStackSize</c> calls). This now prefers
|
||||
/// <c>DescStackSize</c> when present (retail-faithful first, and
|
||||
/// forward-compatible with any server that DOES populate it), falling
|
||||
/// back to the packed <c>StackSize</c> supply-count field clamped to a
|
||||
/// sane positive bound — the field that IS reliably populated against
|
||||
/// ACE. The <c>StackSize == -1</c> (unlimited-supply) sentinel has no
|
||||
/// bounded per-row purchase cap in <see cref="VendorShopItem"/>'s wire
|
||||
/// shape today (no <c>_maxStackSize</c> field carried), so it falls
|
||||
/// through to the conservative "1" default rather than inventing an
|
||||
/// arbitrary ceiling — see the register.
|
||||
/// appeared for ANY vendor stack.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The G2 packed-supply-count fallback (retired 2026-08-08).</b> An
|
||||
/// earlier fix fell back to <see cref="VendorShopItem.StackSize"/> (the
|
||||
/// packed ItemProfile "how many for sale" dword) when
|
||||
/// <c>DescStackSize</c> was absent. That did not survive live testing —
|
||||
/// a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED
|
||||
/// stock (<c>StackSize == -1</c>), so the fallback produced nothing
|
||||
/// usable and the bar stayed hidden, matching the live report exactly
|
||||
/// (bare "Prismatic Taper", no bar, no count). The live retail
|
||||
/// screenshot showed "1000 Prismatic Tapers" with the bar visible and a
|
||||
/// ceiling of 1000 — 1000 being the taper's authored MAX STACK SIZE, not
|
||||
/// any bounded supply count. This now prefers <c>DescStackSize</c> when
|
||||
/// present (retail-faithful first, forward-compatible with any server
|
||||
/// that DOES populate it), falling back to
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> — the item TYPE's authored
|
||||
/// stack ceiling, which ACE DOES populate (an ordinary weenie property)
|
||||
/// and which a real retail server evidently uses AS <c>_stackSize</c>
|
||||
/// for an unlimited-supply "one full stack" browse listing. See
|
||||
/// <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> and the
|
||||
/// register, AP-169.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every other field <see cref="VendorShopItem"/> doesn't carry
|
||||
|
|
@ -276,8 +286,8 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
IconUnderlayId: item.IconUnderlayId,
|
||||
Effects: item.Effects,
|
||||
Value: item.Value,
|
||||
StackSize: ResolveDisplayStackSize(item),
|
||||
StackSizeMax: null,
|
||||
StackSize: VendorSplitPolicy.ResolveAuthoredStackSize(item.DescStackSize, item.MaxStackSize),
|
||||
StackSizeMax: item.MaxStackSize,
|
||||
Burden: null,
|
||||
ContainerId: vendorId,
|
||||
WielderId: 0u,
|
||||
|
|
@ -291,23 +301,6 @@ public sealed class VendorShopItemMaterializer : IDisposable
|
|||
Workmanship: null,
|
||||
PluralName: item.PluralName);
|
||||
|
||||
/// <summary>
|
||||
/// G2 fix: <see cref="VendorShopItem.DescStackSize"/> when the wire
|
||||
/// actually carried it (nonzero — a genuinely retail-faithful server),
|
||||
/// else the packed <see cref="VendorShopItem.StackSize"/> supply count
|
||||
/// (what ACE reliably sends) when it names a real bounded quantity,
|
||||
/// else 1 (non-splittable — the safe default for the unlimited-supply
|
||||
/// sentinel or a genuinely single-unit listing).
|
||||
/// </summary>
|
||||
private static int? ResolveDisplayStackSize(VendorShopItem item)
|
||||
{
|
||||
if (item.DescStackSize is { } desc && desc > 0)
|
||||
return desc;
|
||||
if (item.StackSize > 0)
|
||||
return item.StackSize;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue