fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
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
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>
This commit is contained in:
parent
92ea3977b6
commit
c68ad1e646
11 changed files with 1314 additions and 99 deletions
|
|
@ -231,18 +231,32 @@ internal sealed class SelectionInteractionController
|
|||
// TurnToObject branch, Use keeps sending immediately either way (the
|
||||
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
|
||||
// comment: "consume the strict 0.2-second gate, send immediately").
|
||||
if (_query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
//
|
||||
// F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself
|
||||
// gates on (ownedByPlayer || useable) is computed ONCE, up front,
|
||||
// and checked BEFORE BeginApproach — a prior version of this method
|
||||
// called BeginApproach unconditionally whenever the target was out
|
||||
// of close range, kicking off a client-predicted walk toward a
|
||||
// target the dispatch below was always going to refuse anyway (a
|
||||
// wasted, visually confusing approach with no possible Use at the
|
||||
// end of it). Reordering does not change the dispatch itself: an
|
||||
// ELIGIBLE target still sends immediately, in the same order,
|
||||
// exactly as before.
|
||||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
|
||||
|
||||
if (useable
|
||||
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
&& !approach.IsCloseRange)
|
||||
{
|
||||
_movement.BeginApproach(approach);
|
||||
}
|
||||
|
||||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
serverGuid,
|
||||
ownedByPlayer,
|
||||
ownedByPlayer || _query.IsUseable(serverGuid),
|
||||
useable,
|
||||
reservation,
|
||||
_transport,
|
||||
out uint sequence);
|
||||
|
|
|
|||
|
|
@ -303,12 +303,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
|
||||
/// sends a multi-item Buy in a single wire call. Rides the SAME
|
||||
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
|
||||
/// Retail's client-side affordability/pack-capacity pre-checks
|
||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported
|
||||
/// here either, for the same reason <see cref="TryBuy"/>'s own doc
|
||||
/// comment already gives for the single-item Buy: the server is
|
||||
/// authoritative either way (register AP-162, extended to this batched
|
||||
/// path rather than filing a second near-duplicate row).
|
||||
/// F1 (Slice 6b/6c review): retail's four client-side affordability/
|
||||
/// pack-capacity pre-checks (<c>pc:204017/204032/204053/204067</c>) are
|
||||
/// now ported, but live in the CALLER —
|
||||
/// <c>VendorUiController.BuyAllButtonPressed</c> — ahead of this
|
||||
/// method, not inside it, since they need the staged entries' prices
|
||||
/// and the player's live capacity, neither of which this method
|
||||
/// otherwise touches. <see cref="TryBuy"/>'s single-item path still has
|
||||
/// none (register AP-162, narrowed to that one remaining case).
|
||||
/// </summary>
|
||||
public bool TryBuyAll(
|
||||
uint vendorGuid,
|
||||
|
|
|
|||
|
|
@ -363,6 +363,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
||||
// case only opens a NEW one when this is 0 (pc:204155).
|
||||
private uint _closeConfirmContext;
|
||||
// F5: see DragOverGlobalTimeSink's own doc comment.
|
||||
private readonly DragOverGlobalTimeSink _dragOverSink;
|
||||
private bool _disposed;
|
||||
|
||||
private VendorUiController(
|
||||
|
|
@ -501,6 +503,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
||||
_sellingList?.RegisterDragHandler(this);
|
||||
|
||||
// F5: mount the global-time sink so a live drag hovering anywhere
|
||||
// over this window auto-switches to the Selling tab — see
|
||||
// DragOverGlobalTimeSink's and PollDragOver's own doc comments.
|
||||
_dragOverSink = new DragOverGlobalTimeSink(PollDragOver);
|
||||
_window.ContentRoot.AddChild(_dragOverSink);
|
||||
|
||||
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
|
||||
// (UiMenu draws nothing without SpriteResolve — see the popup
|
||||
// geometry class doc above) and the vendor-authored popup geometry
|
||||
|
|
@ -586,6 +594,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_sellClearListButton.OnClick = () => _sellStaging.Clear();
|
||||
|
||||
_buyStaging.Changed += RebuildBuyingList;
|
||||
// F2 (Slice 6b/6c review): the Items tab's own row visibility must
|
||||
// track staging too — see RefreshItemsTabAvailability's doc.
|
||||
_buyStaging.Changed += RefreshItemsTabAvailability;
|
||||
_sellStaging.Changed += RebuildSellingList;
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
|
|
@ -685,9 +696,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// non-empty (Q3's close-button finding). Optional — absent gracefully
|
||||
// degrades the close gate to a plain hide (see CloseButtonPressed).
|
||||
RetailDialogFactory? dialogs = null,
|
||||
// Slice 6b/6c: InqAcceptability rejection strings + retail's two
|
||||
// Buy-All affordability/capacity transient errors (deliberately not
|
||||
// ported, register AP-162) share this sink.
|
||||
// Slice 6b/6c: InqAcceptability rejection strings, the Buy-All
|
||||
// affordability/capacity pre-send guards (F1, Slice 6b/6c review),
|
||||
// and the buy/sell staging notices all share this one sink.
|
||||
Action<string>? systemMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
|
|
@ -782,6 +793,26 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
|
||||
private enum VendorPanelTab { Items, Buying, Selling }
|
||||
|
||||
/// <summary>
|
||||
/// F5 (Slice 6b/6c review): a runtime-only, zero-size, always-invisible-
|
||||
/// to-hit-testing helper that opts this window into retail's global UI
|
||||
/// message 3 — <c>gmVendorUI::ListenToGlobalMessage</c> (<c>0x004c0480</c>):
|
||||
/// <c>if (arg2 == 3) gmVendorUI::UpdateDragOver(this);</c>. This
|
||||
/// controller is not itself a <see cref="UiElement"/> (it wraps
|
||||
/// several), so it cannot directly implement <see cref="IUiGlobalTimeListener"/>
|
||||
/// the way <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s
|
||||
/// broadcast walks the ELEMENT tree, not arbitrary controllers. Mounting
|
||||
/// this tiny sink as a child of the window gives it the same periodic
|
||||
/// pulse retail's own <c>UIElementManager::UseTime</c> delivers, without
|
||||
/// adding a second per-frame plumbing path.
|
||||
/// </summary>
|
||||
private sealed class DragOverGlobalTimeSink : UiElement, IUiGlobalTimeListener
|
||||
{
|
||||
private readonly Action _onGlobalUiTime;
|
||||
public DragOverGlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
|
||||
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime();
|
||||
}
|
||||
|
||||
private void ShowTab(VendorPanelTab tab)
|
||||
{
|
||||
_itemsPage.Visible = tab == VendorPanelTab.Items;
|
||||
|
|
@ -841,6 +872,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
ClearContent();
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
_window.Hide();
|
||||
// F8 (Slice 6b/6c review): a live X-close confirmation
|
||||
// dialog must not survive the session it was confirming
|
||||
// the abandonment of — a range-triggered Close() or a
|
||||
// portal/logout Reset() while the dialog is up left it
|
||||
// dangling (a stale callback capturing this disposed
|
||||
// controller's state) before this fix.
|
||||
DismissCloseConfirmationIfOpen();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -946,7 +984,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// field, so the toolbar status bar and slider light up for the
|
||||
/// auto-selected item too.
|
||||
/// </remarks>
|
||||
private void RebuildItemList()
|
||||
private void RebuildItemList() => RebuildItemList(reselectFirst: true);
|
||||
|
||||
/// <param name="reselectFirst">
|
||||
/// F2 (Slice 6b/6c review): <see langword="true"/> for every ORIGINAL
|
||||
/// call site (category rebuild — Opened/Refreshed/manual category
|
||||
/// switch, retail's notify=1 case per the remarks above).
|
||||
/// <see langword="false"/> for the NEW staging-triggered repaint this
|
||||
/// review added (<see cref="RefreshItemsTabAvailability"/>) — retail's
|
||||
/// <c>RemoveFromShop</c>/<c>DeleteItem</c> (the function that actually
|
||||
/// hides/shrinks a row as staging consumes it) never reselects to the
|
||||
/// first item; only a full <c>UpdateItemsList</c> rebuild does that, and
|
||||
/// staging a Buy does not trigger one.
|
||||
/// </param>
|
||||
private void RebuildItemList(bool reselectFirst)
|
||||
{
|
||||
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
||||
? _presentCategories[_selectedCategoryIndex].Mask
|
||||
|
|
@ -963,6 +1014,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
// Changed to correct it).
|
||||
uint? selectedGuid = _selection.SelectedObjectId;
|
||||
VendorShopItem? firstItem = null;
|
||||
bool selectedStillVisible = false;
|
||||
|
||||
using (_itemList.DeferLayout())
|
||||
{
|
||||
|
|
@ -972,8 +1024,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
foreach (VendorShopItem item in items)
|
||||
{
|
||||
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
||||
// F2 (Slice 6b/6c review): a shop item fully consumed by
|
||||
// staging hides its row — port of
|
||||
// VendorItemsUI::RemoveFromShop's DeleteItem branch
|
||||
// (pc:202846-202852) — see AvailableShopQuantity's own
|
||||
// doc comment.
|
||||
if (AvailableShopQuantity(item) <= 0) continue;
|
||||
|
||||
firstItem ??= item;
|
||||
if (item.ItemGuid == selectedGuid) selectedStillVisible = true;
|
||||
|
||||
// F5 (Slice 5.4 review): forward the icon underlay/
|
||||
// overlay/effects PublicWeenieDescParser already
|
||||
|
|
@ -1004,21 +1063,68 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
}
|
||||
}
|
||||
|
||||
// F6: unconditional — no survival test. Every rebuild call site
|
||||
// (Opened/Refreshed via RebuildCategories, and a manual category
|
||||
// switch via SelectCategory) is retail's notify=1 case; see the
|
||||
// remarks above for the decomp trace.
|
||||
if (firstItem is { } first)
|
||||
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
||||
else
|
||||
_selection.Clear(SelectionChangeSource.Vendor);
|
||||
if (reselectFirst)
|
||||
{
|
||||
// F6: unconditional — no survival test. Every rebuild call site
|
||||
// (Opened/Refreshed via RebuildCategories, and a manual category
|
||||
// switch via SelectCategory) is retail's notify=1 case; see the
|
||||
// remarks above for the decomp trace.
|
||||
if (firstItem is { } first)
|
||||
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
||||
else
|
||||
_selection.Clear(SelectionChangeSource.Vendor);
|
||||
|
||||
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
||||
// guards on the list being non-empty; resetting an already-empty
|
||||
// list's scroll is harmless).
|
||||
_itemList.Scroll.SetScrollY(0);
|
||||
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
||||
// guards on the list being non-empty; resetting an already-empty
|
||||
// list's scroll is harmless).
|
||||
_itemList.Scroll.SetScrollY(0);
|
||||
}
|
||||
else if (selectedGuid is not null && !selectedStillVisible)
|
||||
{
|
||||
// F2: the currently-selected row just disappeared (staging
|
||||
// consumed its last unit) — matches RemoveFromShop's own
|
||||
// unconditional SetSelectedObject(0, 0) on the delete path
|
||||
// (pc:202848-202850), NOT a reselect-to-first.
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.Vendor,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F2 (Slice 6b/6c review): retail's <c>RemoveFromShop</c>
|
||||
/// (<c>0x004c3ce0</c>) recomputes "shop remaining = ORIGINAL vendor
|
||||
/// supply - the item's CURRENT total staged amount" fresh on every call
|
||||
/// (<c>pc:202844</c>: <c>_maxStackSize_1 = var_c - arg3</c>, where
|
||||
/// <c>var_c</c> is read fresh from the untouched <c>shopItemProfileList</c>
|
||||
/// and <c>arg3</c> is <c>AddToBuyList</c>'s own running accumulated
|
||||
/// total) rather than incrementally decrementing a mutable counter.
|
||||
/// This mirrors that shape exactly: <see cref="VendorState.Items"/>
|
||||
/// (the untouched <c>ApproachVendor</c> snapshot) combined with
|
||||
/// <see cref="_buyStaging"/>'s current total, recomputed on every call
|
||||
/// — so un-staging automatically restores a hidden/shrunk row with no
|
||||
/// separate "restore" code path. <see cref="int.MaxValue"/> for
|
||||
/// unlimited supply (<see cref="VendorShopItem.StackSize"/> == -1,
|
||||
/// retail's <c>ItemProfile</c> sign-extended unlimited sentinel) —
|
||||
/// retail's own <c>var_c != 0xffffffff</c> guard skips the whole
|
||||
/// decrement/delete branch for unlimited stock.
|
||||
/// </summary>
|
||||
private int AvailableShopQuantity(VendorShopItem item)
|
||||
{
|
||||
if (item.StackSize < 0)
|
||||
return int.MaxValue;
|
||||
int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0;
|
||||
return item.StackSize - staged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F2 (Slice 6b/6c review): repaints the Items tab's row visibility
|
||||
/// whenever <see cref="_buyStaging"/> changes, WITHOUT the
|
||||
/// reselect-to-first behavior a full category rebuild performs — see
|
||||
/// <see cref="RebuildItemList(bool)"/>'s <c>reselectFirst</c> doc.
|
||||
/// </summary>
|
||||
private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail row selection display —
|
||||
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
||||
|
|
@ -1115,6 +1221,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
{
|
||||
_ = transition;
|
||||
RefreshSelectionDisplay();
|
||||
// F9 (Slice 6b/6c review): a click on an already-staged Buying/
|
||||
// Selling row calls SelectionState.Select the same way an Items-tab
|
||||
// row does (RebuildBuyingList/RebuildSellingList's own Clicked
|
||||
// handlers), but those two lists are only ever REBUILT when their
|
||||
// OWN staging list changes -- a pure selection change (no staging
|
||||
// mutation) never repainted their cell.Selected flags, so the
|
||||
// highlight silently failed to move onto a staged row. Update both
|
||||
// strips' highlight in place on every selection transition, no
|
||||
// matter which panel originated it -- mirrors RefreshSelectionDisplay's
|
||||
// own "react to ANY global selection change" shape.
|
||||
RefreshStagingSelectionHighlight();
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="OnSelectionTransition"/>'s F9 note.</summary>
|
||||
private void RefreshStagingSelectionHighlight()
|
||||
{
|
||||
uint? selected = _selection.SelectedObjectId;
|
||||
SetHighlight(_buyingList, selected);
|
||||
SetHighlight(_sellingList, selected);
|
||||
|
||||
static void SetHighlight(UiItemList? list, uint? selectedGuid)
|
||||
{
|
||||
if (list is null) return;
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
{
|
||||
if (list.GetItem(i) is { } cell)
|
||||
cell.Selected = cell.ItemId == selectedGuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1158,6 +1293,33 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// (Slice 6.1) is therefore what actually drives "vendor session close
|
||||
/// clears a vendor-owned selection."
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession
|
||||
/// sites, both reachable through this SAME <see cref="ClientObjectTable.ObjectRemoved"/>
|
||||
/// notification in this architecture.
|
||||
/// <para>
|
||||
/// <b>Sell side</b> — <c>gmVendorUI::RecvNotice_ServerSaysMoveItem</c>
|
||||
/// (<c>0x004c44a0</c>): a staged SELL item silently unstages once
|
||||
/// <c>ACCWeenieObject::IsOwnedByPlayer</c> goes false for it (no notice
|
||||
/// shown). The closest reachable proxy here is "the item left
|
||||
/// <see cref="ClientObjectTable"/> entirely" — a strictly narrower
|
||||
/// trigger than retail's "moved to ANY non-player container," but the
|
||||
/// only one an item leaving the table for real (destroyed, traded away
|
||||
/// and never re-registered, sold through a different path) reaches.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Buy side</b> — <c>VendorItemsUI</c>'s shop-list-removal notice
|
||||
/// (<c>0x004c4246</c>, inside <c>gmVendorUI::HandleMousePresses</c>,
|
||||
/// <c>pc:203165</c>: <c>"Removing %s from shopping list"</c>). A staged
|
||||
/// BUY guid that drops out of the vendor's CURRENT stock (sold out by
|
||||
/// someone else, or a different vendor superseded this session) is
|
||||
/// retired from <see cref="ClientObjectTable"/> by
|
||||
/// <c>VendorShopItemMaterializer</c>'s own retire pass on every
|
||||
/// Opened/Refreshed/Closed/Reset transition — which fires this SAME
|
||||
/// event, giving one unified site for both retail sources instead of a
|
||||
/// separate "diff the vendor's item list" listener.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (_selection.SelectedObjectId == item.ObjectId)
|
||||
|
|
@ -1166,6 +1328,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
SelectionChangeSource.Vendor,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
|
||||
// Sell side: silent, matching retail's own RecvNotice_ServerSaysMoveItem.
|
||||
_sellStaging.Remove(item.ObjectId, -1);
|
||||
|
||||
// Buy side: retail's exact "Removing %s from shopping list" notice.
|
||||
if (_buyStaging.Remove(item.ObjectId, -1))
|
||||
{
|
||||
string name = string.IsNullOrWhiteSpace(item.Name) ? "that item" : item.Name;
|
||||
_systemMessage?.Invoke($"Removing {name} from shopping list");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1350,6 +1522,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// SAME quantity computation the display and Buy button already share
|
||||
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
|
||||
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
|
||||
/// F2 (Slice 6b/6c review): re-adding an already-staged item ACCUMULATES
|
||||
/// rather than overwrites, and retail's 5000-unit cap on that
|
||||
/// accumulate shows <see cref="VendorStagingList.TooMuchMessage"/> and
|
||||
/// leaves the entry unchanged — see <see cref="VendorStagingList.Add"/>.
|
||||
/// </summary>
|
||||
private void AddSelectedToBuyList()
|
||||
{
|
||||
|
|
@ -1357,7 +1533,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
return;
|
||||
|
||||
uint quantity = ResolveBuyQuantity(shopItem);
|
||||
_buyStaging.Add(shopItem.ItemGuid, (int)quantity);
|
||||
if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped)
|
||||
_systemMessage?.Invoke(VendorStagingList.TooMuchMessage);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1402,16 +1579,64 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact affordability-failure notice — read from the
|
||||
/// decompiled binary's data segment at <c>0x007b57b4</c> (BuyAllButtonPressed's
|
||||
/// two guards below share this SAME string; see the class doc).
|
||||
/// </summary>
|
||||
private const string NotEnoughMoneyMessage = "You don't have enough money";
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact capacity-failure notice — read from the decompiled
|
||||
/// binary's data segment at <c>0x007b5750</c> (BuyAllButtonPressed's
|
||||
/// two capacity guards below share this SAME string; see the class doc).
|
||||
/// </summary>
|
||||
private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first";
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
|
||||
/// (<c>pc:204011-204079</c>). Sends every staged entry as ONE batched
|
||||
/// Buy (<see cref="ItemInteractionController.TryBuyAll"/>). Retail's
|
||||
/// client-side affordability/pack-capacity pre-checks
|
||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported —
|
||||
/// see <c>TryBuyAll</c>'s own doc comment (register AP-162, extended
|
||||
/// rather than duplicated). On a successful DISPATCH the whole staged
|
||||
/// list is flushed UNCONDITIONALLY and immediately, matching retail's
|
||||
/// literal order: <c>SendShopEvent(...)</c> then
|
||||
/// (<c>pc:204011-204079</c>, <c>0x004c5388</c>). Sends every staged
|
||||
/// entry as ONE batched Buy (<see cref="ItemInteractionController.TryBuyAll"/>).
|
||||
/// <para>
|
||||
/// F1 (Slice 6b/6c review): retail's FOUR client-side pre-send guards
|
||||
/// are ported here, in order, each returning with staging fully
|
||||
/// intact on failure (amends AP-162's "no client-side pre-check"
|
||||
/// claim — see the register):
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>pyreal affordability — transaction total vs. purse
|
||||
/// (<c>pc:204017</c>: <c>m_transactionValue <= m_totalValue</c>).</item>
|
||||
/// <item>alt-currency affordability — vs. held trade currency minus
|
||||
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
|
||||
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
|
||||
/// so this uses the vendor's raw held count, retail's own
|
||||
/// <c>m_last_sale == 0</c> case.</item>
|
||||
/// <item>container-slot capacity (<c>pc:204053</c>:
|
||||
/// <c>containerSlotsNeeded > player.ContainersCapacity - containersUsed</c>).</item>
|
||||
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
|
||||
/// item slots).</item>
|
||||
/// </list>
|
||||
/// Both affordability guards share <see cref="NotEnoughMoneyMessage"/>;
|
||||
/// both capacity guards share <see cref="NotEnoughRoomMessage"/> —
|
||||
/// retail's own two distinct <c>StringInfo</c> literal sites collapse
|
||||
/// to exactly these two strings (<c>pc:204020</c>/<c>204034</c> both
|
||||
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
|
||||
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
|
||||
/// <para>
|
||||
/// <b>Container-vs-item slot classification (register AP-168).</b>
|
||||
/// Retail's own split tests a bitfield bit this codebase does not
|
||||
/// currently thread onto <see cref="VendorShopItem"/>
|
||||
/// (<c>gmVendorUI::InqListSlotCount</c>, <c>pc:200038-200065</c>) — this
|
||||
/// port approximates "is this shop item a container" with
|
||||
/// <see cref="ItemType.Container"/> instead, correct for the ordinary
|
||||
/// case (a real backpack/pouch DOES carry that type bit) but not
|
||||
/// byte-identical for the theoretical case of a non-<c>Container</c>-typed
|
||||
/// item that still authors nonzero pack/side capacities. See the
|
||||
/// register.
|
||||
/// </para>
|
||||
/// On a successful DISPATCH the whole staged list is flushed
|
||||
/// UNCONDITIONALLY and immediately, matching retail's literal order:
|
||||
/// <c>SendShopEvent(...)</c> then
|
||||
/// <c>PackableList::Flush(&m_buyList)</c> (<c>pc:204075-204076</c>) —
|
||||
/// the clear happens right after the send, not gated on the eventual
|
||||
/// server response/UseDone.
|
||||
|
|
@ -1425,10 +1650,105 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||
items.Add((entry.Quantity, entry.ItemGuid));
|
||||
|
||||
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid))
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int transactionValue = ComputeBuyTransactionValue();
|
||||
|
||||
// Guards 1/2: pyreal vs. alt-currency affordability.
|
||||
if (profile.AlternateCurrencyWcid == 0u)
|
||||
{
|
||||
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
||||
if (transactionValue > playerTotal)
|
||||
{
|
||||
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
|
||||
{
|
||||
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guards 3/4: container-slot then item-slot capacity.
|
||||
(int itemSlotsNeeded, int containerSlotsNeeded) = ComputeBuySlotsNeeded(items);
|
||||
ClientObject? player = _objects.Get(_playerGuid());
|
||||
(int itemsUsed, int containersUsed) = CountPlayerContents();
|
||||
|
||||
int freeContainerSlots = (player?.ContainersCapacity ?? 0) - containersUsed;
|
||||
if (containerSlotsNeeded > freeContainerSlots)
|
||||
{
|
||||
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
||||
return;
|
||||
}
|
||||
int freeItemSlots = (player?.ItemsCapacity ?? 0) - itemsUsed;
|
||||
if (itemSlotsNeeded > freeItemSlots)
|
||||
{
|
||||
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
|
||||
_buyStaging.Clear();
|
||||
}
|
||||
|
||||
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
|
||||
private int ComputeBuyTransactionValue()
|
||||
{
|
||||
VendorShopProfile profile = _vendor.Profile;
|
||||
int total = 0;
|
||||
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||
{
|
||||
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem item))
|
||||
continue;
|
||||
int perUnit = VendorPricing.PerUnitValue(item.Value ?? 0, item.DescStackSize);
|
||||
total += VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, entry.Quantity);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded(
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||
{
|
||||
int itemSlots = 0, containerSlots = 0;
|
||||
foreach ((int amount, uint guid) in items)
|
||||
{
|
||||
if (!TryFindShopItem(guid, out VendorShopItem item))
|
||||
continue;
|
||||
bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u;
|
||||
bool stackable = (item.DescStackSize ?? 1) > 1;
|
||||
if (stackable)
|
||||
{
|
||||
if (isContainer) containerSlots += 1; else itemSlots += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isContainer) containerSlots += amount; else itemSlots += amount;
|
||||
}
|
||||
}
|
||||
return (itemSlots, containerSlots);
|
||||
}
|
||||
|
||||
/// <summary>F1: the player's CURRENT occupied item/container slot counts.</summary>
|
||||
private (int Items, int Containers) CountPlayerContents()
|
||||
{
|
||||
int items = 0, containers = 0;
|
||||
foreach (uint guid in _objects.GetContents(_playerGuid()))
|
||||
{
|
||||
ClientObject? obj = _objects.Get(guid);
|
||||
bool isContainer = obj is not null
|
||||
&& (obj.ItemsCapacity > 0
|
||||
|| obj.ContainersCapacity > 0
|
||||
|| (obj.Type & ItemType.Container) != 0);
|
||||
if (isContainer) containers++; else items++;
|
||||
}
|
||||
return (items, containers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
|
||||
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
|
||||
|
|
@ -1446,25 +1766,74 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_buyStaging.Remove(guid, amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact refusal for a partial-stack Sell Item attempt,
|
||||
/// read from the decompiled binary's data segment at
|
||||
/// <c>0x007b57ec</c> (<c>gmVendorUI::SellSingleItem</c>,
|
||||
/// <c>pc:201860-201864</c>).
|
||||
/// </summary>
|
||||
private const string CannotSellPartialStackMessage = "Cannot sell part of a stack";
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
|
||||
/// (<c>pc:204101-204112</c>). Sells the globally-selected staged item
|
||||
/// immediately (a one-entry <see cref="ItemInteractionController.TrySell"/>
|
||||
/// list using the entry's OWN staged quantity — Sell has no separate
|
||||
/// global-slider read the way Buy does), then on a successful dispatch
|
||||
/// removes the ENTIRE staged entry unconditionally — retail always
|
||||
/// passes <c>0xffffffff</c> here (<c>pc:204108</c>), unlike the Buy
|
||||
/// side's stackable-conditional amount.
|
||||
/// (<c>pc:204101-204112</c>), calling <c>gmVendorUI::SellSingleItem</c>
|
||||
/// (<c>pc:201808-201881</c>, <c>0x004c2b40</c>).
|
||||
/// <para>
|
||||
/// F13 (Slice 6b/6c review): retail reads <c>ACCWeenieObject::selectedID</c>
|
||||
/// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid
|
||||
/// actually staged" requirement at all. A prior version of this port
|
||||
/// required a matching <see cref="_sellStaging"/> entry first; that
|
||||
/// gate does not exist in retail (you can Sell Item something you
|
||||
/// never dragged onto the Selling tab, exactly like the Items tab's
|
||||
/// own single-item Buy button operates on the selection with no
|
||||
/// staging requirement either).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// F6 (Slice 6b/6c review, byte-verified): <c>SellSingleItem</c>
|
||||
/// refuses a PARTIAL stack — the selected item's own split slider must
|
||||
/// show the FULL stack (or the item must be non-stackable), else it
|
||||
/// shows <see cref="CannotSellPartialStackMessage"/> and sends NOTHING
|
||||
/// (<c>pc:201833-201864</c>: <c>_stackSize<=1 || splitSize>=maxSplitSize</c>
|
||||
/// gates the send). On success it sends amount <c>1</c> LITERALLY
|
||||
/// (<c>var_9c = 1</c>, <c>pc:201838</c>) — not the stack size — matching
|
||||
/// retail's own send exactly rather than <see cref="VendorStagingEntry.Quantity"/>.
|
||||
/// This method does NOT re-run <see cref="EvaluateSellAcceptability"/>'s
|
||||
/// ownership/type/value gate — retail's own <c>SellSingleItem</c>
|
||||
/// doesn't either at this call site (that gate is drag-time only,
|
||||
/// <c>VendorSellUI::DragItemAcceptable</c>); the server remains
|
||||
/// authoritative for a selection that was never legitimately
|
||||
/// draggable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Retail's own non-empty-container refusal branch inside
|
||||
/// <c>SellSingleItem</c> (<c>pc:201818-201829</c>, a container-type
|
||||
/// item with contents currently blocks a Sell Item attempt on the
|
||||
/// CONTAINER itself with a distinct message) is NOT ported here — see
|
||||
/// the register.
|
||||
/// </para>
|
||||
/// On a successful dispatch the item's own staged entry (if any) is
|
||||
/// still removed unconditionally, mirroring retail's own
|
||||
/// <c>RemoveProfileFromList(&m_sellList, selectedID, 0xffffffff)</c>
|
||||
/// (<c>pc:204108</c>), which runs regardless of whether the sold item
|
||||
/// was ever actually staged.
|
||||
/// </summary>
|
||||
private void SellItemButtonPressed()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid
|
||||
|| !_sellStaging.TryGet(guid, out VendorStagingEntry entry))
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid || _objects.Get(guid) is not { } item)
|
||||
return;
|
||||
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
if (fullStack > 1)
|
||||
{
|
||||
uint live = _splitQuantity.GetObjectSplitSize(guid, guid, fullStack);
|
||||
if (live < fullStack)
|
||||
{
|
||||
_systemMessage?.Invoke(CannotSellPartialStackMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) }))
|
||||
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (1, guid) }))
|
||||
_sellStaging.Remove(guid, -1);
|
||||
}
|
||||
|
||||
|
|
@ -1617,6 +1986,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
: ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F5 (Slice 6b/6c review): port of <c>gmVendorUI::UpdateDragOver</c>
|
||||
/// (<c>0x004c03b0</c>, <c>pc:199542-199553</c>), driven by
|
||||
/// <see cref="DragOverGlobalTimeSink"/>'s periodic pulse. While the
|
||||
/// window is visible, the Selling page is NOT already the open one, and
|
||||
/// a drag is currently live anywhere in the whole UI (retail's
|
||||
/// <c>UIElementManager::s_pInstance->m_dragElement != 0</c>, here
|
||||
/// <see cref="UiRoot.DragSource"/>), retail opens the Selling tab the
|
||||
/// instant the pointer enters the WINDOW'S bounds — not any specific
|
||||
/// list — making <see cref="_sellingList"/> a reachable drop target
|
||||
/// without the player manually clicking the tab first.
|
||||
/// <para>
|
||||
/// <b>Citation correction.</b> Retail's own guard token is
|
||||
/// <c>m_OpenPageToken != 0x100000cd</c> — the review that flagged this
|
||||
/// finding described <c>0x100000cd</c> as "the Buying page," but this
|
||||
/// controller's own <see cref="SellingPageId"/> constant is
|
||||
/// <c>0x100000CD</c>, not <see cref="BuyingPageId"/>
|
||||
/// (<c>0x100000C4</c>). The guard is "don't reopen the tab you're
|
||||
/// already on," checked against the SELLING page specifically —
|
||||
/// <see cref="_sellingPage"/>'s own <c>Visible</c> flag is the exact
|
||||
/// analogue.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void PollDragOver()
|
||||
{
|
||||
if (_sellingPage.Visible) return;
|
||||
if (!_window.IsVisible) return;
|
||||
|
||||
UiRoot? root = _itemsPage.FindRoot();
|
||||
if (root?.DragSource is null) return;
|
||||
|
||||
System.Numerics.Vector2 pos = _window.OuterFrame.ScreenPosition;
|
||||
float x0 = pos.X, y0 = pos.Y;
|
||||
float x1 = x0 + _window.OuterFrame.Width, y1 = y0 + _window.OuterFrame.Height;
|
||||
if (root.MouseX > x0 && root.MouseX < x1 && root.MouseY > y0 && root.MouseY < y1)
|
||||
ShowTab(VendorPanelTab.Selling);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
|
||||
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
|
||||
|
|
@ -1652,19 +2059,21 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// fed from the dragged item's own <see cref="ClientObject"/> state and
|
||||
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
|
||||
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
||||
/// would use: 1 for a non-stack item, else the LIVE split-quantity
|
||||
/// slider value for the dragged item — the SAME read
|
||||
/// <c>ExternalContainerController.HandleDropRelease</c> already uses
|
||||
/// (<c>StackSplitQuantityState.GetObjectSplitSize</c> only returns the
|
||||
/// slider value for the CURRENTLY selected item; both
|
||||
/// <c>InventoryController</c> and <c>PaperdollController</c> already
|
||||
/// select the dragged item on LIFT — <c>ItemList_BeginDrag @
|
||||
/// 0x004E32D0</c> — so by the time a drop reaches here the dragged item
|
||||
/// is already the global selection in the realistic flow). The Slice
|
||||
/// 6b/6c research doc's Q4 section flags this exact quantity source as
|
||||
/// an inferred analogy to the Buying tab's <c>AddToBuyList</c>, not a
|
||||
/// byte-verified citation for the Selling side specifically — see
|
||||
/// <c>VendorStagingList.Add</c>'s own doc comment.
|
||||
/// would use.
|
||||
/// <para>
|
||||
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
|
||||
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
|
||||
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
|
||||
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
|
||||
/// argument, never a slider read. A prior version of this port read the
|
||||
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
|
||||
/// doc's Q4 section had flagged this exact source as an unverified
|
||||
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
|
||||
/// inference is now known WRONG: Sell staging has no partial-quantity
|
||||
/// feature in retail at all, unlike Buy. See
|
||||
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
|
||||
/// (genuinely slider-driven) contrast.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
||||
{
|
||||
|
|
@ -1683,15 +2092,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
perUnitValue,
|
||||
_vendor.Profile.MerchandiseItemTypes,
|
||||
_vendor.Profile.MerchandiseMinValue,
|
||||
_vendor.Profile.MerchandiseMaxValue);
|
||||
_vendor.Profile.MerchandiseMaxValue,
|
||||
item.PublicWeenieBitfield ?? 0u);
|
||||
|
||||
if (rejection == VendorSellRejection.None)
|
||||
{
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
quantity = fullStack > 1
|
||||
? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack)
|
||||
: 1;
|
||||
}
|
||||
quantity = (int)Math.Max(1, item.StackSize);
|
||||
return rejection;
|
||||
}
|
||||
|
||||
|
|
@ -1737,8 +2142,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
/// which preserves the player's category selection) instead of a full
|
||||
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's
|
||||
/// declared string length (<c>0x53</c> wchar16) truncates the literal
|
||||
/// mid-sentence, but the raw bytes immediately following it
|
||||
/// (<c>0x007b5c7e</c>) are <c>3f 00</c> — UTF-16LE for <c>'?'</c> —
|
||||
/// before the null terminator. Retail's data segment carries a trailing
|
||||
/// question mark this port previously dropped.
|
||||
/// </summary>
|
||||
private const string CloseConfirmationMessage =
|
||||
"You have not completed all transactions. Are you sure you want to leave this vendor";
|
||||
"You have not completed all transactions. Are you sure you want to leave this vendor?";
|
||||
|
||||
private void CloseButtonPressed()
|
||||
{
|
||||
|
|
@ -1763,6 +2176,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F8 (Slice 6b/6c review): shared by <see cref="Dispose"/> and the
|
||||
/// session Closed/Reset arm of <see cref="OnVendorChanged"/> — a live
|
||||
/// X-close confirmation dialog must not outlive the controller or the
|
||||
/// vendor session it was asking about.
|
||||
/// </summary>
|
||||
private void DismissCloseConfirmationIfOpen()
|
||||
{
|
||||
if (_closeConfirmContext == 0u)
|
||||
return;
|
||||
_dialogs?.CloseDialog(_closeConfirmContext);
|
||||
_closeConfirmContext = 0u;
|
||||
}
|
||||
|
||||
private void ClearContent()
|
||||
{
|
||||
_presentCategories.Clear();
|
||||
|
|
@ -1830,12 +2257,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
|||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_buyStaging.Changed -= RebuildBuyingList;
|
||||
_buyStaging.Changed -= RefreshItemsTabAvailability;
|
||||
_sellStaging.Changed -= RebuildSellingList;
|
||||
if (_closeConfirmContext != 0u)
|
||||
{
|
||||
_dialogs?.CloseDialog(_closeConfirmContext);
|
||||
_closeConfirmContext = 0u;
|
||||
}
|
||||
DismissCloseConfirmationIfOpen();
|
||||
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
|
||||
RetailTabBinding.SetClick(_itemsTab, null);
|
||||
RetailTabBinding.SetClick(_buyingTab, null);
|
||||
RetailTabBinding.SetClick(_sellingTab, null);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ public enum PublicWeenieFlags : uint
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,28 @@ public enum VendorSellRejection
|
|||
/// <summary>
|
||||
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
|
||||
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
|
||||
/// the generic case in practice, since a genuine bitmask is almost never
|
||||
/// literally 1-4 (Slice 6b/6c research doc's open question #3).
|
||||
/// 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,
|
||||
|
||||
|
|
@ -40,6 +60,19 @@ public enum VendorSellRejection
|
|||
/// <summary>
|
||||
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
|
||||
/// <c>max_value != -1 && value > 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 >> 16)) & 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 >> 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,
|
||||
|
||||
|
|
@ -84,6 +117,18 @@ public static class VendorSellAcceptability
|
|||
/// 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,
|
||||
|
|
@ -91,21 +136,34 @@ public static class VendorSellAcceptability
|
|||
int perUnitValue,
|
||||
uint merchandiseItemTypes,
|
||||
uint merchandiseMinValue,
|
||||
uint merchandiseMaxValue)
|
||||
uint merchandiseMaxValue,
|
||||
uint publicWeenieBitfield = 0u)
|
||||
{
|
||||
if (!ownedByPlayer)
|
||||
return VendorSellRejection.NotOwnedByPlayer;
|
||||
if (containedItemCount > 0)
|
||||
return VendorSellRejection.None;
|
||||
|
||||
if ((itemTypeMask & merchandiseItemTypes) == 0u)
|
||||
// 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)
|
||||
return VendorSellRejection.TooValuable;
|
||||
{
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -16,8 +16,38 @@ public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity);
|
|||
/// 6b/6c research doc's open question #2); this is the one generic list both
|
||||
/// tab controllers own an instance of, rather than two near-duplicate types.
|
||||
/// </summary>
|
||||
/// <summary>Outcome of <see cref="VendorStagingList.Add"/> — see its doc comment.</summary>
|
||||
public enum VendorStagingAddOutcome
|
||||
{
|
||||
/// <summary>Staged: a new entry was appended, or an existing one accumulated.</summary>
|
||||
Added,
|
||||
/// <summary>
|
||||
/// Retail's 5000-unit cap (<c>0x1388</c>) on an ACCUMULATED total would
|
||||
/// be exceeded — rejected, the staged entry is unchanged. Show
|
||||
/// <see cref="VendorStagingList.TooMuchMessage"/>.
|
||||
/// </summary>
|
||||
Capped,
|
||||
/// <summary>Defensive no-op (zero guid or non-positive quantity) — never a retail-modeled path; do not message.</summary>
|
||||
Ignored,
|
||||
}
|
||||
|
||||
public sealed class VendorStagingList
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's cap on a staged entry's ACCUMULATED total — <c>0x1388</c>
|
||||
/// (5000), <c>VendorItemsUI::AddToBuyList</c>'s comparison
|
||||
/// (<c>pc:202936</c>, <c>0x004c3e73</c>).
|
||||
/// </summary>
|
||||
public const int MaxStagedQuantity = 0x1388;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact over-cap notice, read from the decompiled binary's
|
||||
/// data segment at <c>0x007b59d8</c> (<c>VendorItemsUI::AddToBuyList</c>,
|
||||
/// <c>pc:202938-202949</c>).
|
||||
/// </summary>
|
||||
public const string TooMuchMessage =
|
||||
"I can't possibly sell you that much! Please be a little more reasonable.";
|
||||
|
||||
private readonly List<VendorStagingEntry> _entries = new();
|
||||
|
||||
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
|
||||
|
|
@ -27,28 +57,40 @@ public sealed class VendorStagingList
|
|||
|
||||
/// <summary>
|
||||
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
|
||||
/// (Slice 6b/6c research doc §Q3): stages <paramref name="quantity"/>
|
||||
/// units of <paramref name="itemGuid"/>. Re-adding an already-staged guid
|
||||
/// (e.g. pressing "Add to List" again after moving the slider) UPSERTS
|
||||
/// the entry to the new quantity rather than appending a duplicate row —
|
||||
/// retail's own <c>RemoveProfileFromList</c> looks up an entry BY GUID
|
||||
/// (a single match), which only holds if <c>AddToBuyList</c> never
|
||||
/// produces two rows for the same guid; the decomp excerpt available to
|
||||
/// this port does not show the insert side of that invariant directly,
|
||||
/// so this is a deliberate, documented inference from the removal side's
|
||||
/// single-match contract, not a byte-verified citation.
|
||||
/// (<c>pc:202884-202989</c>, <c>0x004c3dc0</c> — read in full for the
|
||||
/// Slice 6b/6c review; the insert side is byte-verified below, not
|
||||
/// inferred). Re-adding an already-staged guid (e.g. pressing "Add to
|
||||
/// List" again after moving the slider) ACCUMULATES
|
||||
/// <paramref name="quantity"/> onto the entry's EXISTING staged amount
|
||||
/// (<c>eax_3 = arg3 + var_9c</c>, <c>pc:202934</c>) — NOT an upsert/
|
||||
/// overwrite, the shape a prior version of this port used before this
|
||||
/// review. A brand-new entry (no existing match) is inserted at
|
||||
/// <paramref name="quantity"/> directly with NO cap check
|
||||
/// (<c>label_4c3e20</c>, <c>pc:202895-202923</c>) — the 5000-unit cap
|
||||
/// only guards the ACCUMULATE branch. Exceeding the cap on an
|
||||
/// accumulate shows <see cref="TooMuchMessage"/> and leaves the entry
|
||||
/// COMPLETELY UNCHANGED (no partial accumulate, no shop-row effect —
|
||||
/// <c>pc:202936-202951</c> jumps straight past both).
|
||||
/// </summary>
|
||||
public void Add(uint itemGuid, int quantity)
|
||||
public VendorStagingAddOutcome Add(uint itemGuid, int quantity)
|
||||
{
|
||||
if (itemGuid == 0u || quantity <= 0)
|
||||
return;
|
||||
return VendorStagingAddOutcome.Ignored;
|
||||
|
||||
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
||||
if (index >= 0)
|
||||
_entries[index] = new VendorStagingEntry(itemGuid, quantity);
|
||||
{
|
||||
int total = _entries[index].Quantity + quantity;
|
||||
if (total > MaxStagedQuantity)
|
||||
return VendorStagingAddOutcome.Capped;
|
||||
_entries[index] = new VendorStagingEntry(itemGuid, total);
|
||||
}
|
||||
else
|
||||
{
|
||||
_entries.Add(new VendorStagingEntry(itemGuid, quantity));
|
||||
}
|
||||
Changed?.Invoke();
|
||||
return VendorStagingAddOutcome.Added;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue